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);
+});