mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-06 16:16:44 +08:00
feat(config): 添加配置验证和YAML解析功能
- 实现了JSON schema解析器和验证器 - 添加了YAML文档解析和注释提取功能 - 创建了配置验证诊断系统支持中英文本地化 - 实现了批量编辑器可编辑字段收集功能 - 添加了配置文件示例生成功能 - 实现了表单更新应用到YAML的功能 - 添加了精确十进制算术运算支持multipleOf约束检查 - 实现了YAML标量值格式化和引用处理 - 创建了完整的配置验证消息本地化系统
This commit is contained in:
parent
06d048f38a
commit
7931b41589
@ -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>
|
||||
/// 验证对象数组中的嵌套字段也会按 schema 递归校验。
|
||||
/// </summary>
|
||||
|
||||
@ -964,10 +964,11 @@ internal static class YamlConfigSchemaValidator
|
||||
|
||||
if (minProperties.HasValue && maxProperties.HasValue && minProperties.Value > maxProperties.Value)
|
||||
{
|
||||
var targetDescription = DescribeObjectSchemaTarget(propertyPath);
|
||||
throw ConfigLoadExceptionFactory.Create(
|
||||
ConfigLoadFailureKind.SchemaUnsupported,
|
||||
tableName,
|
||||
$"Property '{propertyPath}' in schema file '{schemaPath}' declares 'minProperties' greater than 'maxProperties'.",
|
||||
$"{targetDescription} in schema file '{schemaPath}' declares 'minProperties' greater than 'maxProperties'.",
|
||||
schemaPath: schemaPath,
|
||||
displayPath: GetDiagnosticPath(propertyPath));
|
||||
}
|
||||
@ -1232,10 +1233,11 @@ internal static class YamlConfigSchemaValidator
|
||||
!constraintElement.TryGetInt32(out var constraintValue) ||
|
||||
constraintValue < 0)
|
||||
{
|
||||
var targetDescription = DescribeObjectSchemaTarget(propertyPath);
|
||||
throw ConfigLoadExceptionFactory.Create(
|
||||
ConfigLoadFailureKind.SchemaUnsupported,
|
||||
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,
|
||||
displayPath: GetDiagnosticPath(propertyPath));
|
||||
}
|
||||
@ -1243,6 +1245,19 @@ internal static class YamlConfigSchemaValidator
|
||||
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>
|
||||
|
||||
@ -314,6 +314,48 @@ public class SchemaConfigGeneratorTests
|
||||
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>
|
||||
/// 验证 schema 顶层自定义配置目录元数据不能逃逸配置根目录。
|
||||
/// </summary>
|
||||
|
||||
@ -220,7 +220,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
Path.GetFileName(filePath)));
|
||||
}
|
||||
|
||||
var requiredProperties = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var requiredProperties = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (element.TryGetProperty("required", out var requiredElement) &&
|
||||
requiredElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
|
||||
@ -958,24 +958,20 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
|
||||
*/
|
||||
function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) {
|
||||
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({
|
||||
severity: "error",
|
||||
message: localizeValidationMessage(ValidationMessageKeys.expectedObject, localizer, {
|
||||
subject,
|
||||
displayPath
|
||||
})
|
||||
});
|
||||
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) {
|
||||
if (!yamlNode.map.has(requiredProperty)) {
|
||||
@ -1090,6 +1086,10 @@ function buildComparableNodeValue(schemaNode, yamlNode) {
|
||||
* @returns {string} Localized validation message.
|
||||
*/
|
||||
function localizeValidationMessage(key, localizer, params) {
|
||||
if (key === ValidationMessageKeys.expectedObject) {
|
||||
return formatExpectedObjectMessage(params.displayPath, Boolean(localizer && localizer.isChinese));
|
||||
}
|
||||
|
||||
if (key === ValidationMessageKeys.minPropertiesViolation) {
|
||||
return formatObjectPropertyCountMessage(
|
||||
params.displayPath,
|
||||
@ -1130,8 +1130,6 @@ function localizeValidationMessage(key, localizer, params) {
|
||||
return `属性“${params.displayPath}”最多只能包含 ${params.value} 个元素。`;
|
||||
case ValidationMessageKeys.maxLengthViolation:
|
||||
return `属性“${params.displayPath}”长度必须不超过 ${params.value} 个字符。`;
|
||||
case ValidationMessageKeys.maxPropertiesViolation:
|
||||
return formatObjectPropertyCountMessage(params.displayPath, params.value, "max", true);
|
||||
case ValidationMessageKeys.minimumViolation:
|
||||
return `属性“${params.displayPath}”必须大于或等于 ${params.value}。`;
|
||||
case ValidationMessageKeys.multipleOfViolation:
|
||||
@ -1140,14 +1138,10 @@ function localizeValidationMessage(key, localizer, params) {
|
||||
return `属性“${params.displayPath}”至少需要包含 ${params.value} 个元素。`;
|
||||
case ValidationMessageKeys.minLengthViolation:
|
||||
return `属性“${params.displayPath}”长度必须至少为 ${params.value} 个字符。`;
|
||||
case ValidationMessageKeys.minPropertiesViolation:
|
||||
return formatObjectPropertyCountMessage(params.displayPath, params.value, "min", true);
|
||||
case ValidationMessageKeys.patternViolation:
|
||||
return `属性“${params.displayPath}”必须匹配正则模式“${params.value}”。`;
|
||||
case ValidationMessageKeys.uniqueItemsViolation:
|
||||
return `属性“${params.displayPath}”与更早的数组元素 ${params.duplicatePath} 重复;该数组要求元素唯一。`;
|
||||
case ValidationMessageKeys.expectedObject:
|
||||
return params.subject;
|
||||
case ValidationMessageKeys.missingRequired:
|
||||
return `缺少必填属性“${params.displayPath}”。`;
|
||||
case ValidationMessageKeys.unknownProperty:
|
||||
@ -1176,8 +1170,6 @@ function localizeValidationMessage(key, localizer, params) {
|
||||
return `Property '${params.displayPath}' must contain at most ${params.value} items.`;
|
||||
case ValidationMessageKeys.maxLengthViolation:
|
||||
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:
|
||||
return `Property '${params.displayPath}' must be greater than or equal to ${params.value}.`;
|
||||
case ValidationMessageKeys.multipleOfViolation:
|
||||
@ -1186,14 +1178,10 @@ function localizeValidationMessage(key, localizer, params) {
|
||||
return `Property '${params.displayPath}' must contain at least ${params.value} items.`;
|
||||
case ValidationMessageKeys.minLengthViolation:
|
||||
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:
|
||||
return `Property '${params.displayPath}' must match pattern '${params.value}'.`;
|
||||
case ValidationMessageKeys.uniqueItemsViolation:
|
||||
return `Property '${params.displayPath}' duplicates earlier array item '${params.duplicatePath}', but uniqueItems is required.`;
|
||||
case ValidationMessageKeys.expectedObject:
|
||||
return params.subject;
|
||||
case ValidationMessageKeys.missingRequired:
|
||||
return `Required property '${params.displayPath}' is missing.`;
|
||||
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.
|
||||
*
|
||||
|
||||
@ -136,17 +136,14 @@ const enMessages = {
|
||||
[ValidationMessageKeys.maximumViolation]: "Property '{displayPath}' must be less than or equal to {value}.",
|
||||
[ValidationMessageKeys.maxItemsViolation]: "Property '{displayPath}' must contain at most {value} items.",
|
||||
[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.multipleOfViolation]: "Property '{displayPath}' must be a multiple of {value}.",
|
||||
[ValidationMessageKeys.minItemsViolation]: "Property '{displayPath}' must contain at least {value} items.",
|
||||
[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.uniqueItemsViolation]: "Property '{displayPath}' duplicates earlier array item '{duplicatePath}', but uniqueItems is required.",
|
||||
[ValidationMessageKeys.enumMismatch]: "Property '{displayPath}' must be one of: {values}.",
|
||||
[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.expectedScalarValue]: "Property '{displayPath}' is expected to be '{schemaType}', but the current scalar value is incompatible.",
|
||||
[ValidationMessageKeys.missingRequired]: "Required property '{displayPath}' is missing.",
|
||||
@ -244,17 +241,14 @@ const zhCnMessages = {
|
||||
[ValidationMessageKeys.maximumViolation]: "属性“{displayPath}”必须小于或等于 {value}。",
|
||||
[ValidationMessageKeys.maxItemsViolation]: "属性“{displayPath}”最多只能包含 {value} 个元素。",
|
||||
[ValidationMessageKeys.maxLengthViolation]: "属性“{displayPath}”长度必须不超过 {value} 个字符。",
|
||||
[ValidationMessageKeys.maxPropertiesViolation]: "对象属性“{displayPath}”最多只能包含 {value} 个子属性。",
|
||||
[ValidationMessageKeys.minimumViolation]: "属性“{displayPath}”必须大于或等于 {value}。",
|
||||
[ValidationMessageKeys.multipleOfViolation]: "属性“{displayPath}”必须是 {value} 的整数倍。",
|
||||
[ValidationMessageKeys.minItemsViolation]: "属性“{displayPath}”至少需要包含 {value} 个元素。",
|
||||
[ValidationMessageKeys.minLengthViolation]: "属性“{displayPath}”长度必须至少为 {value} 个字符。",
|
||||
[ValidationMessageKeys.minPropertiesViolation]: "对象属性“{displayPath}”至少需要包含 {value} 个子属性。",
|
||||
[ValidationMessageKeys.patternViolation]: "属性“{displayPath}”必须匹配正则模式“{value}”。",
|
||||
[ValidationMessageKeys.uniqueItemsViolation]: "属性“{displayPath}”与更早的数组元素“{duplicatePath}”重复;该数组要求元素唯一。",
|
||||
[ValidationMessageKeys.enumMismatch]: "属性“{displayPath}”必须是以下值之一:{values}。",
|
||||
[ValidationMessageKeys.expectedArray]: "属性“{displayPath}”应为数组。",
|
||||
[ValidationMessageKeys.expectedObject]: "{subject}",
|
||||
[ValidationMessageKeys.expectedScalarShape]: "属性“{displayPath}”应为“{schemaType}”,但当前 YAML 结构是“{yamlKind}”。",
|
||||
[ValidationMessageKeys.expectedScalarValue]: "属性“{displayPath}”应为“{schemaType}”,但当前标量值不兼容。",
|
||||
[ValidationMessageKeys.missingRequired]: "缺少必填属性“{displayPath}”。",
|
||||
|
||||
@ -343,6 +343,34 @@ reward:
|
||||
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", () => {
|
||||
const schema = parseSchemaContent(`
|
||||
{
|
||||
@ -735,6 +763,30 @@ id: 1
|
||||
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", () => {
|
||||
const updated = applyFormUpdates(
|
||||
[
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user