From e28a1e4ecd9e775f1cd0b145920e056d269fd3ce Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:33:44 +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=8A=9F=E8=83=BD=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现配置架构解析器,支持JSON架构到递归树的转换 - 添加YAML解析器,支持根映射、缩进嵌套对象和数组结构 - 实现配置验证诊断功能,提供架构和YAML解析验证 - 添加表单更新应用功能,支持将表单更改安全写回YAML - 实现批编辑器字段提取,支持可编辑标量类型的识别 - 添加配置注释提取功能,将注释映射到逻辑字段路径 - 实现示例配置YAML生成功能,包含架构描述作为注释 - 添加精确十进制算术运算,用于multipleOf约束检查 - 实现标量类型兼容性验证,包括整数、数字、布尔值模式匹配 - 添加常量值元数据处理,支持工具比较对齐运行时行为 --- .../Config/YamlConfigLoaderTests.cs | 47 +++++++++++ .../Config/YamlConfigSchemaValidator.cs | 2 +- .../Config/SchemaConfigGeneratorTests.cs | 45 +++++++++++ .../Config/SchemaConfigGenerator.cs | 6 +- .../src/configValidation.js | 74 ++++++++++++++--- tools/gframework-config-tool/src/extension.js | 20 +++-- .../test/configValidation.test.js | 81 ++++++++++++++++++- 7 files changed, 253 insertions(+), 22 deletions(-) diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs index 3770e1a2..7f115f61 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -1403,6 +1403,53 @@ public class YamlConfigLoaderTests }); } + /// + /// 验证空对象 const 约束会被视为合法 schema,并与空 YAML 映射正确匹配。 + /// + [Test] + public async Task LoadAsync_Should_Accept_Empty_Object_Schema_Const() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + reward: {} + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "reward"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "reward": { + "type": "object", + "properties": {}, + "const": {} + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + await loader.LoadAsync(registry); + + var table = registry.GetTable("monster"); + + Assert.Multiple(() => + { + Assert.That(table.Count, Is.EqualTo(1)); + Assert.That(table.Get(1).Name, Is.EqualTo("Slime")); + }); + } + /// /// 验证对象字段不满足 minProperties 时会在运行时被拒绝。 /// diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index cc827ac9..613fe827 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -2923,7 +2923,7 @@ internal sealed class YamlConfigConstantValue /// 用于诊断输出的原始常量文本。 public YamlConfigConstantValue(string comparableValue, string displayValue) { - ArgumentException.ThrowIfNullOrWhiteSpace(comparableValue); + ArgumentNullException.ThrowIfNull(comparableValue); ArgumentException.ThrowIfNullOrWhiteSpace(displayValue); ComparableValue = comparableValue; diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs index 55f54544..268f3748 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs @@ -46,6 +46,51 @@ public class SchemaConfigGeneratorTests }); } + /// + /// 验证空字符串 const 不会在生成 XML 文档时被当成“缺失约束”跳过。 + /// + [Test] + public void Run_Should_Preserve_Empty_String_Const_In_Generated_Documentation() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "integer" }, + "name": { + "type": "string", + "const": "" + } + } + } + """; + + var result = SchemaGeneratorTestDriver.Run( + source, + ("monster.schema.json", schema)); + + var generatedSources = result.Results + .Single() + .GeneratedSources + .ToDictionary( + static sourceResult => sourceResult.HintName, + static sourceResult => sourceResult.SourceText.ToString(), + StringComparer.Ordinal); + + Assert.That(result.Results.Single().Diagnostics, Is.Empty); + Assert.That(generatedSources["MonsterConfig.g.cs"], Does.Contain("Constraints: const = \"\".")); + } + /// /// 验证深层不支持的数组嵌套会带着完整字段路径产生命名明确的诊断。 /// diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs index e8e62b33..787297f1 100644 --- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -2452,7 +2452,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator var parts = new List(); var constDocumentation = TryBuildConstDocumentation(element, schemaType); - if (!string.IsNullOrWhiteSpace(constDocumentation)) + if (constDocumentation is not null) { parts.Add($"const = {constDocumentation}"); } @@ -2562,7 +2562,9 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator constElement.GetDouble().ToString(CultureInfo.InvariantCulture), "boolean" when constElement.ValueKind == JsonValueKind.True => "true", "boolean" when constElement.ValueKind == JsonValueKind.False => "false", - "string" when constElement.ValueKind == JsonValueKind.String => constElement.GetString(), + // Preserve the exact JSON literal so empty strings and other string-shaped constants + // remain unambiguous in generated XML documentation. + "string" when constElement.ValueKind == JsonValueKind.String => constElement.GetRawText(), "array" when constElement.ValueKind == JsonValueKind.Array => constElement.GetRawText(), "object" when constElement.ValueKind == JsonValueKind.Object => constElement.GetRawText(), _ => null diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js index 29eeb76e..f465617f 100644 --- a/tools/gframework-config-tool/src/configValidation.js +++ b/tools/gframework-config-tool/src/configValidation.js @@ -10,6 +10,22 @@ const IntegerScalarPattern = /^[+-]?\d+$/u; const NumberScalarPattern = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/u; const BooleanScalarPattern = /^(true|false)$/iu; +/** + * Compare two strings using the same UTF-16 code-unit ordering as C#'s + * string.CompareOrdinal so tooling stays aligned with the runtime. + * + * @param {string} left Left operand. + * @param {string} right Right operand. + * @returns {number} Negative when left < right, positive when left > right, zero when equal. + */ +function compareStringsOrdinal(left, right) { + if (left === right) { + return 0; + } + + return left < right ? -1 : 1; +} + /** * Parse the repository's minimal config-schema subset into a recursive tree. * The parser intentionally mirrors the same high-level contract used by the @@ -89,7 +105,7 @@ function getEditableSchemaFields(schemaInfo) { } } - return editableFields.sort((left, right) => left.key.localeCompare(right.key)); + return editableFields.sort((left, right) => compareStringsOrdinal(left.key, right.key)); } /** @@ -462,18 +478,52 @@ function formatSchemaDefaultValue(value) { } /** - * Convert a schema const value into a compact string that can be shown in UI - * metadata hints without losing exactness for arrays and objects. + * Convert a schema const value into the raw scalar text used by sample YAML + * generation and scalar editors. * + * @param {SchemaNode} schemaNode Parsed schema node. * @param {unknown} value Raw schema const value. - * @returns {string | undefined} Display string for the const value. + * @returns {string | undefined} Raw scalar text, or a JSON literal fallback. */ -function formatSchemaConstValue(value) { +function formatSchemaConstEditableValue(schemaNode, value) { if (value === undefined) { return undefined; } - if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + if (schemaNode.type === "string" && typeof value === "string") { + return value; + } + + if ((schemaNode.type === "integer" || schemaNode.type === "number") && + typeof value === "number" && + Number.isFinite(value)) { + return String(value); + } + + if (schemaNode.type === "boolean" && typeof value === "boolean") { + return String(value); + } + + return formatSchemaConstDisplayValue(value); +} + +/** + * Convert a schema const value into an exact JSON-style literal for diagnostics + * and metadata hints. + * + * @param {unknown} value Raw schema const value. + * @returns {string | undefined} Display string for the const value. + */ +function formatSchemaConstDisplayValue(value) { + if (value === undefined) { + return undefined; + } + + if (typeof value === "string") { + return JSON.stringify(value); + } + + if (typeof value === "number" || typeof value === "boolean") { return String(value); } @@ -499,7 +549,8 @@ function applyConstMetadata(schemaNode, rawConst, displayPath) { return { ...schemaNode, - constValue: formatSchemaConstValue(rawConst), + constValue: formatSchemaConstEditableValue(schemaNode, rawConst), + constDisplayValue: formatSchemaConstDisplayValue(rawConst), constComparableValue: buildSchemaConstComparableValue(schemaNode, rawConst, displayPath) }; } @@ -567,7 +618,7 @@ function buildSchemaConstObjectComparableValue(schemaNode, rawConst, displayPath objectEntries.push([key, childComparableValue]); } - objectEntries.sort((left, right) => left[0].localeCompare(right[0])); + objectEntries.sort((left, right) => compareStringsOrdinal(left[0], right[0])); return objectEntries.map(([key, value]) => `${key.length}:${key}=${value.length}:${value}`).join("|"); } @@ -1224,7 +1275,7 @@ function validateConstComparableValue(schemaNode, yamlNode, displayPath, diagnos severity: "error", message: localizeValidationMessage(ValidationMessageKeys.constMismatch, localizer, { displayPath, - value: schemaNode.constValue + value: schemaNode.constDisplayValue ?? schemaNode.constValue }) }); } @@ -1248,7 +1299,7 @@ function buildComparableNodeValue(schemaNode, yamlNode) { return Object.keys(schemaNode.properties) .filter((key) => yamlNode.map.has(key)) - .sort((left, right) => left.localeCompare(right)) + .sort(compareStringsOrdinal) .map((key) => { const valueKey = buildComparableNodeValue(schemaNode.properties[key], yamlNode.map.get(key)); return `${key.length}:${key}=${valueKey.length}:${valueKey}`; @@ -2103,6 +2154,7 @@ module.exports = { * description?: string, * defaultValue?: string, * constValue?: string, + * constDisplayValue?: string, * constComparableValue?: string * } | { * type: "array", @@ -2111,6 +2163,7 @@ module.exports = { * description?: string, * defaultValue?: string, * constValue?: string, + * constDisplayValue?: string, * constComparableValue?: string, * minItems?: number, * maxItems?: number, @@ -2124,6 +2177,7 @@ module.exports = { * description?: string, * defaultValue?: string, * constValue?: string, + * constDisplayValue?: string, * constComparableValue?: string, * minimum?: number, * exclusiveMinimum?: number, diff --git a/tools/gframework-config-tool/src/extension.js b/tools/gframework-config-tool/src/extension.js index f036ad46..dbd71598 100644 --- a/tools/gframework-config-tool/src/extension.js +++ b/tools/gframework-config-tool/src/extension.js @@ -1372,7 +1372,7 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns label, required: requiredSet.has(key), depth, - value: getScalarFieldValue(propertyValue, propertySchema.constValue || propertySchema.defaultValue), + value: getScalarFieldValue(propertyValue, propertySchema.constValue ?? propertySchema.defaultValue), schema: propertySchema, comment: commentLookup[propertyPath] || "" }); @@ -1514,7 +1514,7 @@ function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPa label, required: requiredSet.has(key), depth, - value: getScalarFieldValue(propertyValue, propertySchema.constValue || propertySchema.defaultValue), + value: getScalarFieldValue(propertyValue, propertySchema.constValue ?? propertySchema.defaultValue), schema: propertySchema, itemMode: true, comment: commentLookup[itemDisplayPath] || "" @@ -1555,7 +1555,7 @@ function getScalarFieldValue(yamlNode, fallbackValue) { return unquoteScalar(yamlNode.value || ""); } - return fallbackValue || ""; + return fallbackValue ?? ""; } /** @@ -1577,7 +1577,7 @@ function getScalarArrayValue(yamlNode) { /** * Render human-facing metadata hints for one schema field. * - * @param {{type?: string, description?: string, defaultValue?: string, constValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string, minItems?: number, maxItems?: number, minProperties?: number, maxProperties?: number, uniqueItems?: boolean, enumValues?: string[], items?: {enumValues?: string[], constValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string}, refTable?: string}} propertySchema Property schema metadata. + * @param {{type?: string, description?: string, defaultValue?: string, constValue?: string, constDisplayValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string, minItems?: number, maxItems?: number, minProperties?: number, maxProperties?: number, uniqueItems?: boolean, enumValues?: string[], items?: {enumValues?: string[], constValue?: string, constDisplayValue?: 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. * @param {boolean} includeDescription Whether description text should be included in the hint output. * @returns {string} HTML fragment. @@ -1593,8 +1593,10 @@ function renderFieldHint(propertySchema, isArrayField, includeDescription = true hints.push(escapeHtml(localizer.t("webview.hint.default", {value: propertySchema.defaultValue}))); } - if (propertySchema.constValue) { - hints.push(escapeHtml(localizer.t("webview.hint.const", {value: propertySchema.constValue}))); + if (propertySchema.constValue !== undefined) { + hints.push(escapeHtml(localizer.t("webview.hint.const", { + value: propertySchema.constDisplayValue ?? propertySchema.constValue + }))); } const enumValues = isArrayField @@ -1662,8 +1664,10 @@ function renderFieldHint(propertySchema, isArrayField, includeDescription = true hints.push(escapeHtml(localizer.t("webview.hint.itemMinimum", {value: propertySchema.items.minimum}))); } - if (isArrayField && propertySchema.items && propertySchema.items.constValue) { - hints.push(escapeHtml(localizer.t("webview.hint.itemConst", {value: propertySchema.items.constValue}))); + if (isArrayField && propertySchema.items && propertySchema.items.constValue !== undefined) { + hints.push(escapeHtml(localizer.t("webview.hint.itemConst", { + value: propertySchema.items.constDisplayValue ?? propertySchema.items.constValue + }))); } if (isArrayField && propertySchema.items && typeof propertySchema.items.exclusiveMinimum === "number") { diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index d6bae8df..fac60f1d 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -97,8 +97,51 @@ test("parseSchemaContent should capture const metadata for scalar, object, and a `); assert.equal(schema.properties.rarity.constValue, "common"); + assert.equal(schema.properties.rarity.constDisplayValue, "\"common\""); assert.match(schema.properties.reward.constValue, /"currency":"coin"/u); + assert.match(schema.properties.reward.constDisplayValue, /"currency":"coin"/u); assert.equal(schema.properties.dropItemIds.constValue, "[\"potion\",\"gem\"]"); + assert.equal(schema.properties.dropItemIds.constDisplayValue, "[\"potion\",\"gem\"]"); +}); + +test("parseSchemaContent should preserve empty-string const raw and display metadata", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "" + } + } + } + `); + + assert.equal(schema.properties.name.constValue, ""); + assert.equal(schema.properties.name.constDisplayValue, "\"\""); +}); + +test("parseSchemaContent should build object const comparable keys with ordinal property ordering", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "payload": { + "type": "object", + "properties": { + "z": { "type": "integer" }, + "ä": { "type": "integer" } + }, + "const": { + "z": 1, + "ä": 2 + } + } + } + } + `); + + assert.match(schema.properties.payload.constComparableValue, /^1:z=/u); }); test("parseTopLevelYaml should parse nested mappings and object arrays", () => { @@ -245,7 +288,7 @@ rarity: rare const diagnostics = validateParsedConfig(schema, yaml); assert.equal(diagnostics.length, 1); - assert.match(diagnostics[0].message, /constant value common|固定值 common/u); + assert.match(diagnostics[0].message, /constant value "common"|固定值 "common"/u); }); test("validateParsedConfig should report object and array const mismatches", () => { @@ -1182,6 +1225,42 @@ test("getEditableSchemaFields should keep batch editing limited to top-level sca ]); }); +test("getEditableSchemaFields should sort keys with ordinal semantics", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "a": { "type": "string" }, + "A": { "type": "string" }, + "ä": { "type": "string" }, + "z": { "type": "string" } + } + } + `); + + assert.deepEqual( + getEditableSchemaFields(schema).map((field) => field.key), + ["A", "a", "z", "ä"]); +}); + +test("createSampleConfigYaml should preserve empty-string scalar const values", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "" + } + } + } + `); + + const sample = createSampleConfigYaml(schema); + + assert.match(sample, /^name: ""$/mu); +}); + test("parseBatchArrayValue should keep comma-separated batch editing behavior", () => { assert.deepEqual(parseBatchArrayValue(" potion, bomb , ,elixir "), ["potion", "bomb", "elixir"]); });