test(config): 添加配置验证功能的全面单元测试

- 实现了对嵌套对象和对象数组元数据捕获的测试
- 添加了标量、对象、数组、整数和布尔类型常量元数据测试
- 验证了空字符串常量原始值和显示元数据的保留功能
- 测试了对象常量可比较键的构建逻辑
- 实现了嵌套映射和对象数组解析功能的测试
- 验证了复杂映射键的保留功能
- 添加了缺失和未知嵌套属性报告的测试
- 实现了对象数组项目问题检测功能的测试
- 验证了深层枚举不匹配的报告功能
- 测试了标量常量不匹配检测功能
- 实现了各种类型常量匹配验证的测试
- 验证了对象常量比较标准化但保持数组顺序的功能
- 添加了对象和数组常量不匹配检测的测试
- 实现了整数和布尔常量标量标准化及不匹配测试
- 验证了数字范围和字符串长度不匹配检测功能
- 测试了独占边界、模式和数组项目计数不匹配检测
- 实现了支持字符串格式验证的测试
- 验证了受支持字符串格式接受功能
- 添加了独占最大值和最大项目违规检测的测试
- 实现了对象属性计数不匹配报告功能的测试
- 验证了唯一对象属性计数约束功能
- 测试了倍数和唯一项目违规检测功能
- 实现了包含匹配计数违规报告的测试
- 验证了结构无效项目时跳过包含匹配计数功能
- 测试了仅值级违规时继续包含匹配计数功能
- 实现了最大包含违规检测的测试
- 验证了满足包含约束接受功能
- 测试了对象包含匹配允许额外声明字段功能
- 实现了大十进制倍数无浮点漂移接受的测试
- 验证了非实际倍数大数字拒绝功能
- 测试了科学记数法数字接受功能
- 实现了Unicode语义应用模式的测试
- 验证了无效数组项目跳过唯一项目检查功能
- 测试了一次通过报告每个唯一项目重复功能
- 实现了避免不同对象唯一项目可比较键冲突的测试
- 验证了标量范围和长度元数据捕获功能
- 测试了独占边界、模式和数组项目计数元数据捕获
- 实现了支持字符串格式元数据捕获的测试
This commit is contained in:
GeWuYou 2026-04-16 18:48:15 +08:00
parent ed5d11576d
commit 9fadde0a44
5 changed files with 204 additions and 84 deletions

View File

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

View File

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

View File

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

View File

@ -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", () => {

View File

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