mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-06 16:16:44 +08:00
feat(config): 添加配置验证工具功能
- 实现了JSON Schema解析和YAML验证功能 - 添加了对象和数组枚举值的比较验证逻辑 - 实现了配置文件的采样生成功能 - 添加了批量编辑器的数值更新功能 - 实现了配置路径和注释提取功能 - 添加了多种数据格式验证支持包括日期、邮箱、UUID等 - 实现了常量和枚举值的元数据处理功能 - 添加了配置验证诊断信息生成功能 - 实现了表单更新应用到YAML的功能 - 添加了字符串排序比较算法确保工具一致性
This commit is contained in:
parent
a8cb82e2f1
commit
33dd108697
@ -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>
|
||||
|
||||
@ -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<PhasesItemConfig>();
|
||||
/// </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;
|
||||
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user