From a8cb82e2f197a882a7c06958bd79faaa4c07df20 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:50:46 +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=94=AF=E6=8C=81?= =?UTF-8?q?=E6=9E=9A=E4=B8=BE=E5=AF=B9=E8=B1=A1=E5=92=8C=E6=95=B0=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现 parseSchemaContent 函数解析对象和数组枚举元数据 - 添加 validateParsedConfig 验证对象值是否在枚举声明范围内 - 支持数组枚举候选项的顺序敏感比较 - 优化诊断信息避免父对象枚举不匹配的重复报告 - 添加测试用例验证枚举对象和数组的解析与验证功能 - 实现可编辑字段收集功能支持批量编辑器更新 - 添加 YAML 解析和注释提取功能用于表单预览 - 实现配置验证诊断生成功能支持本地化消息 - 添加格式化和规范化函数支持不同数据类型的处理 --- .../Config/SchemaConfigGeneratorEnumTests.cs | 85 ++++++++++++++----- .../MonsterConfig.ArrayItemEnum.g.txt | 30 +++++++ .../MonsterConfig.ObjectEnum.g.txt | 54 ++++++++++++ .../Config/SchemaConfigGenerator.cs | 3 +- .../src/configValidation.js | 18 ++-- .../test/configValidation.enum.test.js | 62 ++++++++++++++ 6 files changed, 223 insertions(+), 29 deletions(-) create mode 100644 GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGeneratorEnum/MonsterConfig.ArrayItemEnum.g.txt create mode 100644 GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGeneratorEnum/MonsterConfig.ObjectEnum.g.txt diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorEnumTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorEnumTests.cs index f0756af2..56e551c9 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorEnumTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorEnumTests.cs @@ -1,16 +1,19 @@ +using System.IO; +using Microsoft.CodeAnalysis; + namespace GFramework.SourceGenerators.Tests.Config; /// -/// 验证 schema 配置生成器对对象 / 数组 enum 文档输出的行为。 +/// 验证 schema 配置生成器对对象 / 数组 enum 文档输出的快照行为。 /// [TestFixture] public class SchemaConfigGeneratorEnumTests { /// - /// 验证对象 enum 会以原始 JSON 文本写入生成代码 XML 文档。 + /// 验证对象 enum 文档输出与快照保持一致。 /// [Test] - public void Run_Should_Write_Object_Enum_Into_Generated_Documentation() + public async Task Snapshot_Should_Preserve_Object_Enum_Documentation() { const string source = """ namespace TestApp @@ -47,24 +50,15 @@ public class SchemaConfigGeneratorEnumTests 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("Allowed values: { \"gold\": 10, \"itemId\": \"potion\" }, { \"gold\": 50, \"itemId\": \"gem\" }.")); + await AssertSnapshotAsync(result, "MonsterConfig.ObjectEnum.g.txt"); } /// - /// 验证数组 enum 会以保留顺序的 JSON 数组文本写入生成代码 XML 文档。 + /// 验证数组项 enum 文档回退输出与快照保持一致。 /// [Test] - public void Run_Should_Write_Array_Enum_Into_Generated_Documentation() + public async Task Snapshot_Should_Preserve_Array_Item_Enum_Documentation_Fallback() { const string source = """ namespace TestApp @@ -83,11 +77,7 @@ public class SchemaConfigGeneratorEnumTests "id": { "type": "integer" }, "dropItemIds": { "type": "array", - "items": { "type": "string" }, - "enum": [ - ["fire", "ice"], - ["earth"] - ] + "items": { "type": "string", "enum": ["fire", "ice", "earth"] } } } } @@ -97,6 +87,19 @@ public class SchemaConfigGeneratorEnumTests source, ("monster.schema.json", schema)); + Assert.That(result.Results.Single().Diagnostics, Is.Empty); + await AssertSnapshotAsync(result, "MonsterConfig.ArrayItemEnum.g.txt"); + } + + /// + /// 对单个生成文件执行快照断言。 + /// + /// 生成器运行结果。 + /// 快照文件名。 + private static async Task AssertSnapshotAsync( + GeneratorDriverRunResult result, + string snapshotFileName) + { var generatedSources = result.Results .Single() .GeneratedSources @@ -105,8 +108,44 @@ public class SchemaConfigGeneratorEnumTests static sourceResult => sourceResult.SourceText.ToString(), StringComparer.Ordinal); - Assert.That(result.Results.Single().Diagnostics, Is.Empty); - Assert.That(generatedSources["MonsterConfig.g.cs"], - Does.Contain("Allowed values: [\"fire\", \"ice\"], [\"earth\"].")); + if (!generatedSources.TryGetValue("MonsterConfig.g.cs", out var actual)) + { + Assert.Fail("Generated source 'MonsterConfig.g.cs' was not found."); + return; + } + + var snapshotFolder = Path.Combine( + TestContext.CurrentContext.TestDirectory, + "..", + "..", + "..", + "Config", + "snapshots", + "SchemaConfigGeneratorEnum"); + snapshotFolder = Path.GetFullPath(snapshotFolder); + + var path = Path.Combine(snapshotFolder, snapshotFileName); + if (!File.Exists(path)) + { + Directory.CreateDirectory(snapshotFolder); + await File.WriteAllTextAsync(path, actual); + Assert.Fail($"Snapshot not found. Generated new snapshot at:\n{path}"); + } + + var expected = await File.ReadAllTextAsync(path); + Assert.That( + Normalize(expected), + Is.EqualTo(Normalize(actual)), + $"Snapshot mismatch: MonsterConfig.g.cs ({snapshotFileName})"); + } + + /// + /// 标准化快照文本以避免平台换行差异。 + /// + /// 原始文本。 + /// 标准化后的文本。 + private static string Normalize(string text) + { + return text.Replace("\r\n", "\n", StringComparison.Ordinal).Trim(); } } diff --git a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGeneratorEnum/MonsterConfig.ArrayItemEnum.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGeneratorEnum/MonsterConfig.ArrayItemEnum.g.txt new file mode 100644 index 00000000..1f95a454 --- /dev/null +++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGeneratorEnum/MonsterConfig.ArrayItemEnum.g.txt @@ -0,0 +1,30 @@ +// +#nullable enable + +namespace GFramework.Game.Config.Generated; + +/// +/// Auto-generated config type for schema file 'monster.schema.json'. +/// This type is generated from JSON schema so runtime loading and editor tooling can share the same contract. +/// +public sealed partial class MonsterConfig +{ + /// + /// Gets or sets the value mapped from schema property path 'id'. + /// + /// + /// Schema property path: 'id'. + /// + public int Id { get; set; } + + /// + /// Gets or sets the value mapped from schema property path 'dropItemIds'. + /// + /// + /// Schema property path: 'dropItemIds'. + /// Allowed values: fire, ice, earth. + /// Generated default initializer: = global::System.Array.Empty<string>(); + /// + public global::System.Collections.Generic.IReadOnlyList DropItemIds { get; set; } = global::System.Array.Empty(); + +} \ No newline at end of file diff --git a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGeneratorEnum/MonsterConfig.ObjectEnum.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGeneratorEnum/MonsterConfig.ObjectEnum.g.txt new file mode 100644 index 00000000..073eee61 --- /dev/null +++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGeneratorEnum/MonsterConfig.ObjectEnum.g.txt @@ -0,0 +1,54 @@ +// +#nullable enable + +namespace GFramework.Game.Config.Generated; + +/// +/// Auto-generated config type for schema file 'monster.schema.json'. +/// This type is generated from JSON schema so runtime loading and editor tooling can share the same contract. +/// +public sealed partial class MonsterConfig +{ + /// + /// Gets or sets the value mapped from schema property path 'id'. + /// + /// + /// Schema property path: 'id'. + /// + public int Id { get; set; } + + /// + /// Gets or sets the value mapped from schema property path 'reward'. + /// + /// + /// Schema property path: 'reward'. + /// Allowed values: { "gold": 10, "itemId": "potion" }, { "gold": 50, "itemId": "gem" }. + /// Generated default initializer: = new(); + /// + public RewardConfig Reward { get; set; } = new(); + + /// + /// Auto-generated nested config type for schema property path 'reward'. + /// This nested type is generated so object-valued schema fields remain strongly typed in consumer code. + /// + public sealed partial class RewardConfig + { + /// + /// Gets or sets the value mapped from schema property path 'reward.gold'. + /// + /// + /// Schema property path: 'reward.gold'. + /// + public int Gold { get; set; } + + /// + /// Gets or sets the value mapped from schema property path 'reward.itemId'. + /// + /// + /// Schema property path: 'reward.itemId'. + /// Generated default initializer: = string.Empty; + /// + public string ItemId { get; set; } = string.Empty; + + } +} \ No newline at end of file diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs index 9cf12293..369bcafa 100644 --- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -805,7 +805,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator $"global::System.Collections.Generic.IReadOnlyList<{itemClrType}>", TryBuildArrayInitializer(property.Value, itemType, itemClrType) ?? $" = global::System.Array.Empty<{itemClrType}>();", - TryBuildEnumDocumentation(property.Value, "array"), + TryBuildEnumDocumentation(property.Value, "array") ?? + TryBuildEnumDocumentation(itemsElement, itemType), TryBuildConstraintDocumentation(property.Value, "array"), refTableName, null, diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js index 9151922c..3850f4eb 100644 --- a/tools/gframework-config-tool/src/configValidation.js +++ b/tools/gframework-config-tool/src/configValidation.js @@ -1260,11 +1260,13 @@ function parseNegatedSchemaNode(rawNot, displayPath) { */ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) { if (schemaNode.type === "object") { - validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, localizer); + const diagnosticsBeforeNode = diagnostics.length; + validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, localizer, diagnosticsBeforeNode); return; } if (schemaNode.type === "array") { + const diagnosticsBeforeNode = diagnostics.length; if (!yamlNode || yamlNode.kind !== "array") { diagnostics.push({ severity: "error", @@ -1374,7 +1376,7 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) } } - validateEnumComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer); + validateEnumComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer, diagnosticsBeforeNode); validateConstComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer); validateNotSchemaMatch(schemaNode, yamlNode, displayPath, diagnostics, localizer); @@ -1528,8 +1530,9 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) * @param {string} displayPath Current logical path. * @param {Array<{severity: "error" | "warning", message: string}>} diagnostics Diagnostic sink. * @param {{isChinese?: boolean} | undefined} localizer Optional runtime localizer. + * @param {number} diagnosticsBeforeNode Diagnostic count recorded before validating this object node. */ -function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) { +function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, localizer, diagnosticsBeforeNode) { if (!yamlNode || yamlNode.kind !== "object") { diagnostics.push({ severity: "error", @@ -1598,7 +1601,7 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca }); } - validateEnumComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer); + validateEnumComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer, diagnosticsBeforeNode); validateConstComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer); validateNotSchemaMatch(schemaNode, yamlNode, displayPath, diagnostics, localizer); } @@ -1919,12 +1922,17 @@ function isStructurallyCompatibleWithSchemaNode(schemaNode, yamlNode) { * @param {string} displayPath Current logical path. * @param {Array<{severity: "error" | "warning", message: string}>} diagnostics Diagnostic sink. * @param {{isChinese?: boolean} | undefined} localizer Optional runtime localizer. + * @param {number} [diagnosticsBeforeNode] Diagnostic count recorded before validating this node. */ -function validateEnumComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer) { +function validateEnumComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer, diagnosticsBeforeNode) { if (!Array.isArray(schemaNode.enumComparableValues) || schemaNode.enumComparableValues.length === 0) { return; } + if (typeof diagnosticsBeforeNode === "number" && diagnostics.length !== diagnosticsBeforeNode) { + return; + } + const comparableValue = buildComparableNodeValue(schemaNode, yamlNode); if (schemaNode.enumComparableValues.includes(comparableValue)) { return; diff --git a/tools/gframework-config-tool/test/configValidation.enum.test.js b/tools/gframework-config-tool/test/configValidation.enum.test.js index b24cb361..164bea05 100644 --- a/tools/gframework-config-tool/test/configValidation.enum.test.js +++ b/tools/gframework-config-tool/test/configValidation.enum.test.js @@ -99,3 +99,65 @@ dropItemIds: assert.match(diagnostics[0].message, /dropItemIds/u); assert.match(diagnostics[0].message, /\["fire","ice"\]/u); }); + +test("validateParsedConfig should not add parent object enumMismatch after child diagnostics", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "required": ["reward"], + "properties": { + "reward": { + "type": "object", + "required": ["gold", "itemId"], + "properties": { + "gold": { "type": "integer" }, + "itemId": { "type": "string" } + }, + "enum": [ + { "gold": 10, "itemId": "potion" } + ] + } + } + } + `); + const yaml = parseTopLevelYaml(` +reward: + gold: wrong + itemId: potion +`); + + const diagnostics = validateParsedConfig(schema, yaml); + + assert.equal(diagnostics.length, 1); + assert.match(diagnostics[0].message, /reward\.gold/u); + assert.doesNotMatch(diagnostics[0].message, /must be one of|必须是以下值之一/u); +}); + +test("validateParsedConfig should not add parent array enumMismatch after item diagnostics", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "required": ["dropLevels"], + "properties": { + "dropLevels": { + "type": "array", + "items": { "type": "integer" }, + "enum": [ + [1, 2] + ] + } + } + } + `); + const yaml = parseTopLevelYaml(` +dropLevels: + - 1 + - two +`); + + const diagnostics = validateParsedConfig(schema, yaml); + + assert.equal(diagnostics.length, 1); + assert.match(diagnostics[0].message, /dropLevels\[1\]/u); + assert.doesNotMatch(diagnostics[0].message, /must be one of|必须是以下值之一/u); +});