feat(config): 添加配置验证和YAML解析功能

- 实现了JSON schema解析器和验证器
- 添加了YAML文档解析和注释提取功能
- 创建了配置验证诊断系统支持中英文本地化
- 实现了批量编辑器可编辑字段收集功能
- 添加了配置文件示例生成功能
- 实现了表单更新应用到YAML的功能
- 添加了精确十进制算术运算支持multipleOf约束检查
- 实现了YAML标量值格式化和引用处理
- 创建了完整的配置验证消息本地化系统
This commit is contained in:
GeWuYou 2026-04-10 10:10:47 +08:00
parent 06d048f38a
commit 7931b41589
7 changed files with 248 additions and 30 deletions

View File

@ -1357,6 +1357,113 @@ public class YamlConfigLoaderTests
}); });
} }
/// <summary>
/// 验证对象字段将 <c>minProperties</c> 声明为非法值时,会在 schema 解析阶段被拒绝。
/// </summary>
[Test]
public void LoadAsync_Should_Throw_When_Object_Property_Count_Constraint_Is_Not_NonNegative_Integer()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
reward:
gold: 10
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name", "reward"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"reward": {
"type": "object",
"minProperties": -1,
"properties": {
"gold": { "type": "integer" }
}
}
}
}
""");
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable<int, MonsterNestedConfigStub>("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
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("minProperties"));
Assert.That(exception.Message, Does.Contain("non-negative integer"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
/// <summary>
/// 验证对象字段将 <c>minProperties</c> 声明为大于 <c>maxProperties</c> 时,会在 schema 解析阶段被拒绝。
/// </summary>
[Test]
public void LoadAsync_Should_Throw_When_Object_Property_Count_Constraints_Are_Inverted()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
reward:
gold: 10
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name", "reward"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"reward": {
"type": "object",
"minProperties": 3,
"maxProperties": 2,
"properties": {
"gold": { "type": "integer" },
"currency": { "type": "string" },
"tier": { "type": "string" }
}
}
}
}
""");
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable<int, MonsterNestedConfigStub>("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
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("minProperties"));
Assert.That(exception.Message, Does.Contain("greater than 'maxProperties'"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
/// <summary> /// <summary>
/// 验证对象数组中的嵌套字段也会按 schema 递归校验。 /// 验证对象数组中的嵌套字段也会按 schema 递归校验。
/// </summary> /// </summary>

View File

@ -964,10 +964,11 @@ internal static class YamlConfigSchemaValidator
if (minProperties.HasValue && maxProperties.HasValue && minProperties.Value > maxProperties.Value) if (minProperties.HasValue && maxProperties.HasValue && minProperties.Value > maxProperties.Value)
{ {
var targetDescription = DescribeObjectSchemaTarget(propertyPath);
throw ConfigLoadExceptionFactory.Create( throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported, ConfigLoadFailureKind.SchemaUnsupported,
tableName, tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' declares 'minProperties' greater than 'maxProperties'.", $"{targetDescription} in schema file '{schemaPath}' declares 'minProperties' greater than 'maxProperties'.",
schemaPath: schemaPath, schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath)); displayPath: GetDiagnosticPath(propertyPath));
} }
@ -1232,10 +1233,11 @@ internal static class YamlConfigSchemaValidator
!constraintElement.TryGetInt32(out var constraintValue) || !constraintElement.TryGetInt32(out var constraintValue) ||
constraintValue < 0) constraintValue < 0)
{ {
var targetDescription = DescribeObjectSchemaTarget(propertyPath);
throw ConfigLoadExceptionFactory.Create( throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported, ConfigLoadFailureKind.SchemaUnsupported,
tableName, tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' must declare '{keywordName}' as a non-negative integer.", $"{targetDescription} in schema file '{schemaPath}' must declare '{keywordName}' as a non-negative integer.",
schemaPath: schemaPath, schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath)); displayPath: GetDiagnosticPath(propertyPath));
} }
@ -1243,6 +1245,19 @@ internal static class YamlConfigSchemaValidator
return constraintValue; return constraintValue;
} }
/// <summary>
/// 为对象级 schema 关键字构造稳定的诊断主体。
/// 根对象不会再显示为空字符串属性名,避免坏 schema 诊断出现 <c>Property ''</c> 之类的文本。
/// </summary>
/// <param name="propertyPath">对象字段路径。</param>
/// <returns>用于错误消息的对象主体描述。</returns>
private static string DescribeObjectSchemaTarget(string propertyPath)
{
return string.IsNullOrWhiteSpace(propertyPath)
? "Root object"
: $"Property '{propertyPath}'";
}
/// <summary> /// <summary>
/// 读取数组去重约束。 /// 读取数组去重约束。
/// </summary> /// </summary>

