From 51de7f11026093dfddcc70a658c956a3a355e3bb Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:06:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E9=AA=8C=E8=AF=81=E5=92=8CYAML=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现了配置模式解析器,支持递归对象/数组/标量树结构 - 添加了可编辑字段收集功能,支持批量编辑标量和数组类型 - 实现了YAML解析器,支持嵌套对象、标量数组和对象数组 - 添加了YAML注释提取功能,将注释映射到逻辑字段路径 - 实现了基于模式的示例YAML配置生成功能 - 添加了扩展端验证诊断功能,支持中英文错误消息 - 实现了表单更新应用功能,支持标量、数组和对象数组更新 - 添加了批处理数组值解析和模式枚举值标准化功能 - 实现了YAML标量格式化和引号移除功能 - 添加了完整的模式节点验证,支持数值约束、长度限制和模式匹配 - 实现了多语言验证消息本地化功能 - 添加了YAML标记化和块解析功能 - 实现了唯一性检查和比较键构建功能 --- .../src/configValidation.js | 131 ++++++++++++++++++ tools/gframework-config-tool/src/extension.js | 14 +- .../src/localization.js | 10 ++ .../src/localizationKeys.js | 2 + .../test/configValidation.test.js | 67 +++++++++ 5 files changed, 223 insertions(+), 1 deletion(-) diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js index e80a559e..25a49b19 100644 --- a/tools/gframework-config-tool/src/configValidation.js +++ b/tools/gframework-config-tool/src/configValidation.js @@ -370,6 +370,16 @@ function normalizeSchemaNumber(value) { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } +/** + * Normalize one strictly positive finite schema number. + * + * @param {unknown} value Raw schema value. + * @returns {number | undefined} Normalized positive number. + */ +function normalizeSchemaPositiveNumber(value) { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; +} + /** * Normalize one non-negative integer schema value for length constraints. * @@ -380,6 +390,16 @@ function normalizeSchemaNonNegativeInteger(value) { return Number.isInteger(value) && value >= 0 ? value : undefined; } +/** + * Normalize one boolean schema flag. + * + * @param {unknown} value Raw schema value. + * @returns {boolean | undefined} Normalized boolean. + */ +function normalizeSchemaBoolean(value) { + return typeof value === "boolean" ? value : undefined; +} + /** * Normalize one schema pattern string when the regular expression can be * compiled by the local tooling runtime. @@ -454,6 +474,25 @@ function matchesSchemaPattern(scalarValue, pattern, displayPath) { } } +/** + * Test whether one numeric scalar satisfies a multipleOf constraint. + * + * @param {string} scalarValue YAML scalar value. + * @param {number | undefined} multipleOf Schema multipleOf value. + * @returns {boolean} True when compatible or the constraint is absent. + */ +function matchesSchemaMultipleOf(scalarValue, multipleOf) { + if (typeof multipleOf !== "number") { + return true; + } + + const numericValue = Number(scalarValue); + const quotient = numericValue / multipleOf; + const nearestInteger = Math.round(quotient); + const tolerance = 1e-9 * Math.max(1, Math.abs(quotient)); + return Math.abs(quotient - nearestInteger) <= tolerance; +} + /** * Format a scalar value for YAML output. * @@ -505,11 +544,13 @@ function parseSchemaNode(rawNode, displayPath) { exclusiveMinimum: normalizeSchemaNumber(value.exclusiveMinimum), maximum: normalizeSchemaNumber(value.maximum), exclusiveMaximum: normalizeSchemaNumber(value.exclusiveMaximum), + multipleOf: normalizeSchemaPositiveNumber(value.multipleOf), minLength: normalizeSchemaNonNegativeInteger(value.minLength), maxLength: normalizeSchemaNonNegativeInteger(value.maxLength), pattern: normalizeSchemaPattern(value.pattern, displayPath), minItems: normalizeSchemaNonNegativeInteger(value.minItems), maxItems: normalizeSchemaNonNegativeInteger(value.maxItems), + uniqueItems: normalizeSchemaBoolean(value.uniqueItems), refTable: typeof value["x-gframework-ref-table"] === "string" ? value["x-gframework-ref-table"] : undefined @@ -545,6 +586,7 @@ function parseSchemaNode(rawNode, displayPath) { defaultValue: metadata.defaultValue, minItems: metadata.minItems, maxItems: metadata.maxItems, + uniqueItems: metadata.uniqueItems === true, refTable: metadata.refTable, items: itemNode }; @@ -568,6 +610,9 @@ function parseSchemaNode(rawNode, displayPath) { exclusiveMaximum: type === "integer" || type === "number" ? metadata.exclusiveMaximum : undefined, + multipleOf: type === "integer" || type === "number" + ? metadata.multipleOf + : undefined, minLength: type === "string" ? metadata.minLength : undefined, @@ -638,6 +683,26 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) diagnostics, localizer); } + + if (schemaNode.uniqueItems === true) { + const seenItems = new Map(); + for (let index = 0; index < yamlNode.items.length; index += 1) { + const comparableValue = buildComparableNodeValue(schemaNode.items, yamlNode.items[index]); + if (seenItems.has(comparableValue)) { + diagnostics.push({ + severity: "error", + message: localizeValidationMessage(ValidationMessageKeys.uniqueItemsViolation, localizer, { + displayPath: joinArrayIndexPath(displayPath, index), + duplicatePath: joinArrayIndexPath(displayPath, seenItems.get(comparableValue)) + }) + }); + break; + } + + seenItems.set(comparableValue, index); + } + } + return; } @@ -729,6 +794,17 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) }); } + if (supportsNumericConstraints && + !matchesSchemaMultipleOf(scalarValue, schemaNode.multipleOf)) { + diagnostics.push({ + severity: "error", + message: localizeValidationMessage(ValidationMessageKeys.multipleOfViolation, localizer, { + displayPath, + value: String(schemaNode.multipleOf) + }) + }); + } + if (supportsLengthConstraints && typeof schemaNode.minLength === "number" && scalarValue.length < schemaNode.minLength) { @@ -824,6 +900,51 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca } } +/** + * Build one schema-aware comparable key for uniqueItems checks. + * + * @param {SchemaNode} schemaNode Schema node. + * @param {YamlNode | undefined} yamlNode YAML node. + * @returns {string} Comparable key. + */ +function buildComparableNodeValue(schemaNode, yamlNode) { + if (!yamlNode) { + return "missing"; + } + + if (schemaNode.type === "object") { + if (yamlNode.kind !== "object") { + return yamlNode.kind; + } + + return Object.keys(schemaNode.properties) + .filter((key) => yamlNode.map.has(key)) + .sort((left, right) => left.localeCompare(right)) + .map((key) => `${key.length}:${key}=${buildComparableNodeValue(schemaNode.properties[key], yamlNode.map.get(key))}`) + .join("|"); + } + + if (schemaNode.type === "array") { + if (yamlNode.kind !== "array") { + return yamlNode.kind; + } + + return `[${yamlNode.items.map((item) => buildComparableNodeValue(schemaNode.items, item)).join(",")}]`; + } + + if (yamlNode.kind !== "scalar") { + return yamlNode.kind; + } + + const scalarValue = unquoteScalar(yamlNode.value); + const normalizedScalar = schemaNode.type === "integer" || schemaNode.type === "number" + ? String(Number(scalarValue)) + : schemaNode.type === "boolean" + ? String(/^true$/iu.test(scalarValue)) + : scalarValue; + return `${schemaNode.type}:${normalizedScalar}`; +} + /** * Format one validation message in either English or Simplified Chinese. * @@ -859,12 +980,16 @@ function localizeValidationMessage(key, localizer, params) { return `属性“${params.displayPath}”长度必须不超过 ${params.value} 个字符。`; case ValidationMessageKeys.minimumViolation: return `属性“${params.displayPath}”必须大于或等于 ${params.value}。`; + case ValidationMessageKeys.multipleOfViolation: + return `属性“${params.displayPath}”必须是 ${params.value} 的整数倍。`; case ValidationMessageKeys.minItemsViolation: return `属性“${params.displayPath}”至少需要包含 ${params.value} 个元素。`; case ValidationMessageKeys.minLengthViolation: return `属性“${params.displayPath}”长度必须至少为 ${params.value} 个字符。`; case ValidationMessageKeys.patternViolation: return `属性“${params.displayPath}”必须匹配正则模式“${params.value}”。`; + case ValidationMessageKeys.uniqueItemsViolation: + return `属性“${params.displayPath}”与更早的数组元素 ${params.duplicatePath} 重复;该数组要求元素唯一。`; case ValidationMessageKeys.expectedObject: return params.subject; case ValidationMessageKeys.missingRequired: @@ -897,12 +1022,16 @@ function localizeValidationMessage(key, localizer, params) { return `Property '${params.displayPath}' must be at most ${params.value} characters long.`; case ValidationMessageKeys.minimumViolation: return `Property '${params.displayPath}' must be greater than or equal to ${params.value}.`; + case ValidationMessageKeys.multipleOfViolation: + return `Property '${params.displayPath}' must be a multiple of ${params.value}.`; case ValidationMessageKeys.minItemsViolation: return `Property '${params.displayPath}' must contain at least ${params.value} items.`; case ValidationMessageKeys.minLengthViolation: return `Property '${params.displayPath}' must be at least ${params.value} characters long.`; case ValidationMessageKeys.patternViolation: return `Property '${params.displayPath}' must match pattern '${params.value}'.`; + case ValidationMessageKeys.uniqueItemsViolation: + return `Property '${params.displayPath}' duplicates earlier array item '${params.duplicatePath}', but uniqueItems is required.`; case ValidationMessageKeys.expectedObject: return params.subject; case ValidationMessageKeys.missingRequired: @@ -1558,6 +1687,7 @@ module.exports = { * defaultValue?: string, * minItems?: number, * maxItems?: number, + * uniqueItems?: boolean, * refTable?: string, * items: SchemaNode * } | { @@ -1570,6 +1700,7 @@ module.exports = { * exclusiveMinimum?: number, * maximum?: number, * exclusiveMaximum?: number, + * multipleOf?: number, * minLength?: number, * maxLength?: number, * pattern?: string, diff --git a/tools/gframework-config-tool/src/extension.js b/tools/gframework-config-tool/src/extension.js index 870f01e7..c78c8aa4 100644 --- a/tools/gframework-config-tool/src/extension.js +++ b/tools/gframework-config-tool/src/extension.js @@ -1574,7 +1574,7 @@ function getScalarArrayValue(yamlNode) { /** * Render human-facing metadata hints for one schema field. * - * @param {{description?: string, defaultValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, minLength?: number, maxLength?: number, pattern?: string, minItems?: number, maxItems?: number, enumValues?: string[], items?: {enumValues?: string[], minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, minLength?: number, maxLength?: number, pattern?: string}, refTable?: string}} propertySchema Property schema metadata. + * @param {{description?: string, defaultValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string, minItems?: number, maxItems?: number, uniqueItems?: boolean, enumValues?: string[], items?: {enumValues?: string[], minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string}, refTable?: string}} propertySchema Property schema metadata. * @param {boolean} isArrayField Whether the field is an array. * @returns {string} HTML fragment. */ @@ -1614,6 +1614,10 @@ function renderFieldHint(propertySchema, isArrayField) { hints.push(escapeHtml(localizer.t("webview.hint.exclusiveMaximum", {value: propertySchema.exclusiveMaximum}))); } + if (!isArrayField && typeof propertySchema.multipleOf === "number") { + hints.push(escapeHtml(localizer.t("webview.hint.multipleOf", {value: propertySchema.multipleOf}))); + } + if (!isArrayField && typeof propertySchema.minLength === "number") { hints.push(escapeHtml(localizer.t("webview.hint.minLength", {value: propertySchema.minLength}))); } @@ -1634,6 +1638,10 @@ function renderFieldHint(propertySchema, isArrayField) { hints.push(escapeHtml(localizer.t("webview.hint.maxItems", {value: propertySchema.maxItems}))); } + if (isArrayField && propertySchema.uniqueItems === true) { + hints.push(escapeHtml(localizer.t("webview.hint.uniqueItems"))); + } + if (isArrayField && propertySchema.items && typeof propertySchema.items.minimum === "number") { hints.push(escapeHtml(localizer.t("webview.hint.itemMinimum", {value: propertySchema.items.minimum}))); } @@ -1650,6 +1658,10 @@ function renderFieldHint(propertySchema, isArrayField) { hints.push(escapeHtml(localizer.t("webview.hint.itemExclusiveMaximum", {value: propertySchema.items.exclusiveMaximum}))); } + if (isArrayField && propertySchema.items && typeof propertySchema.items.multipleOf === "number") { + hints.push(escapeHtml(localizer.t("webview.hint.itemMultipleOf", {value: propertySchema.items.multipleOf}))); + } + if (isArrayField && propertySchema.items && typeof propertySchema.items.minLength === "number") { hints.push(escapeHtml(localizer.t("webview.hint.itemMinLength", {value: propertySchema.items.minLength}))); } diff --git a/tools/gframework-config-tool/src/localization.js b/tools/gframework-config-tool/src/localization.js index b73c3848..f58bddf2 100644 --- a/tools/gframework-config-tool/src/localization.js +++ b/tools/gframework-config-tool/src/localization.js @@ -109,15 +109,18 @@ const enMessages = { "webview.hint.exclusiveMinimum": "Exclusive minimum: {value}", "webview.hint.maximum": "Maximum: {value}", "webview.hint.exclusiveMaximum": "Exclusive maximum: {value}", + "webview.hint.multipleOf": "Multiple of: {value}", "webview.hint.minLength": "Min length: {value}", "webview.hint.maxLength": "Max length: {value}", "webview.hint.pattern": "Pattern: {value}", "webview.hint.minItems": "Min items: {value}", "webview.hint.maxItems": "Max items: {value}", + "webview.hint.uniqueItems": "Items must be unique", "webview.hint.itemMinimum": "Item minimum: {value}", "webview.hint.itemExclusiveMinimum": "Item exclusive minimum: {value}", "webview.hint.itemMaximum": "Item maximum: {value}", "webview.hint.itemExclusiveMaximum": "Item exclusive maximum: {value}", + "webview.hint.itemMultipleOf": "Item multiple of: {value}", "webview.hint.itemMinLength": "Item min length: {value}", "webview.hint.itemMaxLength": "Item max length: {value}", "webview.hint.itemPattern": "Item pattern: {value}", @@ -132,9 +135,11 @@ const enMessages = { [ValidationMessageKeys.maxItemsViolation]: "Property '{displayPath}' must contain at most {value} items.", [ValidationMessageKeys.maxLengthViolation]: "Property '{displayPath}' must be at most {value} characters long.", [ValidationMessageKeys.minimumViolation]: "Property '{displayPath}' must be greater than or equal to {value}.", + [ValidationMessageKeys.multipleOfViolation]: "Property '{displayPath}' must be a multiple of {value}.", [ValidationMessageKeys.minItemsViolation]: "Property '{displayPath}' must contain at least {value} items.", [ValidationMessageKeys.minLengthViolation]: "Property '{displayPath}' must be at least {value} characters long.", [ValidationMessageKeys.patternViolation]: "Property '{displayPath}' must match pattern '{value}'.", + [ValidationMessageKeys.uniqueItemsViolation]: "Property '{displayPath}' duplicates earlier array item '{duplicatePath}', but uniqueItems is required.", [ValidationMessageKeys.enumMismatch]: "Property '{displayPath}' must be one of: {values}.", [ValidationMessageKeys.expectedArray]: "Property '{displayPath}' is expected to be an array.", [ValidationMessageKeys.expectedObject]: "{subject} is expected to be an object.", @@ -208,15 +213,18 @@ const zhCnMessages = { "webview.hint.exclusiveMinimum": "开区间最小值:{value}", "webview.hint.maximum": "最大值:{value}", "webview.hint.exclusiveMaximum": "开区间最大值:{value}", + "webview.hint.multipleOf": "倍数约束:{value}", "webview.hint.minLength": "最小长度:{value}", "webview.hint.maxLength": "最大长度:{value}", "webview.hint.pattern": "正则模式:{value}", "webview.hint.minItems": "最少元素数:{value}", "webview.hint.maxItems": "最多元素数:{value}", + "webview.hint.uniqueItems": "元素必须唯一", "webview.hint.itemMinimum": "元素最小值:{value}", "webview.hint.itemExclusiveMinimum": "元素开区间最小值:{value}", "webview.hint.itemMaximum": "元素最大值:{value}", "webview.hint.itemExclusiveMaximum": "元素开区间最大值:{value}", + "webview.hint.itemMultipleOf": "元素倍数约束:{value}", "webview.hint.itemMinLength": "元素最小长度:{value}", "webview.hint.itemMaxLength": "元素最大长度:{value}", "webview.hint.itemPattern": "元素正则模式:{value}", @@ -231,9 +239,11 @@ const zhCnMessages = { [ValidationMessageKeys.maxItemsViolation]: "属性“{displayPath}”最多只能包含 {value} 个元素。", [ValidationMessageKeys.maxLengthViolation]: "属性“{displayPath}”长度必须不超过 {value} 个字符。", [ValidationMessageKeys.minimumViolation]: "属性“{displayPath}”必须大于或等于 {value}。", + [ValidationMessageKeys.multipleOfViolation]: "属性“{displayPath}”必须是 {value} 的整数倍。", [ValidationMessageKeys.minItemsViolation]: "属性“{displayPath}”至少需要包含 {value} 个元素。", [ValidationMessageKeys.minLengthViolation]: "属性“{displayPath}”长度必须至少为 {value} 个字符。", [ValidationMessageKeys.patternViolation]: "属性“{displayPath}”必须匹配正则模式“{value}”。", + [ValidationMessageKeys.uniqueItemsViolation]: "属性“{displayPath}”与更早的数组元素“{duplicatePath}”重复;该数组要求元素唯一。", [ValidationMessageKeys.enumMismatch]: "属性“{displayPath}”必须是以下值之一:{values}。", [ValidationMessageKeys.expectedArray]: "属性“{displayPath}”应为数组。", [ValidationMessageKeys.expectedObject]: "{subject}", diff --git a/tools/gframework-config-tool/src/localizationKeys.js b/tools/gframework-config-tool/src/localizationKeys.js index 2eb991ff..3e315ff9 100644 --- a/tools/gframework-config-tool/src/localizationKeys.js +++ b/tools/gframework-config-tool/src/localizationKeys.js @@ -10,10 +10,12 @@ const ValidationMessageKeys = Object.freeze({ maxItemsViolation: "validation.maxItemsViolation", maxLengthViolation: "validation.maxLengthViolation", minimumViolation: "validation.minimumViolation", + multipleOfViolation: "validation.multipleOfViolation", minItemsViolation: "validation.minItemsViolation", minLengthViolation: "validation.minLengthViolation", missingRequired: "validation.missingRequired", patternViolation: "validation.patternViolation", + uniqueItemsViolation: "validation.uniqueItemsViolation", unknownProperty: "validation.unknownProperty" }); diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index 0c3ebaab..19020fdf 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -308,6 +308,47 @@ tags: assert.match(diagnostics[1].message, /at most 3 items|最多只能包含 3 个元素/u); }); +test("validateParsedConfig should report multipleOf and uniqueItems violations", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "hp": { + "type": "integer", + "multipleOf": 5 + }, + "phases": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "properties": { + "wave": { "type": "integer" }, + "monsterId": { "type": "string" } + } + } + } + } + } + `); + const yaml = parseTopLevelYaml(` +hp: 12 +phases: + - + wave: 1 + monsterId: slime + - + monsterId: slime + wave: 1 +`); + + const diagnostics = validateParsedConfig(schema, yaml); + + assert.equal(diagnostics.length, 2); + assert.match(diagnostics[0].message, /multiple of 5|5 的整数倍/u); + assert.match(diagnostics[1].message, /phases\[1\]|uniqueItems|元素唯一/u); +}); + test("parseSchemaContent should capture scalar range and length metadata", () => { const schema = parseSchemaContent(` { @@ -378,6 +419,32 @@ test("parseSchemaContent should capture exclusive bounds, pattern, and array ite assert.equal(schema.properties.tags.items.pattern, "^[a-z]+$"); }); +test("parseSchemaContent should capture multipleOf and uniqueItems metadata", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "hp": { + "type": "integer", + "multipleOf": 5 + }, + "dropRates": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "number", + "multipleOf": 0.5 + } + } + } + } + `); + + assert.equal(schema.properties.hp.multipleOf, 5); + assert.equal(schema.properties.dropRates.uniqueItems, true); + assert.equal(schema.properties.dropRates.items.multipleOf, 0.5); +}); + test("parseSchemaContent should reject invalid pattern declarations instead of dropping them", () => { assert.throws( () => parseSchemaContent(`