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