From 9fadde0a44e70a653b45ac63a53919d0056b3eb3 Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Thu, 16 Apr 2026 18:48:15 +0800
Subject: [PATCH] =?UTF-8?q?test(config):=20=E6=B7=BB=E5=8A=A0=E9=85=8D?=
=?UTF-8?q?=E7=BD=AE=E9=AA=8C=E8=AF=81=E5=8A=9F=E8=83=BD=E7=9A=84=E5=85=A8?=
=?UTF-8?q?=E9=9D=A2=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 实现了对嵌套对象和对象数组元数据捕获的测试
- 添加了标量、对象、数组、整数和布尔类型常量元数据测试
- 验证了空字符串常量原始值和显示元数据的保留功能
- 测试了对象常量可比较键的构建逻辑
- 实现了嵌套映射和对象数组解析功能的测试
- 验证了复杂映射键的保留功能
- 添加了缺失和未知嵌套属性报告的测试
- 实现了对象数组项目问题检测功能的测试
- 验证了深层枚举不匹配的报告功能
- 测试了标量常量不匹配检测功能
- 实现了各种类型常量匹配验证的测试
- 验证了对象常量比较标准化但保持数组顺序的功能
- 添加了对象和数组常量不匹配检测的测试
- 实现了整数和布尔常量标量标准化及不匹配测试
- 验证了数字范围和字符串长度不匹配检测功能
- 测试了独占边界、模式和数组项目计数不匹配检测
- 实现了支持字符串格式验证的测试
- 验证了受支持字符串格式接受功能
- 添加了独占最大值和最大项目违规检测的测试
- 实现了对象属性计数不匹配报告功能的测试
- 验证了唯一对象属性计数约束功能
- 测试了倍数和唯一项目违规检测功能
- 实现了包含匹配计数违规报告的测试
- 验证了结构无效项目时跳过包含匹配计数功能
- 测试了仅值级违规时继续包含匹配计数功能
- 实现了最大包含违规检测的测试
- 验证了满足包含约束接受功能
- 测试了对象包含匹配允许额外声明字段功能
- 实现了大十进制倍数无浮点漂移接受的测试
- 验证了非实际倍数大数字拒绝功能
- 测试了科学记数法数字接受功能
- 实现了Unicode语义应用模式的测试
- 验证了无效数组项目跳过唯一项目检查功能
- 测试了一次通过报告每个唯一项目重复功能
- 实现了避免不同对象唯一项目可比较键冲突的测试
- 验证了标量范围和长度元数据捕获功能
- 测试了独占边界、模式和数组项目计数元数据捕获
- 实现了支持字符串格式元数据捕获的测试
---
.../YamlConfigLoaderDependentRequiredTests.cs | 94 +++++++++++
.../Config/YamlConfigSchemaValidator.cs | 19 ++-
.../Config/SchemaConfigGenerator.cs | 151 +++++++++---------
.../test/configValidation.test.js | 12 +-
.../test/localization.test.js | 12 ++
5 files changed, 204 insertions(+), 84 deletions(-)
diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderDependentRequiredTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderDependentRequiredTests.cs
index e9ae7602..d9978c61 100644
--- a/GFramework.Game.Tests/Config/YamlConfigLoaderDependentRequiredTests.cs
+++ b/GFramework.Game.Tests/Config/YamlConfigLoaderDependentRequiredTests.cs
@@ -229,6 +229,57 @@ public sealed class YamlConfigLoaderDependentRequiredTests
});
}
+ ///
+ /// 验证 dependentRequired 的 schema 诊断会保留对象路径原始大小写,避免作者难以定位大小写敏感的坏元数据。
+ ///
+ [Test]
+ public void LoadAsync_Should_Preserve_Object_Path_Casing_In_DependentRequired_Diagnostics()
+ {
+ CreateConfigFile(
+ "monster/slime.yaml",
+ """
+ id: 1
+ Reward:
+ ItemId: potion
+ """);
+ CreateSchemaFile(
+ "schemas/monster.schema.json",
+ """
+ {
+ "type": "object",
+ "required": ["id", "Reward"],
+ "properties": {
+ "id": { "type": "integer" },
+ "Reward": {
+ "type": "object",
+ "properties": {
+ "ItemId": { "type": "string" },
+ "ItemCount": { "type": "integer" }
+ },
+ "dependentRequired": {
+ "ItemId": [42]
+ }
+ }
+ }
+ }
+ """);
+
+ var loader = CreateCaseSensitiveRewardLoader();
+ var registry = CreateRegistry();
+
+ var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(exception, Is.Not.Null);
+ Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.SchemaUnsupported));
+ Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("Reward"));
+ Assert.That(exception.Message, Does.Contain("Property 'ItemId' in property 'Reward'"));
+ Assert.That(exception.Message, Does.Not.Contain("property 'reward'"));
+ Assert.That(registry.Count, Is.EqualTo(0));
+ });
+ }
+
///
/// 验证 dependentRequired 只能引用同一对象内已声明的字段。
///
@@ -317,6 +368,17 @@ public sealed class YamlConfigLoaderDependentRequiredTests
static config => config.Id);
}
+ ///
+ /// 创建使用大小写敏感对象路径的加载器,验证 schema 诊断不会篡改原始字段名。
+ ///
+ /// 已注册 PascalCase 奖励节点的加载器。
+ private YamlConfigLoader CreateCaseSensitiveRewardLoader()
+ {
+ return new YamlConfigLoader(_rootPath)
+ .RegisterTable("monster", "monster", "schemas/monster.schema.json",
+ static config => config.Id);
+ }
+
///
/// 创建新的配置注册表,确保每个用例从干净状态开始。
///
@@ -357,4 +419,36 @@ public sealed class YamlConfigLoaderDependentRequiredTests
///
public int ItemCount { get; set; }
}
+
+ ///
+ /// 用于验证大小写敏感字段路径诊断的配置类型。
+ ///
+ private sealed class MonsterPascalCaseRewardConfigStub
+ {
+ ///
+ /// 获取或设置主键。
+ ///
+ public int Id { get; set; }
+
+ ///
+ /// 获取或设置使用 PascalCase 字段名的奖励对象。
+ ///
+ public PascalCaseRewardConfigStub Reward { get; set; } = new();
+ }
+
+ ///
+ /// 表示使用 PascalCase 字段路径的奖励节点。
+ ///
+ private sealed class PascalCaseRewardConfigStub
+ {
+ ///
+ /// 获取或设置掉落物 ID。
+ ///
+ public string ItemId { get; set; } = string.Empty;
+
+ ///
+ /// 获取或设置掉落物数量。
+ ///
+ public int ItemCount { get; set; }
+ }
}
diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs
index 51ea0a3c..e523c32e 100644
--- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs
+++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs
@@ -1631,7 +1631,7 @@ internal static class YamlConfigSchemaValidator
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
- $"Property '{dependency.Name}' in {DescribeObjectSchemaTarget(propertyPath).ToLowerInvariant()} of schema file '{schemaPath}' must declare 'dependentRequired' as an array of sibling property names.",
+ $"Property '{dependency.Name}' in {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'dependentRequired' as an array of sibling property names.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
@@ -1645,7 +1645,7 @@ internal static class YamlConfigSchemaValidator
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
- $"Property '{dependency.Name}' in {DescribeObjectSchemaTarget(propertyPath).ToLowerInvariant()} of schema file '{schemaPath}' must declare 'dependentRequired' entries as strings.",
+ $"Property '{dependency.Name}' in {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'dependentRequired' entries as strings.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
@@ -1656,7 +1656,7 @@ internal static class YamlConfigSchemaValidator
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
- $"Property '{dependency.Name}' in {DescribeObjectSchemaTarget(propertyPath).ToLowerInvariant()} of schema file '{schemaPath}' cannot declare blank 'dependentRequired' entries.",
+ $"Property '{dependency.Name}' in {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' cannot declare blank 'dependentRequired' entries.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
@@ -2073,6 +2073,19 @@ internal static class YamlConfigSchemaValidator
: $"Property '{propertyPath}'";
}
+ ///
+ /// 为插入句中位置的对象级 schema 关键字构造稳定描述。
+ /// 这里只调整语法前缀大小写,不改变真实字段路径,避免诊断消息把 schema 作者声明的大小写一起改写。
+ ///
+ /// 对象字段路径。
+ /// 可直接拼接到句中介词后的对象主体描述。
+ private static string DescribeObjectSchemaTargetInClause(string propertyPath)
+ {
+ return string.IsNullOrWhiteSpace(propertyPath)
+ ? "root object"
+ : $"property '{propertyPath}'";
+ }
+
///
/// 读取数组去重约束。
///
diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
index e6fa5cc4..7b449fa9 100644
--- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
+++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
@@ -644,6 +644,47 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
string displayPath,
JsonElement element,
out Diagnostic? diagnostic)
+ {
+ return TryTraverseSchemaRecursively(
+ filePath,
+ displayPath,
+ element,
+ static (currentFilePath, currentDisplayPath, currentElement, schemaType) =>
+ {
+ if (string.IsNullOrWhiteSpace(schemaType))
+ {
+ return (true, (Diagnostic?)null);
+ }
+
+ return TryValidateStringFormatMetadata(
+ currentFilePath,
+ currentDisplayPath,
+ currentElement,
+ schemaType,
+ out var currentDiagnostic)
+ ? (true, (Diagnostic?)null)
+ : (false, currentDiagnostic);
+ },
+ out diagnostic);
+ }
+
+ ///
+ /// 以统一顺序递归遍历 schema 树,并把每个节点交给调用方提供的校验逻辑。
+ /// 该遍历覆盖对象属性、not 子 schema、数组 items 与 contains,
+ /// 避免不同关键字验证器在同一棵 schema 树上各自维护一份容易漂移的递归流程。
+ ///
+ /// Schema 文件路径。
+ /// 逻辑字段路径。
+ /// 当前 schema 节点。
+ /// 当前节点的关键字校验回调。
+ /// 失败时返回的诊断。
+ /// 当前节点树是否通过指定关键字的校验。
+ private static bool TryTraverseSchemaRecursively(
+ string filePath,
+ string displayPath,
+ JsonElement element,
+ Func nodeValidator,
+ out Diagnostic? diagnostic)
{
diagnostic = null;
if (element.ValueKind != JsonValueKind.Object)
@@ -656,11 +697,13 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
typeElement.ValueKind == JsonValueKind.String)
{
schemaType = typeElement.GetString() ?? string.Empty;
- if (!string.IsNullOrWhiteSpace(schemaType) &&
- !TryValidateStringFormatMetadata(filePath, displayPath, element, schemaType, out diagnostic))
- {
- return false;
- }
+ }
+
+ var nodeValidationResult = nodeValidator(filePath, displayPath, element, schemaType);
+ if (!nodeValidationResult.IsValid)
+ {
+ diagnostic = nodeValidationResult.Diagnostic;
+ return false;
}
if (string.Equals(schemaType, "object", StringComparison.Ordinal) &&
@@ -669,10 +712,11 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
{
foreach (var property in propertiesElement.EnumerateObject())
{
- if (!TryValidateStringFormatMetadataRecursively(
+ if (!TryTraverseSchemaRecursively(
filePath,
CombinePath(displayPath, property.Name),
property.Value,
+ nodeValidator,
out diagnostic))
{
return false;
@@ -682,10 +726,11 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
if (element.TryGetProperty("not", out var notElement) &&
notElement.ValueKind == JsonValueKind.Object &&
- !TryValidateStringFormatMetadataRecursively(
+ !TryTraverseSchemaRecursively(
filePath,
$"{displayPath}[not]",
notElement,
+ nodeValidator,
out diagnostic))
{
return false;
@@ -698,17 +743,23 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
if (element.TryGetProperty("items", out var itemsElement) &&
itemsElement.ValueKind == JsonValueKind.Object &&
- !TryValidateStringFormatMetadataRecursively(filePath, $"{displayPath}[]", itemsElement, out diagnostic))
+ !TryTraverseSchemaRecursively(
+ filePath,
+ $"{displayPath}[]",
+ itemsElement,
+ nodeValidator,
+ out diagnostic))
{
return false;
}
if (element.TryGetProperty("contains", out var containsElement) &&
containsElement.ValueKind == JsonValueKind.Object &&
- !TryValidateStringFormatMetadataRecursively(
+ !TryTraverseSchemaRecursively(
filePath,
$"{displayPath}[contains]",
containsElement,
+ nodeValidator,
out diagnostic))
{
return false;
@@ -733,76 +784,26 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
JsonElement element,
out Diagnostic? diagnostic)
{
- diagnostic = null;
- if (element.ValueKind != JsonValueKind.Object)
- {
- return true;
- }
-
- var schemaType = string.Empty;
- if (element.TryGetProperty("type", out var typeElement) &&
- typeElement.ValueKind == JsonValueKind.String)
- {
- schemaType = typeElement.GetString() ?? string.Empty;
- if (string.Equals(schemaType, "object", StringComparison.Ordinal) &&
- !TryValidateDependentRequiredMetadata(filePath, displayPath, element, out diagnostic))
+ return TryTraverseSchemaRecursively(
+ filePath,
+ displayPath,
+ element,
+ static (currentFilePath, currentDisplayPath, currentElement, schemaType) =>
{
- return false;
- }
- }
-
- if (string.Equals(schemaType, "object", StringComparison.Ordinal) &&
- element.TryGetProperty("properties", out var propertiesElement) &&
- propertiesElement.ValueKind == JsonValueKind.Object)
- {
- foreach (var property in propertiesElement.EnumerateObject())
- {
- if (!TryValidateDependentRequiredMetadataRecursively(
- filePath,
- CombinePath(displayPath, property.Name),
- property.Value,
- out diagnostic))
+ if (!string.Equals(schemaType, "object", StringComparison.Ordinal))
{
- return false;
+ return (true, (Diagnostic?)null);
}
- }
- }
- if (element.TryGetProperty("not", out var notElement) &&
- notElement.ValueKind == JsonValueKind.Object &&
- !TryValidateDependentRequiredMetadataRecursively(
- filePath,
- $"{displayPath}[not]",
- notElement,
- out diagnostic))
- {
- return false;
- }
-
- if (!string.Equals(schemaType, "array", StringComparison.Ordinal))
- {
- return true;
- }
-
- if (element.TryGetProperty("items", out var itemsElement) &&
- itemsElement.ValueKind == JsonValueKind.Object &&
- !TryValidateDependentRequiredMetadataRecursively(filePath, $"{displayPath}[]", itemsElement, out diagnostic))
- {
- return false;
- }
-
- if (element.TryGetProperty("contains", out var containsElement) &&
- containsElement.ValueKind == JsonValueKind.Object &&
- !TryValidateDependentRequiredMetadataRecursively(
- filePath,
- $"{displayPath}[contains]",
- containsElement,
- out diagnostic))
- {
- return false;
- }
-
- return true;
+ return TryValidateDependentRequiredMetadata(
+ currentFilePath,
+ currentDisplayPath,
+ currentElement,
+ out var currentDiagnostic)
+ ? (true, (Diagnostic?)null)
+ : (false, currentDiagnostic);
+ },
+ out diagnostic);
}
///
diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js
index f6d52fca..d7fc30ee 100644
--- a/tools/gframework-config-tool/test/configValidation.test.js
+++ b/tools/gframework-config-tool/test/configValidation.test.js
@@ -1734,12 +1734,12 @@ reward:
itemId: potion
`);
- assert.deepEqual(validateParsedConfig(schema, yaml), [
- {
- severity: "error",
- message: "Property 'reward.itemCount' is required when sibling property 'reward.itemId' is present."
- }
- ]);
+ const diagnostics = validateParsedConfig(schema, yaml);
+
+ assert.equal(diagnostics.length, 1);
+ assert.equal(diagnostics[0].severity, "error");
+ assert.match(diagnostics[0].message, /reward\.itemCount/u);
+ assert.match(diagnostics[0].message, /reward\.itemId/u);
});
test("validateParsedConfig should accept missing dependentRequired targets when the trigger is absent", () => {
diff --git a/tools/gframework-config-tool/test/localization.test.js b/tools/gframework-config-tool/test/localization.test.js
index b5cd2008..f97606cd 100644
--- a/tools/gframework-config-tool/test/localization.test.js
+++ b/tools/gframework-config-tool/test/localization.test.js
@@ -86,6 +86,18 @@ test("createLocalizer should expose dependentRequired validation keys", () => {
const englishLocalizer = createLocalizer("en");
const chineseLocalizer = createLocalizer("zh-cn");
+ assert.equal(
+ englishLocalizer.t("webview.hint.dependentRequired", {
+ trigger: "reward.itemId",
+ dependencies: "reward.itemCount, reward.bonusCount"
+ }),
+ "When reward.itemId is set: require reward.itemCount, reward.bonusCount");
+ assert.equal(
+ chineseLocalizer.t("webview.hint.dependentRequired", {
+ trigger: "reward.itemId",
+ dependencies: "reward.itemCount, reward.bonusCount"
+ }),
+ "当 reward.itemId 出现时:还必须声明 reward.itemCount, reward.bonusCount");
assert.equal(
englishLocalizer.t(ValidationMessageKeys.dependentRequiredViolation, {
displayPath: "reward.itemCount",