From 7931b41589f592d4e5e5e395add9691a1d41aa37 Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Fri, 10 Apr 2026 10:10:47 +0800
Subject: [PATCH] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0=E9=85=8D?=
=?UTF-8?q?=E7=BD=AE=E9=AA=8C=E8=AF=81=E5=92=8CYAML=E8=A7=A3=E6=9E=90?=
=?UTF-8?q?=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 实现了JSON schema解析器和验证器
- 添加了YAML文档解析和注释提取功能
- 创建了配置验证诊断系统支持中英文本地化
- 实现了批量编辑器可编辑字段收集功能
- 添加了配置文件示例生成功能
- 实现了表单更新应用到YAML的功能
- 添加了精确十进制算术运算支持multipleOf约束检查
- 实现了YAML标量值格式化和引用处理
- 创建了完整的配置验证消息本地化系统
---
.../Config/YamlConfigLoaderTests.cs | 107 ++++++++++++++++++
.../Config/YamlConfigSchemaValidator.cs | 19 +++-
.../Config/SchemaConfigGeneratorTests.cs | 42 +++++++
.../Config/SchemaConfigGenerator.cs | 2 +-
.../src/configValidation.js | 50 ++++----
.../src/localization.js | 6 -
.../test/configValidation.test.js | 52 +++++++++
7 files changed, 248 insertions(+), 30 deletions(-)
diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
index cc088e19..5d78e9b9 100644
--- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
+++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
@@ -1357,6 +1357,113 @@ public class YamlConfigLoaderTests
});
}
+ ///
+ /// 验证对象字段将 minProperties 声明为非法值时,会在 schema 解析阶段被拒绝。
+ ///
+ [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("monster", "monster", "schemas/monster.schema.json",
+ static config => config.Id);
+ var registry = new ConfigRegistry();
+
+ 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("minProperties"));
+ Assert.That(exception.Message, Does.Contain("non-negative integer"));
+ Assert.That(registry.Count, Is.EqualTo(0));
+ });
+ }
+
+ ///
+ /// 验证对象字段将 minProperties 声明为大于 maxProperties 时,会在 schema 解析阶段被拒绝。
+ ///
+ [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("monster", "monster", "schemas/monster.schema.json",
+ static config => config.Id);
+ var registry = new ConfigRegistry();
+
+ 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("minProperties"));
+ Assert.That(exception.Message, Does.Contain("greater than 'maxProperties'"));
+ Assert.That(registry.Count, Is.EqualTo(0));
+ });
+ }
+
///
/// 验证对象数组中的嵌套字段也会按 schema 递归校验。
///
diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs
index 8ed9a213..18886113 100644
--- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs
+++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs
@@ -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;
}
+ ///
+ /// 为对象级 schema 关键字构造稳定的诊断主体。
+ /// 根对象不会再显示为空字符串属性名,避免坏 schema 诊断出现 Property '' 之类的文本。
+ ///
+ /// 对象字段路径。
+ /// 用于错误消息的对象主体描述。
+ private static string DescribeObjectSchemaTarget(string propertyPath)
+ {
+ return string.IsNullOrWhiteSpace(propertyPath)
+ ? "Root object"
+ : $"Property '{propertyPath}'";
+ }
+
///
/// 读取数组去重约束。
///
diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs
index fc645f88..55f54544 100644
--- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs
+++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs
@@ -314,6 +314,48 @@ public class SchemaConfigGeneratorTests
Does.Contain("Throwing here would permanently poison the cached index for this wrapper instance."));
}
+ ///
+ /// 验证生成器对 required 名称保持大小写敏感,避免与运行时 validator 对同一 schema 产生分歧。
+ ///
+ [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; }"));
+ }
+
///
/// 验证 schema 顶层自定义配置目录元数据不能逃逸配置根目录。
///
diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
index 9cf5d672..3f9993b0 100644
--- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
+++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
@@ -220,7 +220,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
Path.GetFileName(filePath)));
}
- var requiredProperties = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var requiredProperties = new HashSet(StringComparer.Ordinal);
if (element.TryGetProperty("required", out var requiredElement) &&
requiredElement.ValueKind == JsonValueKind.Array)
{
diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js
index 9c339f17..abf64f60 100644
--- a/tools/gframework-config-tool/src/configValidation.js
+++ b/tools/gframework-config-tool/src/configValidation.js
@@ -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.
*
diff --git a/tools/gframework-config-tool/src/localization.js b/tools/gframework-config-tool/src/localization.js
index 23aaf326..839177e9 100644
--- a/tools/gframework-config-tool/src/localization.js
+++ b/tools/gframework-config-tool/src/localization.js
@@ -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}”。",
diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js
index e8b1518e..9fe032d0 100644
--- a/tools/gframework-config-tool/test/configValidation.test.js
+++ b/tools/gframework-config-tool/test/configValidation.test.js
@@ -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(
[