From 809e1f5ded390aefabaaa0a90cf63827f81662d6 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:18:25 +0800 Subject: [PATCH 1/3] =?UTF-8?q?docs(config):=20=E6=B7=BB=E5=8A=A0=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=E5=86=85=E5=AE=B9=E9=85=8D=E7=BD=AE=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 YAML 配置与 JSON Schema 结构描述支持 - 添加一对象一文件的目录组织方式说明 - 实现运行时只读查询功能详细文档 - 添加 Source Generator 生成配置类型的完整指南 - 集成 VS Code 插件提供配置浏览和校验功能 - 添加 Godot 文本配置桥接的使用说明 - 实现热重载模板和 Architecture 接入示例 - 添加运行时校验行为和错误处理机制 - 提供开发期热重载功能的详细配置方法 - 添加生成器接入约定和工具使用说明 --- .../Config/YamlConfigLoaderEnumTests.cs | 295 ++++++++++++++++++ .../Config/YamlConfigSchemaValidator.cs | 162 ++++++++-- .../Config/SchemaConfigGeneratorEnumTests.cs | 112 +++++++ .../Config/SchemaConfigGenerator.cs | 10 +- docs/zh-CN/game/config-system.md | 6 +- .../src/configValidation.js | 144 ++++++--- .../test/configValidation.enum.test.js | 101 ++++++ .../test/configValidation.test.js | 2 +- 8 files changed, 757 insertions(+), 75 deletions(-) create mode 100644 GFramework.Game.Tests/Config/YamlConfigLoaderEnumTests.cs create mode 100644 GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorEnumTests.cs create mode 100644 tools/gframework-config-tool/test/configValidation.enum.test.js diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderEnumTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderEnumTests.cs new file mode 100644 index 00000000..d6253a8f --- /dev/null +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderEnumTests.cs @@ -0,0 +1,295 @@ +using System.IO; +using GFramework.Game.Abstractions.Config; +using GFramework.Game.Config; + +namespace GFramework.Game.Tests.Config; + +/// +/// 验证 YAML 配置加载器对对象 / 数组 enum 约束的运行时行为。 +/// +[TestFixture] +public class YamlConfigLoaderEnumTests +{ + /// + /// 为每个测试创建独立临时目录,避免文件系统状态互相污染。 + /// + [SetUp] + public void SetUp() + { + _rootPath = Path.Combine(Path.GetTempPath(), "GFramework.ConfigTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_rootPath); + } + + /// + /// 清理测试期间创建的临时目录。 + /// + [TearDown] + public void TearDown() + { + if (Directory.Exists(_rootPath)) + { + Directory.Delete(_rootPath, true); + } + } + + private string _rootPath = null!; + + /// + /// 验证对象 enum 会按字段名排序后的稳定比较键匹配,而不是依赖 schema 内的 JSON 字段顺序。 + /// + [Test] + public async Task LoadAsync_Should_Accept_Object_Value_Declared_In_Schema_Enum_When_Property_Order_Differs() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + reward: + gold: 10 + itemId: potion + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "reward"], + "properties": { + "id": { "type": "integer" }, + "reward": { + "type": "object", + "required": ["gold", "itemId"], + "properties": { + "gold": { "type": "integer" }, + "itemId": { "type": "string" } + }, + "enum": [ + { "itemId": "potion", "gold": 10 }, + { "gold": 50, "itemId": "gem" } + ] + } + } + } + """); + + var loader = CreateLoader(); + 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).Reward.Gold, Is.EqualTo(10)); + Assert.That(table.Get(1).Reward.ItemId, Is.EqualTo("potion")); + }); + } + + /// + /// 验证对象 enum 不匹配时,运行时会拒绝整个对象值并输出候选 JSON 文本。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Object_Value_Is_Not_Declared_In_Schema_Enum() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + reward: + gold: 10 + itemId: elixir + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "reward"], + "properties": { + "id": { "type": "integer" }, + "reward": { + "type": "object", + "required": ["gold", "itemId"], + "properties": { + "gold": { "type": "integer" }, + "itemId": { "type": "string" } + }, + "enum": [ + { "gold": 10, "itemId": "potion" }, + { "gold": 50, "itemId": "gem" } + ] + } + } + } + """); + + var loader = CreateLoader(); + var registry = new ConfigRegistry(); + + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Message, Does.Contain("reward")); + Assert.That(exception.Message, Does.Contain("\"itemId\": \"potion\"")); + Assert.That(exception.Message, Does.Contain("\"itemId\": \"gem\"")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证数组 enum 会保留元素顺序;同一批元素但顺序不同仍视为不同候选值。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Array_Value_Order_Does_Not_Match_Schema_Enum() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + dropItemIds: + - ice + - fire + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "dropItemIds"], + "properties": { + "id": { "type": "integer" }, + "dropItemIds": { + "type": "array", + "items": { "type": "string" }, + "enum": [ + ["fire", "ice"], + ["earth"] + ] + } + } + } + """); + + var loader = CreateLoader(); + var registry = new ConfigRegistry(); + + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Message, Does.Contain("dropItemIds")); + Assert.That(exception.Message, Does.Contain("[\"fire\", \"ice\"]")); + Assert.That(exception.Message, Does.Contain("[\"earth\"]")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 创建带 schema 校验的测试加载器。 + /// + /// 配置类型。 + /// 已注册 monster 表的加载器。 + private YamlConfigLoader CreateLoader() + where TConfig : IHasMonsterId + { + return new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", static config => config.Id); + } + + /// + /// 创建测试配置文件。 + /// + /// 相对路径。 + /// 文件内容。 + private void CreateConfigFile(string relativePath, string content) + { + var filePath = Path.Combine(_rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar)); + var directoryPath = Path.GetDirectoryName(filePath); + if (!string.IsNullOrWhiteSpace(directoryPath)) + { + Directory.CreateDirectory(directoryPath); + } + + File.WriteAllText(filePath, content); + } + + /// + /// 创建测试 schema 文件。 + /// + /// 相对路径。 + /// 文件内容。 + private void CreateSchemaFile(string relativePath, string content) + { + var filePath = Path.Combine(_rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar)); + var directoryPath = Path.GetDirectoryName(filePath); + if (!string.IsNullOrWhiteSpace(directoryPath)) + { + Directory.CreateDirectory(directoryPath); + } + + File.WriteAllText(filePath, content); + } + + /// + /// 为通用测试加载器暴露统一主键访问约定。 + /// + private interface IHasMonsterId + { + /// + /// 获取配置主键。 + /// + int Id { get; } + } + + /// + /// 供对象 enum 测试使用的配置桩。 + /// + private sealed class MonsterRewardConfigStub : IHasMonsterId + { + /// + /// 获取或设置主键。 + /// + public int Id { get; set; } + + /// + /// 获取或设置奖励对象。 + /// + public RewardConfigStub Reward { get; set; } = new(); + } + + /// + /// 供数组 enum 测试使用的配置桩。 + /// + private sealed class MonsterDropItemIdsConfigStub : IHasMonsterId + { + /// + /// 获取或设置主键。 + /// + public int Id { get; set; } + + /// + /// 获取或设置掉落物数组。 + /// + public IReadOnlyList DropItemIds { get; set; } = Array.Empty(); + } + + /// + /// 供对象 enum 测试使用的奖励配置桩。 + /// + private sealed class RewardConfigStub + { + /// + /// 获取或设置金币数量。 + /// + public int Gold { get; set; } + + /// + /// 获取或设置道具标识。 + /// + public string ItemId { get; set; } = string.Empty; + } +} diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index 2f22c714..e7d6f89d 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -462,6 +462,8 @@ internal static class YamlConfigSchemaValidator requiredProperties, ParseObjectConstraints(tableName, schemaPath, propertyPath, element), schemaPath); + objectNode = objectNode.WithAllowedValues( + ParseEnumValues(tableName, schemaPath, propertyPath, element, objectNode, "enum")); return objectNode.WithConstantValue( ParseConstantValue(tableName, schemaPath, propertyPath, element, objectNode)); } @@ -525,8 +527,11 @@ internal static class YamlConfigSchemaValidator var arrayNode = YamlConfigSchemaNode.CreateArray( itemNode, + allowedValues: null, ParseArrayConstraints(tableName, schemaPath, propertyPath, element), schemaPath); + arrayNode = arrayNode.WithAllowedValues( + ParseEnumValues(tableName, schemaPath, propertyPath, element, arrayNode, "enum")); return arrayNode.WithConstantValue( ParseConstantValue(tableName, schemaPath, propertyPath, element, arrayNode)); } @@ -553,9 +558,11 @@ internal static class YamlConfigSchemaValidator var scalarNode = YamlConfigSchemaNode.CreateScalar( nodeType, referenceTableName, - ParseEnumValues(tableName, schemaPath, propertyPath, element, nodeType, "enum"), + allowedValues: null, ParseScalarConstraints(tableName, schemaPath, propertyPath, element, nodeType), schemaPath); + scalarNode = scalarNode.WithAllowedValues( + ParseEnumValues(tableName, schemaPath, propertyPath, element, scalarNode, "enum")); return scalarNode.WithConstantValue( ParseConstantValue(tableName, schemaPath, propertyPath, element, scalarNode)); } @@ -794,6 +801,7 @@ internal static class YamlConfigSchemaValidator ValidateObjectConstraints(tableName, yamlPath, displayPath, seenProperties.Count, schemaNode); } + ValidateAllowedValues(tableName, yamlPath, displayPath, mappingNode, schemaNode); ValidateConstantValue(tableName, yamlPath, displayPath, mappingNode, schemaNode); ValidateNegatedSchemaConstraint(tableName, yamlPath, displayPath, mappingNode, schemaNode); } @@ -917,6 +925,7 @@ internal static class YamlConfigSchemaValidator ValidateArrayUniqueItemsConstraint(tableName, yamlPath, displayPath, sequenceNode, schemaNode); ValidateArrayContainsConstraints(tableName, yamlPath, displayPath, sequenceNode, schemaNode, references); + ValidateAllowedValues(tableName, yamlPath, displayPath, sequenceNode, schemaNode); ValidateConstantValue(tableName, yamlPath, displayPath, sequenceNode, schemaNode); ValidateNegatedSchemaConstraint(tableName, yamlPath, displayPath, sequenceNode, schemaNode); } @@ -992,19 +1001,7 @@ internal static class YamlConfigSchemaValidator } var normalizedValue = NormalizeScalarValue(schemaNode.NodeType, value); - if (schemaNode.AllowedValues is { Count: > 0 } && - !schemaNode.AllowedValues.Contains(normalizedValue, StringComparer.Ordinal)) - { - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.EnumValueNotAllowed, - tableName, - $"Property '{displayPath}' in config file '{yamlPath}' must be one of [{string.Join(", ", schemaNode.AllowedValues)}], but the current YAML scalar value is '{value}'.", - yamlPath: yamlPath, - schemaPath: schemaNode.SchemaPathHint, - displayPath: GetDiagnosticPath(displayPath), - rawValue: value, - detail: $"Allowed values: {string.Join(", ", schemaNode.AllowedValues)}."); - } + ValidateAllowedValues(tableName, yamlPath, displayPath, scalarNode, schemaNode); if (schemaNode.Constraints is not null) { @@ -1029,21 +1026,22 @@ internal static class YamlConfigSchemaValidator } /// - /// 解析 enum,并在读取阶段验证枚举值与字段类型的兼容性。 + /// 解析 enum,并在读取阶段将每个候选值预归一化成与运行时 YAML 相同的稳定比较键。 + /// 这样对象、数组和标量节点都可以复用同一套匹配逻辑,而不必在每次加载时重新解释 schema 字面量。 /// /// 所属配置表名称。 /// Schema 文件路径。 /// 字段路径。 /// Schema 节点。 - /// 期望的标量类型。 + /// 当前节点的已解析 schema 模型。 /// 当前读取的关键字名称。 - /// 归一化后的枚举值集合;未声明时返回空。 - private static IReadOnlyCollection? ParseEnumValues( + /// 归一化后的允许值集合;未声明时返回空。 + private static IReadOnlyCollection? ParseEnumValues( string tableName, string schemaPath, string propertyPath, JsonElement element, - YamlConfigSchemaPropertyType expectedType, + YamlConfigSchemaNode schemaNode, string keywordName) { if (!element.TryGetProperty("enum", out var enumElement)) @@ -1061,16 +1059,29 @@ internal static class YamlConfigSchemaValidator displayPath: GetDiagnosticPath(propertyPath)); } - var allowedValues = new List(); + var allowedValues = new List(); foreach (var item in enumElement.EnumerateArray()) { allowedValues.Add( - NormalizeKeywordScalarValue(tableName, schemaPath, propertyPath, keywordName, expectedType, item)); + new YamlConfigAllowedValue( + BuildComparableConstantValue(tableName, schemaPath, propertyPath, keywordName, item, schemaNode), + BuildEnumDisplayValue(item))); } return allowedValues; } + /// + /// 读取一个 enum 候选值的展示文本。 + /// 这里直接保留原始 JSON 字面量,避免字符串引号、对象字段顺序或数组结构在诊断中被二次格式化后丢失上下文。 + /// + /// Schema 中的单个枚举值。 + /// 适合放入诊断消息的展示文本。 + private static string BuildEnumDisplayValue(JsonElement element) + { + return element.GetRawText(); + } + /// /// 解析 const,并把 schema 常量预归一化成与运行时 YAML 相同的稳定比较键。 /// 这样运行时只需要复用现有递归比较逻辑,而不必在每次加载时重新解释 JSON 常量。 @@ -2084,6 +2095,50 @@ internal static class YamlConfigSchemaValidator } } + /// + /// 校验节点值是否命中声明的 enum 集合。 + /// 该实现与 const 共享同一套可比较键,保证对象字段顺序、数组顺序和数值归一化语义稳定一致。 + /// + /// 所属配置表名称。 + /// YAML 文件路径。 + /// 字段路径;根节点时为空。 + /// 当前 YAML 节点。 + /// 对应的 schema 节点。 + private static void ValidateAllowedValues( + string tableName, + string yamlPath, + string displayPath, + YamlNode node, + YamlConfigSchemaNode schemaNode) + { + var allowedValues = schemaNode.AllowedValues; + if (allowedValues is null || allowedValues.Count == 0) + { + return; + } + + var comparableValue = BuildComparableNodeValue(node, schemaNode); + if (allowedValues.Any(allowedValue => + string.Equals(allowedValue.ComparableValue, comparableValue, StringComparison.Ordinal))) + { + return; + } + + var subject = string.IsNullOrWhiteSpace(displayPath) + ? "Root object" + : $"Property '{displayPath}'"; + var displayValues = string.Join(", ", allowedValues.Select(static allowedValue => allowedValue.DisplayValue)); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.EnumValueNotAllowed, + tableName, + $"{subject} in config file '{yamlPath}' must be one of [{displayValues}].", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: DescribeYamlNodeForDiagnostics(node, schemaNode), + detail: $"Allowed values: {displayValues}."); + } + /// /// 校验节点值是否满足 const 约束。 /// 该检查复用与 uniqueItems 相同的稳定比较键,保证对象字段顺序、数字字面量和布尔大小写不会造成伪差异。 @@ -3482,9 +3537,9 @@ internal sealed class YamlConfigSchemaNode public string? ReferenceTableName { get; } /// - /// 获取标量允许值集合;未声明 enum 时返回空。 + /// 获取节点允许值集合;未声明 enum 时返回空。 /// - public IReadOnlyCollection? AllowedValues { get; } + public IReadOnlyCollection? AllowedValues { get; } /// /// 获取标量范围与长度约束;未声明时返回空。 @@ -3549,11 +3604,13 @@ internal sealed class YamlConfigSchemaNode /// 创建数组节点描述。 /// /// 数组元素节点。 + /// 数组节点允许值集合。 /// 数组元素数量约束。 /// 用于错误信息的 schema 文件路径提示。 /// 数组节点模型。 public static YamlConfigSchemaNode CreateArray( YamlConfigSchemaNode itemNode, + IReadOnlyCollection? allowedValues, YamlConfigArrayConstraints? arrayConstraints, string schemaPathHint) { @@ -3562,7 +3619,7 @@ internal sealed class YamlConfigSchemaNode new NodeChildren(properties: null, requiredProperties: null, itemNode), new NodeValidation( referenceTableName: null, - allowedValues: null, + allowedValues, constraints: null, arrayConstraints, objectConstraints: null, @@ -3583,7 +3640,7 @@ internal sealed class YamlConfigSchemaNode public static YamlConfigSchemaNode CreateScalar( YamlConfigSchemaPropertyType nodeType, string? referenceTableName, - IReadOnlyCollection? allowedValues, + IReadOnlyCollection? allowedValues, YamlConfigScalarConstraints? constraints, string schemaPathHint) { @@ -3616,6 +3673,20 @@ internal sealed class YamlConfigSchemaNode SchemaPathHint); } + /// + /// 基于当前节点复制一个只替换 enum 允许值集合的新节点。 + /// + /// 新的允许值集合。 + /// 复制后的节点。 + public YamlConfigSchemaNode WithAllowedValues(IReadOnlyCollection? allowedValues) + { + return new YamlConfigSchemaNode( + NodeType, + _children, + _validation.WithAllowedValues(allowedValues), + SchemaPathHint); + } + /// /// 基于当前节点复制一个只替换常量约束的新节点。 /// @@ -3669,7 +3740,7 @@ internal sealed class YamlConfigSchemaNode { public NodeValidation( string? referenceTableName, - IReadOnlyCollection? allowedValues, + IReadOnlyCollection? allowedValues, YamlConfigScalarConstraints? constraints, YamlConfigArrayConstraints? arrayConstraints, YamlConfigObjectConstraints? objectConstraints, @@ -3696,7 +3767,7 @@ internal sealed class YamlConfigSchemaNode public string? ReferenceTableName { get; } - public IReadOnlyCollection? AllowedValues { get; } + public IReadOnlyCollection? AllowedValues { get; } public YamlConfigScalarConstraints? Constraints { get; } @@ -3714,6 +3785,12 @@ internal sealed class YamlConfigSchemaNode ObjectConstraints, ConstantValue, NegatedSchemaNode); } + public NodeValidation WithAllowedValues(IReadOnlyCollection? allowedValues) + { + return new NodeValidation(ReferenceTableName, allowedValues, Constraints, ArrayConstraints, + ObjectConstraints, ConstantValue, NegatedSchemaNode); + } + public NodeValidation WithConstantValue(YamlConfigConstantValue? constantValue) { return new NodeValidation(ReferenceTableName, AllowedValues, Constraints, ArrayConstraints, @@ -3759,6 +3836,37 @@ internal sealed class YamlConfigConstantValue public string DisplayValue { get; } } +/// +/// 表示一个节点上声明的单个 enum 候选值。 +/// 该模型同时保留稳定比较键与原始 JSON 文本,分别供运行时匹配和诊断输出复用。 +/// +internal sealed class YamlConfigAllowedValue +{ + /// + /// 初始化一个枚举候选值模型。 + /// + /// 用于与 YAML 节点比较的稳定键。 + /// 用于诊断输出的原始 JSON 文本。 + public YamlConfigAllowedValue(string comparableValue, string displayValue) + { + ArgumentNullException.ThrowIfNull(comparableValue); + ArgumentException.ThrowIfNullOrWhiteSpace(displayValue); + + ComparableValue = comparableValue; + DisplayValue = displayValue; + } + + /// + /// 获取用于运行时比较的稳定键。 + /// + public string ComparableValue { get; } + + /// + /// 获取用于诊断输出的原始 JSON 文本。 + /// + public string DisplayValue { get; } +} + /// /// 表示一个对象节点上声明的属性数量约束。 /// 该模型将对象级约束与数组 / 标量约束拆开保存,避免运行时节点继续暴露无关成员。 diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorEnumTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorEnumTests.cs new file mode 100644 index 00000000..f0756af2 --- /dev/null +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorEnumTests.cs @@ -0,0 +1,112 @@ +namespace GFramework.SourceGenerators.Tests.Config; + +/// +/// 验证 schema 配置生成器对对象 / 数组 enum 文档输出的行为。 +/// +[TestFixture] +public class SchemaConfigGeneratorEnumTests +{ + /// + /// 验证对象 enum 会以原始 JSON 文本写入生成代码 XML 文档。 + /// + [Test] + public void Run_Should_Write_Object_Enum_Into_Generated_Documentation() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id", "reward"], + "properties": { + "id": { "type": "integer" }, + "reward": { + "type": "object", + "required": ["gold", "itemId"], + "properties": { + "gold": { "type": "integer" }, + "itemId": { "type": "string" } + }, + "enum": [ + { "gold": 10, "itemId": "potion" }, + { "gold": 50, "itemId": "gem" } + ] + } + } + } + """; + + 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("Allowed values: { \"gold\": 10, \"itemId\": \"potion\" }, { \"gold\": 50, \"itemId\": \"gem\" }.")); + } + + /// + /// 验证数组 enum 会以保留顺序的 JSON 数组文本写入生成代码 XML 文档。 + /// + [Test] + public void Run_Should_Write_Array_Enum_Into_Generated_Documentation() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id", "dropItemIds"], + "properties": { + "id": { "type": "integer" }, + "dropItemIds": { + "type": "array", + "items": { "type": "string" }, + "enum": [ + ["fire", "ice"], + ["earth"] + ] + } + } + } + """; + + 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("Allowed values: [\"fire\", \"ice\"], [\"earth\"].")); + } +} diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs index 84a1cef6..9cf12293 100644 --- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -478,7 +478,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator "object", isRequired ? objectSpec.ClassName : $"{objectSpec.ClassName}?", isRequired ? " = new();" : null, - null, + TryBuildEnumDocumentation(property.Value, "object"), null, null, objectSpec, @@ -805,7 +805,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator $"global::System.Collections.Generic.IReadOnlyList<{itemClrType}>", TryBuildArrayInitializer(property.Value, itemType, itemClrType) ?? $" = global::System.Array.Empty<{itemClrType}>();", - TryBuildEnumDocumentation(itemsElement, itemType), + TryBuildEnumDocumentation(property.Value, "array"), TryBuildConstraintDocumentation(property.Value, "array"), refTableName, null, @@ -856,7 +856,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator "array", $"global::System.Collections.Generic.IReadOnlyList<{objectSpec.ClassName}>", $" = global::System.Array.Empty<{objectSpec.ClassName}>();", - null, + TryBuildEnumDocumentation(property.Value, "array"), TryBuildConstraintDocumentation(property.Value, "array"), null, null, @@ -2760,7 +2760,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator /// 将 enum 值整理成 XML 文档可读字符串。 /// /// Schema 节点。 - /// 标量类型。 + /// 当前 schema 类型。 /// 格式化后的枚举说明。 private static string? TryBuildEnumDocumentation(JsonElement element, string schemaType) { @@ -2782,6 +2782,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator "boolean" when item.ValueKind == JsonValueKind.True => "true", "boolean" when item.ValueKind == JsonValueKind.False => "false", "string" when item.ValueKind == JsonValueKind.String => item.GetString(), + "array" when item.ValueKind == JsonValueKind.Array => item.GetRawText(), + "object" when item.ValueKind == JsonValueKind.Object => item.GetRawText(), _ => null }; diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index e3e23bd9..2ff052e4 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -12,7 +12,7 @@ - JSON Schema 作为结构描述 - 一对象一文件的目录组织 - 运行时只读查询 -- Runtime / Generator / Tooling 共享支持 `const`、`not`、`minimum`、`maximum`、`exclusiveMinimum`、`exclusiveMaximum`、`multipleOf`、`minLength`、`maxLength`、`pattern`、`format`(当前稳定子集:`date`、`date-time`、`duration`、`email`、`time`、`uri`、`uuid`)、`minItems`、`maxItems`、`uniqueItems`、`contains`、`minContains`、`maxContains`、`minProperties`、`maxProperties` +- Runtime / Generator / Tooling 共享支持 `enum`、`const`、`not`、`minimum`、`maximum`、`exclusiveMinimum`、`exclusiveMaximum`、`multipleOf`、`minLength`、`maxLength`、`pattern`、`format`(当前稳定子集:`date`、`date-time`、`duration`、`email`、`time`、`uri`、`uuid`)、`minItems`、`maxItems`、`uniqueItems`、`contains`、`minContains`、`maxContains`、`minProperties`、`maxProperties` - Source Generator 生成配置类型、表包装、单表注册/访问辅助,以及项目级聚合注册目录 - VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口 @@ -721,7 +721,7 @@ var loader = new YamlConfigLoader("config-root") - 对象字段违反 `minProperties` / `maxProperties` - 标量 / 对象 / 数组字段违反 `const` - 标量 / 对象 / 数组字段命中 `not` -- 标量 `enum` 不匹配 +- 标量 / 对象 / 数组字段违反 `enum` - 标量数组元素 `enum` 不匹配 - 通过 `x-gframework-ref-table` 声明的跨表引用缺失目标行 @@ -769,7 +769,7 @@ if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var re - `default`:供生成类型属性初始值和工具提示复用 - `const`:供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;对象会忽略字段顺序比较,数组保留元素顺序,标量按运行时同一套类型归一化规则比较 - `not`:供运行时校验、VS Code 校验和生成代码 XML 文档复用;`not` 子 schema 会复用同一套递归校验规则,但对象匹配保持主校验链的严格语义,不会像 `contains` 那样把“声明属性子集”视为命中 -- `enum`:供运行时校验、VS Code 校验和表单枚举选择复用 +- `enum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用;当前标量、对象和数组节点都支持 `enum`,其中标量 `enum` 继续驱动表单枚举选择,对象 / 数组 `enum` 当前主要提供校验与文档约束 - `minimum` / `maximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用 - `exclusiveMinimum` / `exclusiveMaximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用 - `multipleOf`:供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;当前优先按运行时与 JS 共用的十进制精确整倍数判定处理常见十进制步进,并在必要时退回浮点容差兜底 diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js index 670e7000..9151922c 100644 --- a/tools/gframework-config-tool/src/configValidation.js +++ b/tools/gframework-config-tool/src/configValidation.js @@ -373,25 +373,6 @@ function parseBatchArrayValue(value) { .filter((item) => item.length > 0); } -/** - * Normalize a schema enum array into string values that can be shown in UI - * hints and compared against parsed YAML scalar content. - * - * @param {unknown} value Raw schema enum value. - * @returns {string[] | undefined} Normalized enum values. - */ -function normalizeSchemaEnumValues(value) { - if (!Array.isArray(value)) { - return undefined; - } - - const normalized = value - .filter((item) => ["string", "number", "boolean"].includes(typeof item)) - .map((item) => String(item)); - - return normalized.length > 0 ? normalized : undefined; -} - /** * Normalize one finite schema number for tooling metadata and comparisons. * @@ -598,6 +579,47 @@ function applyConstMetadata(schemaNode, rawConst, displayPath) { }; } +/** + * Attach parsed enum metadata to one schema node. + * + * @param {SchemaNode} schemaNode Parsed schema node. + * @param {unknown} rawEnum Raw schema enum value. + * @param {string} displayPath Logical property path. + * @returns {SchemaNode} Schema node with optional enum metadata. + */ +function applyEnumMetadata(schemaNode, rawEnum, displayPath) { + if (!Array.isArray(rawEnum)) { + return schemaNode; + } + + const enumComparableValues = []; + const enumDisplayValues = []; + const enumValues = []; + + for (const item of rawEnum) { + enumComparableValues.push(buildSchemaConstComparableValue(schemaNode, item, displayPath)); + + const displayValue = formatSchemaConstDisplayValue(item); + if (displayValue !== undefined) { + enumDisplayValues.push(displayValue); + } + + if (schemaNode.type !== "object" && schemaNode.type !== "array") { + const editableValue = formatSchemaConstEditableValue(schemaNode, item); + if (editableValue !== undefined) { + enumValues.push(editableValue); + } + } + } + + return { + ...schemaNode, + enumValues: enumValues.length > 0 ? enumValues : undefined, + enumDisplayValues: enumDisplayValues.length > 0 ? enumDisplayValues : undefined, + enumComparableValues: enumComparableValues.length > 0 ? enumComparableValues : undefined + }; +} + /** * Test one scalar value against one compiled schema pattern. * @@ -1107,7 +1129,7 @@ function parseSchemaNode(rawNode, displayPath) { properties[key] = parseSchemaNode(propertyNode, joinPropertyPath(displayPath, key)); } - return applyConstMetadata({ + return applyEnumMetadata(applyConstMetadata({ type: "object", displayPath, required, @@ -1118,7 +1140,7 @@ function parseSchemaNode(rawNode, displayPath) { description: metadata.description, defaultValue: metadata.defaultValue, not: negatedSchemaNode - }, value.const, displayPath); + }, value.const, displayPath), value.enum, displayPath); } if (type === "array") { @@ -1144,7 +1166,7 @@ function parseSchemaNode(rawNode, displayPath) { throw new Error(`Schema property '${displayPath}' declares 'minContains' greater than 'maxContains'.`); } - return applyConstMetadata({ + return applyEnumMetadata(applyConstMetadata({ type: "array", displayPath, title: metadata.title, @@ -1163,10 +1185,10 @@ function parseSchemaNode(rawNode, displayPath) { contains: containsNode, items: itemNode, not: negatedSchemaNode - }, value.const, displayPath); + }, value.const, displayPath), value.enum, displayPath); } - return applyConstMetadata({ + return applyEnumMetadata(applyConstMetadata({ type, displayPath, title: metadata.title, @@ -1202,10 +1224,9 @@ function parseSchemaNode(rawNode, displayPath) { format: type === "string" ? metadata.format : undefined, - enumValues: normalizeSchemaEnumValues(value.enum), refTable: metadata.refTable, not: negatedSchemaNode - }, value.const, displayPath); + }, value.const, displayPath), value.enum, displayPath); } /** @@ -1353,6 +1374,7 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) } } + validateEnumComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer); validateConstComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer); validateNotSchemaMatch(schemaNode, yamlNode, displayPath, diagnostics, localizer); @@ -1382,17 +1404,7 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) return; } - if (Array.isArray(schemaNode.enumValues) && - schemaNode.enumValues.length > 0 && - !schemaNode.enumValues.includes(unquoteScalar(yamlNode.value))) { - diagnostics.push({ - severity: "error", - message: localizeValidationMessage(ValidationMessageKeys.enumMismatch, localizer, { - displayPath, - values: schemaNode.enumValues.join(", ") - }) - }); - } + validateEnumComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer); const scalarValue = unquoteScalar(yamlNode.value); const supportsNumericConstraints = schemaNode.type === "integer" || schemaNode.type === "number"; @@ -1586,6 +1598,7 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca }); } + validateEnumComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer); validateConstComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer); validateNotSchemaMatch(schemaNode, yamlNode, displayPath, diagnostics, localizer); } @@ -1660,6 +1673,12 @@ function matchesSchemaNodeInternal(schemaNode, yamlNode, allowUnknownObjectPrope return false; } + if (Array.isArray(schemaNode.enumComparableValues) && + schemaNode.enumComparableValues.length > 0 && + !schemaNode.enumComparableValues.includes(buildComparableNodeValue(schemaNode, yamlNode))) { + return false; + } + if (typeof schemaNode.constComparableValue === "string" && buildComparableNodeValue(schemaNode, yamlNode) !== schemaNode.constComparableValue) { return false; @@ -1722,6 +1741,12 @@ function matchesSchemaNodeInternal(schemaNode, yamlNode, allowUnknownObjectPrope } } + if (Array.isArray(schemaNode.enumComparableValues) && + schemaNode.enumComparableValues.length > 0 && + !schemaNode.enumComparableValues.includes(buildComparableNodeValue(schemaNode, yamlNode))) { + return false; + } + if (typeof schemaNode.constComparableValue === "string" && buildComparableNodeValue(schemaNode, yamlNode) !== schemaNode.constComparableValue) { return false; @@ -1738,9 +1763,9 @@ function matchesSchemaNodeInternal(schemaNode, yamlNode, allowUnknownObjectPrope return false; } - if (Array.isArray(schemaNode.enumValues) && - schemaNode.enumValues.length > 0 && - !schemaNode.enumValues.includes(unquoteScalar(yamlNode.value))) { + if (Array.isArray(schemaNode.enumComparableValues) && + schemaNode.enumComparableValues.length > 0 && + !schemaNode.enumComparableValues.includes(buildComparableNodeValue(schemaNode, yamlNode))) { return false; } @@ -1886,6 +1911,39 @@ function isStructurallyCompatibleWithSchemaNode(schemaNode, yamlNode) { isScalarCompatible(schemaNode.type, yamlNode.value); } +/** + * Validate one parsed YAML node against one normalized enum comparable set. + * + * @param {SchemaNode} schemaNode Schema node. + * @param {YamlNode} yamlNode YAML node. + * @param {string} displayPath Current logical path. + * @param {Array<{severity: "error" | "warning", message: string}>} diagnostics Diagnostic sink. + * @param {{isChinese?: boolean} | undefined} localizer Optional runtime localizer. + */ +function validateEnumComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer) { + if (!Array.isArray(schemaNode.enumComparableValues) || schemaNode.enumComparableValues.length === 0) { + return; + } + + const comparableValue = buildComparableNodeValue(schemaNode, yamlNode); + if (schemaNode.enumComparableValues.includes(comparableValue)) { + return; + } + + const displayValues = Array.isArray(schemaNode.enumDisplayValues) && schemaNode.enumDisplayValues.length > 0 + ? schemaNode.enumDisplayValues + : Array.isArray(schemaNode.enumValues) + ? schemaNode.enumValues + : []; + diagnostics.push({ + severity: "error", + message: localizeValidationMessage(ValidationMessageKeys.enumMismatch, localizer, { + displayPath, + values: displayValues.join(", ") + }) + }); +} + /** * Validate one parsed YAML node against one normalized const comparable value. * The helper reuses the same comparable-key logic as uniqueItems so array order @@ -2804,6 +2862,8 @@ module.exports = { * title?: string, * description?: string, * defaultValue?: string, + * enumDisplayValues?: string[], + * enumComparableValues?: string[], * constValue?: string, * constDisplayValue?: string, * constComparableValue?: string, @@ -2814,6 +2874,8 @@ module.exports = { * title?: string, * description?: string, * defaultValue?: string, + * enumDisplayValues?: string[], + * enumComparableValues?: string[], * constValue?: string, * constDisplayValue?: string, * constComparableValue?: string, @@ -2835,6 +2897,8 @@ module.exports = { * constValue?: string, * constDisplayValue?: string, * constComparableValue?: string, + * enumDisplayValues?: string[], + * enumComparableValues?: string[], * minimum?: number, * exclusiveMinimum?: number, * maximum?: number, diff --git a/tools/gframework-config-tool/test/configValidation.enum.test.js b/tools/gframework-config-tool/test/configValidation.enum.test.js new file mode 100644 index 00000000..b24cb361 --- /dev/null +++ b/tools/gframework-config-tool/test/configValidation.enum.test.js @@ -0,0 +1,101 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { + parseSchemaContent, + parseTopLevelYaml, + validateParsedConfig +} = require("../src/configValidation"); + +test("parseSchemaContent should capture object and array enum comparable metadata", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "gold": { "type": "integer" }, + "itemId": { "type": "string" } + }, + "enum": [ + { "gold": 10, "itemId": "potion" } + ] + }, + "dropItemIds": { + "type": "array", + "items": { "type": "string" }, + "enum": [ + ["fire", "ice"] + ] + } + } + } + `); + + assert.deepEqual(schema.properties.reward.enumDisplayValues, ["{\"gold\":10,\"itemId\":\"potion\"}"]); + assert.match(schema.properties.reward.enumComparableValues[0], /^4:gold=/u); + assert.deepEqual(schema.properties.dropItemIds.enumDisplayValues, ["[\"fire\",\"ice\"]"]); + assert.equal(schema.properties.dropItemIds.enumComparableValues[0], "[13:string:4:fire,12:string:3:ice]"); +}); + +test("validateParsedConfig should reject object values not declared in object enum", () => { + 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: 10 + itemId: elixir +`); + + const diagnostics = validateParsedConfig(schema, yaml); + + assert.equal(diagnostics.length, 1); + assert.match(diagnostics[0].message, /reward/u); + assert.match(diagnostics[0].message, /"itemId":"potion"/u); +}); + +test("validateParsedConfig should treat array enum candidates as order-sensitive", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "required": ["dropItemIds"], + "properties": { + "dropItemIds": { + "type": "array", + "items": { "type": "string" }, + "enum": [ + ["fire", "ice"] + ] + } + } + } + `); + const yaml = parseTopLevelYaml(` +dropItemIds: + - ice + - fire +`); + + const diagnostics = validateParsedConfig(schema, yaml); + + assert.equal(diagnostics.length, 1); + assert.match(diagnostics[0].message, /dropItemIds/u); + assert.match(diagnostics[0].message, /\["fire","ice"\]/u); +}); diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index 38eb14ec..f99990f2 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -323,7 +323,7 @@ reward: const diagnostics = validateParsedConfig(schema, yaml); assert.equal(diagnostics.length, 1); - assert.match(diagnostics[0].message, /coin, gem/u); + assert.match(diagnostics[0].message, /"coin", "gem"/u); }); test("validateParsedConfig should report scalar const mismatches", () => { 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 2/3] =?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); +}); From 33dd10869797ae5a3a2c2cba8589ec0fec52f4d9 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:12:02 +0800 Subject: [PATCH 3/3] =?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=B7=A5=E5=85=B7=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现了JSON Schema解析和YAML验证功能 - 添加了对象和数组枚举值的比较验证逻辑 - 实现了配置文件的采样生成功能 - 添加了批量编辑器的数值更新功能 - 实现了配置路径和注释提取功能 - 添加了多种数据格式验证支持包括日期、邮箱、UUID等 - 实现了常量和枚举值的元数据处理功能 - 添加了配置验证诊断信息生成功能 - 实现了表单更新应用到YAML的功能 - 添加了字符串排序比较算法确保工具一致性 --- .../Config/SchemaConfigGeneratorEnumTests.cs | 48 ++++++++++++++ .../MonsterConfig.ArrayObjectItemEnum.g.txt | 54 ++++++++++++++++ .../Config/SchemaConfigGenerator.cs | 5 +- .../src/configValidation.js | 16 +++++ .../test/configValidation.enum.test.js | 64 +++++++++++++++++++ 5 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGeneratorEnum/MonsterConfig.ArrayObjectItemEnum.g.txt diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorEnumTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorEnumTests.cs index 56e551c9..801a343c 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorEnumTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorEnumTests.cs @@ -91,6 +91,54 @@ public class SchemaConfigGeneratorEnumTests await AssertSnapshotAsync(result, "MonsterConfig.ArrayItemEnum.g.txt"); } + /// + /// 验证对象数组项 enum 文档回退输出与快照保持一致。 + /// + [Test] + public async Task Snapshot_Should_Preserve_Array_Object_Item_Enum_Documentation_Fallback() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id", "phases"], + "properties": { + "id": { "type": "integer" }, + "phases": { + "type": "array", + "items": { + "type": "object", + "required": ["wave", "monsterId"], + "properties": { + "wave": { "type": "integer" }, + "monsterId": { "type": "string" } + }, + "enum": [ + { "wave": 1, "monsterId": "slime" }, + { "wave": 2, "monsterId": "goblin" } + ] + } + } + } + } + """; + + var result = SchemaGeneratorTestDriver.Run( + source, + ("monster.schema.json", schema)); + + Assert.That(result.Results.Single().Diagnostics, Is.Empty); + await AssertSnapshotAsync(result, "MonsterConfig.ArrayObjectItemEnum.g.txt"); + } + /// /// 对单个生成文件执行快照断言。 /// diff --git a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGeneratorEnum/MonsterConfig.ArrayObjectItemEnum.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGeneratorEnum/MonsterConfig.ArrayObjectItemEnum.g.txt new file mode 100644 index 00000000..3c9def56 --- /dev/null +++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGeneratorEnum/MonsterConfig.ArrayObjectItemEnum.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 'phases'. + /// + /// + /// Schema property path: 'phases'. + /// Allowed values: { "wave": 1, "monsterId": "slime" }, { "wave": 2, "monsterId": "goblin" }. + /// Generated default initializer: = global::System.Array.Empty<PhasesItemConfig>(); + /// + public global::System.Collections.Generic.IReadOnlyList Phases { get; set; } = global::System.Array.Empty(); + + /// + /// Auto-generated nested config type for schema property path 'phases[]'. + /// This nested type is generated so object-valued schema fields remain strongly typed in consumer code. + /// + public sealed partial class PhasesItemConfig + { + /// + /// Gets or sets the value mapped from schema property path 'phases[].wave'. + /// + /// + /// Schema property path: 'phases[].wave'. + /// + public int Wave { get; set; } + + /// + /// Gets or sets the value mapped from schema property path 'phases[].monsterId'. + /// + /// + /// Schema property path: 'phases[].monsterId'. + /// Generated default initializer: = string.Empty; + /// + public string MonsterId { 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 369bcafa..a85cc5ae 100644 --- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -857,7 +857,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator "array", $"global::System.Collections.Generic.IReadOnlyList<{objectSpec.ClassName}>", $" = global::System.Array.Empty<{objectSpec.ClassName}>();", - TryBuildEnumDocumentation(property.Value, "array"), + TryBuildEnumDocumentation(property.Value, "array") ?? + TryBuildEnumDocumentation(itemsElement, "object"), TryBuildConstraintDocumentation(property.Value, "array"), null, null, @@ -866,7 +867,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator "object", objectSpec.ClassName, null, - null, + TryBuildEnumDocumentation(itemsElement, "object"), null, null, objectSpec, diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js index 3850f4eb..5038f22b 100644 --- a/tools/gframework-config-tool/src/configValidation.js +++ b/tools/gframework-config-tool/src/configValidation.js @@ -592,6 +592,10 @@ function applyEnumMetadata(schemaNode, rawEnum, displayPath) { return schemaNode; } + if (rawEnum.length === 0) { + throw new Error(`Schema property '${displayPath}' must declare 'enum' with at least one value.`); + } + const enumComparableValues = []; const enumDisplayValues = []; const enumValues = []; @@ -614,6 +618,7 @@ function applyEnumMetadata(schemaNode, rawEnum, displayPath) { return { ...schemaNode, + enumSampleValue: rawEnum[0], enumValues: enumValues.length > 0 ? enumValues : undefined, enumDisplayValues: enumDisplayValues.length > 0 ? enumDisplayValues : undefined, enumComparableValues: enumComparableValues.length > 0 ? enumComparableValues : undefined @@ -2584,6 +2589,10 @@ function createObjectNode() { */ function createSampleNodeFromSchema(schemaNode) { if (!schemaNode || schemaNode.type === "object") { + if (schemaNode && schemaNode.enumSampleValue !== undefined) { + return createNodeFromFormValue(schemaNode.enumSampleValue); + } + const objectNode = createObjectNode(); for (const [key, propertySchema] of Object.entries(schemaNode && schemaNode.properties ? schemaNode.properties : {})) { const childNode = createSampleNodeFromSchema(propertySchema); @@ -2594,6 +2603,10 @@ function createSampleNodeFromSchema(schemaNode) { } if (schemaNode.type === "array") { + if (schemaNode.enumSampleValue !== undefined) { + return createNodeFromFormValue(schemaNode.enumSampleValue); + } + if (schemaNode.items.type === "object") { return createArrayNode([createSampleNodeFromSchema(schemaNode.items)]); } @@ -2870,6 +2883,7 @@ module.exports = { * title?: string, * description?: string, * defaultValue?: string, + * enumSampleValue?: unknown, * enumDisplayValues?: string[], * enumComparableValues?: string[], * constValue?: string, @@ -2882,6 +2896,7 @@ module.exports = { * title?: string, * description?: string, * defaultValue?: string, + * enumSampleValue?: unknown, * enumDisplayValues?: string[], * enumComparableValues?: string[], * constValue?: string, @@ -2902,6 +2917,7 @@ module.exports = { * title?: string, * description?: string, * defaultValue?: string, + * enumSampleValue?: unknown, * constValue?: string, * constDisplayValue?: string, * constComparableValue?: string, diff --git a/tools/gframework-config-tool/test/configValidation.enum.test.js b/tools/gframework-config-tool/test/configValidation.enum.test.js index 164bea05..6569d588 100644 --- a/tools/gframework-config-tool/test/configValidation.enum.test.js +++ b/tools/gframework-config-tool/test/configValidation.enum.test.js @@ -1,6 +1,7 @@ const test = require("node:test"); const assert = require("node:assert/strict"); const { + createSampleConfigYaml, parseSchemaContent, parseTopLevelYaml, validateParsedConfig @@ -161,3 +162,66 @@ dropLevels: assert.match(diagnostics[0].message, /dropLevels\[1\]/u); assert.doesNotMatch(diagnostics[0].message, /must be one of|必须是以下值之一/u); }); + +test("createSampleConfigYaml should reuse object and array enum payloads for valid samples", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "required": ["reward", "phases"], + "properties": { + "reward": { + "type": "object", + "required": ["gold", "itemId"], + "properties": { + "gold": { "type": "integer" }, + "itemId": { "type": "string" } + }, + "enum": [ + { "gold": 10, "itemId": "potion" } + ] + }, + "phases": { + "type": "array", + "items": { + "type": "object", + "required": ["wave", "monsterId"], + "properties": { + "wave": { "type": "integer" }, + "monsterId": { "type": "string" } + }, + "enum": [ + { "wave": 1, "monsterId": "slime" } + ] + } + } + } + } + `); + + const sample = createSampleConfigYaml(schema); + const yaml = parseTopLevelYaml(sample); + const diagnostics = validateParsedConfig(schema, yaml); + + assert.equal(diagnostics.length, 0); + assert.match(sample, /^reward:$/mu); + assert.match(sample, /^ gold: 10$/mu); + assert.match(sample, /^ itemId: potion$/mu); + assert.match(sample, /^phases:$/mu); + assert.match(sample, /^ -$/mu); + assert.match(sample, /^ wave: 1$/mu); + assert.match(sample, /^ monsterId: slime$/mu); +}); + +test("parseSchemaContent should reject empty enum arrays", () => { + assert.throws(() => parseSchemaContent(` + { + "type": "object", + "properties": { + "rarity": { + "type": "string", + "enum": [] + } + } + } + `), /must declare 'enum' with at least one value/u); +});