mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-07 00:39:00 +08:00
feat(config): 添加配置验证功能支持枚举对象和数组
- 实现 parseSchemaContent 函数解析对象和数组枚举元数据 - 添加 validateParsedConfig 验证对象值是否在枚举声明范围内 - 支持数组枚举候选项的顺序敏感比较 - 优化诊断信息避免父对象枚举不匹配的重复报告 - 添加测试用例验证枚举对象和数组的解析与验证功能 - 实现可编辑字段收集功能支持批量编辑器更新 - 添加 YAML 解析和注释提取功能用于表单预览 - 实现配置验证诊断生成功能支持本地化消息 - 添加格式化和规范化函数支持不同数据类型的处理
This commit is contained in:
parent
809e1f5ded
commit
a8cb82e2f1
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<string>();
|
||||
/// </remarks>
|
||||
public global::System.Collections.Generic.IReadOnlyList<string> DropItemIds { get; set; } = global::System.Array.Empty<string>();
|
||||
|
||||
}
|
||||
@ -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;
|
||||
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user