View File

@ -314,6 +314,48 @@ public class SchemaConfigGeneratorTests
Does.Contain("Throwing here would permanently poison the cached index for this wrapper instance.")); Does.Contain("Throwing here would permanently poison the cached index for this wrapper instance."));
} }
/// <summary>
/// 验证生成器对 <c>required</c> 名称保持大小写敏感,避免与运行时 validator 对同一 schema 产生分歧。
/// </summary>
[Test]
public void Run_Should_Treat_Required_Property_Names_As_Case_Sensitive()
{
const string source = """
namespace TestApp
{
public sealed class Dummy
{
}
}
""";
const string schema = """
{
"type": "object",
"required": ["id", "Name"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" }
}
}
""";
var result = SchemaGeneratorTestDriver.Run(
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("public string? Name { get; set; }"));
}
/// <summary> /// <summary>
/// 验证 schema 顶层自定义配置目录元数据不能逃逸配置根目录。 /// 验证 schema 顶层自定义配置目录元数据不能逃逸配置根目录。
/// </summary> /// </summary>

View File

@ -220,7 +220,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
Path.GetFileName(filePath))); Path.GetFileName(filePath)));
} }
var requiredProperties = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var requiredProperties = new HashSet<string>(StringComparer.Ordinal);
if (element.TryGetProperty("required", out var requiredElement) && if (element.TryGetProperty("required", out var requiredElement) &&
requiredElement.ValueKind == JsonValueKind.Array) requiredElement.ValueKind == JsonValueKind.Array)
{ {

View File

@ -958,24 +958,20 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
*/ */
function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) { function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) {
if (!yamlNode || yamlNode.kind !== "object") { if (!yamlNode || yamlNode.kind !== "object") {
const subject = displayPath.length === 0
? localizer && localizer.isChinese
? "根对象应为对象。"
: "Root object is expected to be an object."
: localizer && localizer.isChinese
? `属性“${displayPath}”应为对象。`
: `Property '${displayPath}' is expected to be an object.`;
diagnostics.push({ diagnostics.push({
severity: "error", severity: "error",
message: localizeValidationMessage(ValidationMessageKeys.expectedObject, localizer, { message: localizeValidationMessage(ValidationMessageKeys.expectedObject, localizer, {
subject,
displayPath displayPath
}) })
}); });
return; return;
} }
const propertyCount = Array.isArray(yamlNode.entries) ? yamlNode.entries.length : 0; const propertyCount = yamlNode.map instanceof Map
? yamlNode.map.size
: Array.isArray(yamlNode.entries)
? new Set(yamlNode.entries.map((entry) => entry.key)).size
: 0;
for (const requiredProperty of schemaNode.required) { for (const requiredProperty of schemaNode.required) {
if (!yamlNode.map.has(requiredProperty)) { if (!yamlNode.map.has(requiredProperty)) {
@ -1090,6 +1086,10 @@ function buildComparableNodeValue(schemaNode, yamlNode) {
* @returns {string} Localized validation message. * @returns {string} Localized validation message.
*/ */
function localizeValidationMessage(key, localizer, params) { function localizeValidationMessage(key, localizer, params) {
if (key === ValidationMessageKeys.expectedObject) {
return formatExpectedObjectMessage(params.displayPath, Boolean(localizer && localizer.isChinese));
}
if (key === ValidationMessageKeys.minPropertiesViolation) { if (key === ValidationMessageKeys.minPropertiesViolation) {
return formatObjectPropertyCountMessage( return formatObjectPropertyCountMessage(
params.displayPath, params.displayPath,
@ -1130,8 +1130,6 @@ function localizeValidationMessage(key, localizer, params) {
return `属性“${params.displayPath}”最多只能包含 ${params.value} 个元素。`; return `属性“${params.displayPath}”最多只能包含 ${params.value} 个元素。`;
case ValidationMessageKeys.maxLengthViolation: case ValidationMessageKeys.maxLengthViolation:
return `属性“${params.displayPath}”长度必须不超过 ${params.value} 个字符。`; return `属性“${params.displayPath}”长度必须不超过 ${params.value} 个字符。`;
case ValidationMessageKeys.maxPropertiesViolation:
return formatObjectPropertyCountMessage(params.displayPath, params.value, "max", true);
case ValidationMessageKeys.minimumViolation: case ValidationMessageKeys.minimumViolation:
return `属性“${params.displayPath}”必须大于或等于 ${params.value}`; return `属性“${params.displayPath}”必须大于或等于 ${params.value}`;
case ValidationMessageKeys.multipleOfViolation: case ValidationMessageKeys.multipleOfViolation:
@ -1140,14 +1138,10 @@ function localizeValidationMessage(key, localizer, params) {
return `属性“${params.displayPath}”至少需要包含 ${params.value} 个元素。`; return `属性“${params.displayPath}”至少需要包含 ${params.value} 个元素。`;
case ValidationMessageKeys.minLengthViolation: case ValidationMessageKeys.minLengthViolation:
return `属性“${params.displayPath}”长度必须至少为 ${params.value} 个字符。`; return `属性“${params.displayPath}”长度必须至少为 ${params.value} 个字符。`;
case ValidationMessageKeys.minPropertiesViolation:
return formatObjectPropertyCountMessage(params.displayPath, params.value, "min", true);
case ValidationMessageKeys.patternViolation: case ValidationMessageKeys.patternViolation:
return `属性“${params.displayPath}”必须匹配正则模式“${params.value}”。`; return `属性“${params.displayPath}”必须匹配正则模式“${params.value}”。`;
case ValidationMessageKeys.uniqueItemsViolation: case ValidationMessageKeys.uniqueItemsViolation:
return `属性“${params.displayPath}”与更早的数组元素 ${params.duplicatePath} 重复;该数组要求元素唯一。`; return `属性“${params.displayPath}”与更早的数组元素 ${params.duplicatePath} 重复;该数组要求元素唯一。`;
case ValidationMessageKeys.expectedObject:
return params.subject;
case ValidationMessageKeys.missingRequired: case ValidationMessageKeys.missingRequired:
return `缺少必填属性“${params.displayPath}”。`; return `缺少必填属性“${params.displayPath}”。`;
case ValidationMessageKeys.unknownProperty: case ValidationMessageKeys.unknownProperty:
@ -1176,8 +1170,6 @@ function localizeValidationMessage(key, localizer, params) {
return `Property '${params.displayPath}' must contain at most ${params.value} items.`; return `Property '${params.displayPath}' must contain at most ${params.value} items.`;
case ValidationMessageKeys.maxLengthViolation: case ValidationMessageKeys.maxLengthViolation:
return `Property '${params.displayPath}' must be at most ${params.value} characters long.`; return `Property '${params.displayPath}' must be at most ${params.value} characters long.`;
case ValidationMessageKeys.maxPropertiesViolation:
return formatObjectPropertyCountMessage(params.displayPath, params.value, "max", false);
case ValidationMessageKeys.minimumViolation: case ValidationMessageKeys.minimumViolation:
return `Property '${params.displayPath}' must be greater than or equal to ${params.value}.`; return `Property '${params.displayPath}' must be greater than or equal to ${params.value}.`;
case ValidationMessageKeys.multipleOfViolation: case ValidationMessageKeys.multipleOfViolation:
@ -1186,14 +1178,10 @@ function localizeValidationMessage(key, localizer, params) {
return `Property '${params.displayPath}' must contain at least ${params.value} items.`; return `Property '${params.displayPath}' must contain at least ${params.value} items.`;
case ValidationMessageKeys.minLengthViolation: case ValidationMessageKeys.minLengthViolation:
return `Property '${params.displayPath}' must be at least ${params.value} characters long.`; return `Property '${params.displayPath}' must be at least ${params.value} characters long.`;
case ValidationMessageKeys.minPropertiesViolation:
return formatObjectPropertyCountMessage(params.displayPath, params.value, "min", false);
case ValidationMessageKeys.patternViolation: case ValidationMessageKeys.patternViolation:
return `Property '${params.displayPath}' must match pattern '${params.value}'.`; return `Property '${params.displayPath}' must match pattern '${params.value}'.`;
case ValidationMessageKeys.uniqueItemsViolation: case ValidationMessageKeys.uniqueItemsViolation:
return `Property '${params.displayPath}' duplicates earlier array item '${params.duplicatePath}', but uniqueItems is required.`; return `Property '${params.displayPath}' duplicates earlier array item '${params.duplicatePath}', but uniqueItems is required.`;
case ValidationMessageKeys.expectedObject:
return params.subject;
case ValidationMessageKeys.missingRequired: case ValidationMessageKeys.missingRequired:
return `Required property '${params.displayPath}' is missing.`; return `Required property '${params.displayPath}' is missing.`;
case ValidationMessageKeys.unknownProperty: case ValidationMessageKeys.unknownProperty:
@ -1203,6 +1191,26 @@ function localizeValidationMessage(key, localizer, params) {
} }
} }
/**
* Format one object-shape expectation diagnostic.
*
* @param {string} displayPath Logical object path, or empty for the root object.
* @param {boolean} isChinese Whether Chinese text should be produced.
* @returns {string} Formatted message.
*/
function formatExpectedObjectMessage(displayPath, isChinese) {
const isRoot = !displayPath;
if (isChinese) {
return isRoot
? "根对象应为对象。"
: `属性“${displayPath}”应为对象。`;
}
return isRoot
? "Root object is expected to be an object."
: `Property '${displayPath}' is expected to be an object.`;
}
/** /**
* Format one object-property-count validation message. * Format one object-property-count validation message.
* *

View File

@ -136,17 +136,14 @@ const enMessages = {
[ValidationMessageKeys.maximumViolation]: "Property '{displayPath}' must be less than or equal to {value}.", [ValidationMessageKeys.maximumViolation]: "Property '{displayPath}' must be less than or equal to {value}.",
[ValidationMessageKeys.maxItemsViolation]: "Property '{displayPath}' must contain at most {value} items.", [ValidationMessageKeys.maxItemsViolation]: "Property '{displayPath}' must contain at most {value} items.",
[ValidationMessageKeys.maxLengthViolation]: "Property '{displayPath}' must be at most {value} characters long.", [ValidationMessageKeys.maxLengthViolation]: "Property '{displayPath}' must be at most {value} characters long.",
[ValidationMessageKeys.maxPropertiesViolation]: "Property '{displayPath}' must contain at most {value} properties.",
[ValidationMessageKeys.minimumViolation]: "Property '{displayPath}' must be greater than or equal to {value}.", [ValidationMessageKeys.minimumViolation]: "Property '{displayPath}' must be greater than or equal to {value}.",
[ValidationMessageKeys.multipleOfViolation]: "Property '{displayPath}' must be a multiple of {value}.", [ValidationMessageKeys.multipleOfViolation]: "Property '{displayPath}' must be a multiple of {value}.",
[ValidationMessageKeys.minItemsViolation]: "Property '{displayPath}' must contain at least {value} items.", [ValidationMessageKeys.minItemsViolation]: "Property '{displayPath}' must contain at least {value} items.",
[ValidationMessageKeys.minLengthViolation]: "Property '{displayPath}' must be at least {value} characters long.", [ValidationMessageKeys.minLengthViolation]: "Property '{displayPath}' must be at least {value} characters long.",
[ValidationMessageKeys.minPropertiesViolation]: "Property '{displayPath}' must contain at least {value} properties.",
[ValidationMessageKeys.patternViolation]: "Property '{displayPath}' must match pattern '{value}'.", [ValidationMessageKeys.patternViolation]: "Property '{displayPath}' must match pattern '{value}'.",
[ValidationMessageKeys.uniqueItemsViolation]: "Property '{displayPath}' duplicates earlier array item '{duplicatePath}', but uniqueItems is required.", [ValidationMessageKeys.uniqueItemsViolation]: "Property '{displayPath}' duplicates earlier array item '{duplicatePath}', but uniqueItems is required.",
[ValidationMessageKeys.enumMismatch]: "Property '{displayPath}' must be one of: {values}.", [ValidationMessageKeys.enumMismatch]: "Property '{displayPath}' must be one of: {values}.",
[ValidationMessageKeys.expectedArray]: "Property '{displayPath}' is expected to be an array.", [ValidationMessageKeys.expectedArray]: "Property '{displayPath}' is expected to be an array.",
[ValidationMessageKeys.expectedObject]: "{subject} is expected to be an object.",
[ValidationMessageKeys.expectedScalarShape]: "Property '{displayPath}' is expected to be '{schemaType}', but the current YAML shape is '{yamlKind}'.", [ValidationMessageKeys.expectedScalarShape]: "Property '{displayPath}' is expected to be '{schemaType}', but the current YAML shape is '{yamlKind}'.",
[ValidationMessageKeys.expectedScalarValue]: "Property '{displayPath}' is expected to be '{schemaType}', but the current scalar value is incompatible.", [ValidationMessageKeys.expectedScalarValue]: "Property '{displayPath}' is expected to be '{schemaType}', but the current scalar value is incompatible.",
[ValidationMessageKeys.missingRequired]: "Required property '{displayPath}' is missing.", [ValidationMessageKeys.missingRequired]: "Required property '{displayPath}' is missing.",
@ -244,17 +241,14 @@ const zhCnMessages = {
[ValidationMessageKeys.maximumViolation]: "属性“{displayPath}”必须小于或等于 {value}。", [ValidationMessageKeys.maximumViolation]: "属性“{displayPath}”必须小于或等于 {value}。",
[ValidationMessageKeys.maxItemsViolation]: "属性“{displayPath}”最多只能包含 {value} 个元素。", [ValidationMessageKeys.maxItemsViolation]: "属性“{displayPath}”最多只能包含 {value} 个元素。",
[ValidationMessageKeys.maxLengthViolation]: "属性“{displayPath}”长度必须不超过 {value} 个字符。", [ValidationMessageKeys.maxLengthViolation]: "属性“{displayPath}”长度必须不超过 {value} 个字符。",
[ValidationMessageKeys.maxPropertiesViolation]: "对象属性“{displayPath}”最多只能包含 {value} 个子属性。",
[ValidationMessageKeys.minimumViolation]: "属性“{displayPath}”必须大于或等于 {value}。", [ValidationMessageKeys.minimumViolation]: "属性“{displayPath}”必须大于或等于 {value}。",
[ValidationMessageKeys.multipleOfViolation]: "属性“{displayPath}”必须是 {value} 的整数倍。", [ValidationMessageKeys.multipleOfViolation]: "属性“{displayPath}”必须是 {value} 的整数倍。",
[ValidationMessageKeys.minItemsViolation]: "属性“{displayPath}”至少需要包含 {value} 个元素。", [ValidationMessageKeys.minItemsViolation]: "属性“{displayPath}”至少需要包含 {value} 个元素。",
[ValidationMessageKeys.minLengthViolation]: "属性“{displayPath}”长度必须至少为 {value} 个字符。", [ValidationMessageKeys.minLengthViolation]: "属性“{displayPath}”长度必须至少为 {value} 个字符。",
[ValidationMessageKeys.minPropertiesViolation]: "对象属性“{displayPath}”至少需要包含 {value} 个子属性。",
[ValidationMessageKeys.patternViolation]: "属性“{displayPath}”必须匹配正则模式“{value}”。", [ValidationMessageKeys.patternViolation]: "属性“{displayPath}”必须匹配正则模式“{value}”。",
[ValidationMessageKeys.uniqueItemsViolation]: "属性“{displayPath}”与更早的数组元素“{duplicatePath}”重复;该数组要求元素唯一。", [ValidationMessageKeys.uniqueItemsViolation]: "属性“{displayPath}”与更早的数组元素“{duplicatePath}”重复;该数组要求元素唯一。",
[ValidationMessageKeys.enumMismatch]: "属性“{displayPath}”必须是以下值之一:{values}。", [ValidationMessageKeys.enumMismatch]: "属性“{displayPath}”必须是以下值之一:{values}。",
[ValidationMessageKeys.expectedArray]: "属性“{displayPath}”应为数组。", [ValidationMessageKeys.expectedArray]: "属性“{displayPath}”应为数组。",
[ValidationMessageKeys.expectedObject]: "{subject}",
[ValidationMessageKeys.expectedScalarShape]: "属性“{displayPath}”应为“{schemaType}”,但当前 YAML 结构是“{yamlKind}”。", [ValidationMessageKeys.expectedScalarShape]: "属性“{displayPath}”应为“{schemaType}”,但当前 YAML 结构是“{yamlKind}”。",
[ValidationMessageKeys.expectedScalarValue]: "属性“{displayPath}”应为“{schemaType}”,但当前标量值不兼容。", [ValidationMessageKeys.expectedScalarValue]: "属性“{displayPath}”应为“{schemaType}”,但当前标量值不兼容。",
[ValidationMessageKeys.missingRequired]: "缺少必填属性“{displayPath}”。", [ValidationMessageKeys.missingRequired]: "缺少必填属性“{displayPath}”。",

View File

@ -343,6 +343,34 @@ reward:
assert.ok(messages.some((message) => /reward.*at most 2 properties|reward.*最多只能包含 2 个子属性/u.test(message))); assert.ok(messages.some((message) => /reward.*at most 2 properties|reward.*最多只能包含 2 个子属性/u.test(message)));
}); });
test("validateParsedConfig should count unique object properties for property-count constraints", () => {
const schema = parseSchemaContent(`
{
"type": "object",
"properties": {
"reward": {
"type": "object",
"minProperties": 2,
"properties": {
"gold": { "type": "integer" },
"currency": { "type": "string" }
}
}
}
}
`);
const yaml = parseTopLevelYaml(`
reward:
gold: 10
gold: 20
`);
const diagnostics = validateParsedConfig(schema, yaml);
assert.equal(diagnostics.length, 1);
assert.match(diagnostics[0].message, /reward.*at least 2 properties|reward.*至少需要包含 2 个子属性/u);
});
test("validateParsedConfig should report multipleOf and uniqueItems violations", () => { test("validateParsedConfig should report multipleOf and uniqueItems violations", () => {
const schema = parseSchemaContent(` const schema = parseSchemaContent(`
{ {
@ -735,6 +763,30 @@ id: 1
assert.match(diagnostics[1].message, /未在匹配的 schema 中声明/u); assert.match(diagnostics[1].message, /未在匹配的 schema 中声明/u);
}); });
test("validateParsedConfig should localize expected object diagnostics when Chinese UI is requested", () => {
const schema = parseSchemaContent(`
{
"type": "object",
"properties": {
"reward": {
"type": "object",
"properties": {
"gold": { "type": "integer" }
}
}
}
}
`);
const yaml = parseTopLevelYaml(`
reward: 1
`);
const diagnostics = validateParsedConfig(schema, yaml, {isChinese: true});
assert.equal(diagnostics.length, 1);
assert.equal(diagnostics[0].message, "属性“reward”应为对象。");
});
test("applyFormUpdates should update nested scalar and scalar-array paths", () => { test("applyFormUpdates should update nested scalar and scalar-array paths", () => {
const updated = applyFormUpdates( const updated = applyFormUpdates(
[ [