feat(config): 添加配置验证工具功能

- 实现了JSON Schema解析和YAML验证功能
- 添加了对象和数组枚举值的比较验证逻辑
- 实现了配置文件的采样生成功能
- 添加了批量编辑器的数值更新功能
- 实现了配置路径和注释提取功能
- 添加了多种数据格式验证支持包括日期、邮箱、UUID等
- 实现了常量和枚举值的元数据处理功能
- 添加了配置验证诊断信息生成功能
- 实现了表单更新应用到YAML的功能
- 添加了字符串排序比较算法确保工具一致性
This commit is contained in:
GeWuYou 2026-04-16 15:12:02 +08:00
parent a8cb82e2f1
commit 33dd108697
5 changed files with 185 additions and 2 deletions

View File

@ -91,6 +91,54 @@ public class SchemaConfigGeneratorEnumTests
await AssertSnapshotAsync(result, "MonsterConfig.ArrayItemEnum.g.txt");
}
/// <summary>
/// 验证对象数组项 <c>enum</c> 文档回退输出与快照保持一致。
/// </summary>
[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");
}
/// <summary>
/// 对单个生成文件执行快照断言。
/// </summary>

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 'phases'.
/// </summary>
/// <remarks>
/// Schema property path: 'phases'.
/// Allowed values: { "wave": 1, "monsterId": "slime" }, { "wave": 2, "monsterId": "goblin" }.
/// Generated default initializer: = global::System.Array.Empty&lt;PhasesItemConfig&gt;();
/// </remarks>
public global::System.Collections.Generic.IReadOnlyList<PhasesItemConfig> Phases { get; set; } = global::System.Array.Empty<PhasesItemConfig>();
/// <summary>
/// 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.
/// </summary>
public sealed partial class PhasesItemConfig
{
/// <summary>
/// Gets or sets the value mapped from schema property path 'phases[].wave'.
/// </summary>
/// <remarks>
/// Schema property path: 'phases[].wave'.
/// </remarks>
public int Wave { get; set; }
/// <summary>
/// Gets or sets the value mapped from schema property path 'phases[].monsterId'.
/// </summary>
/// <remarks>
/// Schema property path: 'phases[].monsterId'.
/// Generated default initializer: = string.Empty;
/// </remarks>
public string MonsterId { get; set; } = string.Empty;
}
}

View File

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

View File

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

View File

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