mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-06 16:16:44 +08:00
test(config): 添加配置验证功能的全面单元测试
- 实现了对嵌套对象和对象数组元数据捕获的测试 - 添加了标量、对象、数组、整数和布尔类型常量元数据测试 - 验证了空字符串常量原始值和显示元数据的保留功能 - 测试了对象常量可比较键的构建逻辑 - 实现了嵌套映射和对象数组解析功能的测试 - 验证了复杂映射键的保留功能 - 添加了缺失和未知嵌套属性报告的测试 - 实现了对象数组项目问题检测功能的测试 - 验证了深层枚举不匹配的报告功能 - 测试了标量常量不匹配检测功能 - 实现了各种类型常量匹配验证的测试 - 验证了对象常量比较标准化但保持数组顺序的功能 - 添加了对象和数组常量不匹配检测的测试 - 实现了整数和布尔常量标量标准化及不匹配测试 - 验证了数字范围和字符串长度不匹配检测功能 - 测试了独占边界、模式和数组项目计数不匹配检测 - 实现了支持字符串格式验证的测试 - 验证了受支持字符串格式接受功能 - 添加了独占最大值和最大项目违规检测的测试 - 实现了对象属性计数不匹配报告功能的测试 - 验证了唯一对象属性计数约束功能 - 测试了倍数和唯一项目违规检测功能 - 实现了包含匹配计数违规报告的测试 - 验证了结构无效项目时跳过包含匹配计数功能 - 测试了仅值级违规时继续包含匹配计数功能 - 实现了最大包含违规检测的测试 - 验证了满足包含约束接受功能 - 测试了对象包含匹配允许额外声明字段功能 - 实现了大十进制倍数无浮点漂移接受的测试 - 验证了非实际倍数大数字拒绝功能 - 测试了科学记数法数字接受功能 - 实现了Unicode语义应用模式的测试 - 验证了无效数组项目跳过唯一项目检查功能 - 测试了一次通过报告每个唯一项目重复功能 - 实现了避免不同对象唯一项目可比较键冲突的测试 - 验证了标量范围和长度元数据捕获功能 - 测试了独占边界、模式和数组项目计数元数据捕获 - 实现了支持字符串格式元数据捕获的测试
This commit is contained in:
parent
ed5d11576d
commit
9fadde0a44
@ -229,6 +229,57 @@ public sealed class YamlConfigLoaderDependentRequiredTests
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 dependentRequired 的 schema 诊断会保留对象路径原始大小写,避免作者难以定位大小写敏感的坏元数据。
|
||||
/// </summary>
|
||||
[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<ConfigLoadException>(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));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 dependentRequired 只能引用同一对象内已声明的字段。
|
||||
/// </summary>
|
||||
@ -317,6 +368,17 @@ public sealed class YamlConfigLoaderDependentRequiredTests
|
||||
static config => config.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建使用大小写敏感对象路径的加载器,验证 schema 诊断不会篡改原始字段名。
|
||||
/// </summary>
|
||||
/// <returns>已注册 PascalCase 奖励节点的加载器。</returns>
|
||||
private YamlConfigLoader CreateCaseSensitiveRewardLoader()
|
||||
{
|
||||
return new YamlConfigLoader(_rootPath)
|
||||
.RegisterTable<int, MonsterPascalCaseRewardConfigStub>("monster", "monster", "schemas/monster.schema.json",
|
||||
static config => config.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建新的配置注册表,确保每个用例从干净状态开始。
|
||||
/// </summary>
|
||||
@ -357,4 +419,36 @@ public sealed class YamlConfigLoaderDependentRequiredTests
|
||||
/// </summary>
|
||||
public int ItemCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用于验证大小写敏感字段路径诊断的配置类型。
|
||||
/// </summary>
|
||||
private sealed class MonsterPascalCaseRewardConfigStub
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置主键。
|
||||
/// </summary>
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置使用 PascalCase 字段名的奖励对象。
|
||||
/// </summary>
|
||||
public PascalCaseRewardConfigStub Reward { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 表示使用 PascalCase 字段路径的奖励节点。
|
||||
/// </summary>
|
||||
private sealed class PascalCaseRewardConfigStub
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置掉落物 ID。
|
||||
/// </summary>
|
||||
public string ItemId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置掉落物数量。
|
||||
/// </summary>
|
||||
public int ItemCount { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}'";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为插入句中位置的对象级 schema 关键字构造稳定描述。
|
||||
/// 这里只调整语法前缀大小写,不改变真实字段路径,避免诊断消息把 schema 作者声明的大小写一起改写。
|
||||
/// </summary>
|
||||
/// <param name="propertyPath">对象字段路径。</param>
|
||||
/// <returns>可直接拼接到句中介词后的对象主体描述。</returns>
|
||||
private static string DescribeObjectSchemaTargetInClause(string propertyPath)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(propertyPath)
|
||||
? "root object"
|
||||
: $"property '{propertyPath}'";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取数组去重约束。
|
||||
/// </summary>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 以统一顺序递归遍历 schema 树,并把每个节点交给调用方提供的校验逻辑。
|
||||
/// 该遍历覆盖对象属性、<c>not</c> 子 schema、数组 <c>items</c> 与 <c>contains</c>,
|
||||
/// 避免不同关键字验证器在同一棵 schema 树上各自维护一份容易漂移的递归流程。
|
||||
/// </summary>
|
||||
/// <param name="filePath">Schema 文件路径。</param>
|
||||
/// <param name="displayPath">逻辑字段路径。</param>
|
||||
/// <param name="element">当前 schema 节点。</param>
|
||||
/// <param name="nodeValidator">当前节点的关键字校验回调。</param>
|
||||
/// <param name="diagnostic">失败时返回的诊断。</param>
|
||||
/// <returns>当前节点树是否通过指定关键字的校验。</returns>
|
||||
private static bool TryTraverseSchemaRecursively(
|
||||
string filePath,
|
||||
string displayPath,
|
||||
JsonElement element,
|
||||
Func<string, string, JsonElement, string, (bool IsValid, Diagnostic? Diagnostic)> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user