feat(config): 添加配置验证功能支持枚举对象和数组

- 实现 parseSchemaContent 函数解析对象和数组枚举元数据
- 添加 validateParsedConfig 验证对象值是否在枚举声明范围内
- 支持数组枚举候选项的顺序敏感比较
- 优化诊断信息避免父对象枚举不匹配的重复报告
- 添加测试用例验证枚举对象和数组的解析与验证功能
- 实现可编辑字段收集功能支持批量编辑器更新
- 添加 YAML 解析和注释提取功能用于表单预览
- 实现配置验证诊断生成功能支持本地化消息
- 添加格式化和规范化函数支持不同数据类型的处理
This commit is contained in:
GeWuYou 2026-04-16 14:50:46 +08:00
parent 809e1f5ded
commit a8cb82e2f1
6 changed files with 223 additions and 29 deletions

View File

@ -1,16 +1,19 @@
using System.IO;
using Microsoft.CodeAnalysis;
namespace GFramework.SourceGenerators.Tests.Config;
/// <summary>
/// 验证 schema 配置生成器对对象 / 数组 <c>enum</c> 文档输出的行为。
/// 验证 schema 配置生成器对对象 / 数组 <c>enum</c> 文档输出的快照行为。
/// </summary>
[TestFixture]
public class SchemaConfigGeneratorEnumTests
{
/// <summary>
/// 验证对象 <c>enum</c> 会以原始 JSON 文本写入生成代码 XML 文档。
/// 验证对象 <c>enum</c> 文档输出与快照保持一致
/// </summary>
[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");
}
/// <summary>
/// 验证数组 <c>enum</c> 会以保留顺序的 JSON 数组文本写入生成代码 XML 文档。
/// 验证数组项 <c>enum</c> 文档回退输出与快照保持一致。
/// </summary>
[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");
}
/// <summary>
/// 对单个生成文件执行快照断言。
/// </summary>
/// <param name="result">生成器运行结果。</param>
/// <param name="snapshotFileName">快照文件名。</param>
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})");
}
/// <summary>
/// 标准化快照文本以避免平台换行差异。
/// </summary>
/// <param name="text">原始文本。</param>
/// <returns>标准化后的文本。</returns>
private static string Normalize(string text)
{
return text.Replace("\r\n", "\n", StringComparison.Ordinal).Trim();
}
}

View File

@ -0,0 +1,30 @@
// <auto-generated />
#nullable enable
namespace GFramework.Game.Config.Generated;
/// <summary>
/// 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.
/// </summary>
public sealed partial class MonsterConfig
{
/// <summary>
/// Gets or sets the value mapped from schema property path 'id'.
/// </summary>
/// <remarks>
/// Schema property path: 'id'.
/// </remarks>
public int Id { get; set; }
/// <summary>
/// Gets or sets the value mapped from schema property path 'dropItemIds'.
/// </summary>
/// <remarks>
/// Schema property path: 'dropItemIds'.
/// Allowed values: fire, ice, earth.
/// Generated default initializer: = global::System.Array.Empty&lt;string&gt;();
/// </remarks>
public global::System.Collections.Generic.IReadOnlyList<string> DropItemIds { get; set; } = global::System.Array.Empty<string>();
}

View File

@ -0,0 +1,54 @@
// <auto-generated />
#nullable enable
namespace GFramework.Game.Config.Generated;
/// <summary>
/// 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.
/// </summary>
public sealed partial class MonsterConfig
{
/// <summary>
/// Gets or sets the value mapped from schema property path 'id'.
/// </summary>
/// <remarks>
/// Schema property path: 'id'.
/// </remarks>
public int Id { get; set; }
/// <summary>
/// Gets or sets the value mapped from schema property path 'reward'.
/// </summary>
/// <remarks>
/// Schema property path: 'reward'.
/// Allowed values: { "gold": 10, "itemId": "potion" }, { "gold": 50, "itemId": "gem" }.
/// Generated default initializer: = new();
/// </remarks>
public RewardConfig Reward { get; set; } = new();
/// <summary>
/// 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.
/// </summary>
public sealed partial class RewardConfig
{
/// <summary>
/// Gets or sets the value mapped from schema property path 'reward.gold'.
/// </summary>
/// <remarks>
/// Schema property path: 'reward.gold'.
/// </remarks>
public int Gold { get; set; }
/// <summary>
/// Gets or sets the value mapped from schema property path 'reward.itemId'.
/// </summary>
/// <remarks>
/// Schema property path: 'reward.itemId'.
/// Generated default initializer: = string.Empty;
/// </remarks>
public string ItemId { get; set; } = string.Empty;
}
}

View File

@ -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,

View File

@ -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;

View File

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