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