,
+ * minProperties?: number,
+ * maxProperties?: number,
* title?: string,
* description?: string,
* defaultValue?: string
diff --git a/tools/gframework-config-tool/src/extension.js b/tools/gframework-config-tool/src/extension.js
index c78c8aa4..02519140 100644
--- a/tools/gframework-config-tool/src/extension.js
+++ b/tools/gframework-config-tool/src/extension.js
@@ -1095,6 +1095,7 @@ function renderFormField(field) {
${escapeHtml(field.displayPath || field.path)}
${renderYamlCommentBlock(field)}
${field.description ? `${escapeHtml(field.description)}` : ""}
+ ${field.schema ? renderFieldHint(field.schema, false, false) : ""}
${renderCommentEditor(field)}
`;
@@ -1302,6 +1303,7 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns
path: propertyPath,
label,
description: propertySchema.description,
+ schema: propertySchema,
comment: commentLookup[propertyPath] || "",
required: requiredSet.has(key),
depth
@@ -1468,6 +1470,7 @@ function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPa
displayPath: itemDisplayPath,
label,
description: propertySchema.description,
+ schema: propertySchema,
comment: commentLookup[itemDisplayPath] || "",
required: requiredSet.has(key),
depth
@@ -1574,14 +1577,15 @@ function getScalarArrayValue(yamlNode) {
/**
* Render human-facing metadata hints for one schema field.
*
- * @param {{description?: string, defaultValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string, minItems?: number, maxItems?: number, uniqueItems?: boolean, enumValues?: string[], items?: {enumValues?: string[], minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string}, refTable?: string}} propertySchema Property schema metadata.
+ * @param {{type?: string, description?: string, defaultValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string, minItems?: number, maxItems?: number, minProperties?: number, maxProperties?: number, uniqueItems?: boolean, enumValues?: string[], items?: {enumValues?: string[], minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string}, refTable?: string}} propertySchema Property schema metadata.
* @param {boolean} isArrayField Whether the field is an array.
+ * @param {boolean} includeDescription Whether description text should be included in the hint output.
* @returns {string} HTML fragment.
*/
-function renderFieldHint(propertySchema, isArrayField) {
+function renderFieldHint(propertySchema, isArrayField, includeDescription = true) {
const hints = [];
- if (propertySchema.description) {
+ if (includeDescription && propertySchema.description) {
hints.push(escapeHtml(propertySchema.description));
}
@@ -1630,6 +1634,14 @@ function renderFieldHint(propertySchema, isArrayField) {
hints.push(escapeHtml(localizer.t("webview.hint.pattern", {value: propertySchema.pattern})));
}
+ if (propertySchema.type === "object" && typeof propertySchema.minProperties === "number") {
+ hints.push(escapeHtml(localizer.t("webview.hint.minProperties", {value: propertySchema.minProperties})));
+ }
+
+ if (propertySchema.type === "object" && typeof propertySchema.maxProperties === "number") {
+ hints.push(escapeHtml(localizer.t("webview.hint.maxProperties", {value: propertySchema.maxProperties})));
+ }
+
if (isArrayField && typeof propertySchema.minItems === "number") {
hints.push(escapeHtml(localizer.t("webview.hint.minItems", {value: propertySchema.minItems})));
}
diff --git a/tools/gframework-config-tool/src/localization.js b/tools/gframework-config-tool/src/localization.js
index f58bddf2..23aaf326 100644
--- a/tools/gframework-config-tool/src/localization.js
+++ b/tools/gframework-config-tool/src/localization.js
@@ -124,6 +124,8 @@ const enMessages = {
"webview.hint.itemMinLength": "Item min length: {value}",
"webview.hint.itemMaxLength": "Item max length: {value}",
"webview.hint.itemPattern": "Item pattern: {value}",
+ "webview.hint.minProperties": "Min properties: {value}",
+ "webview.hint.maxProperties": "Max properties: {value}",
"webview.hint.refTable": "Ref table: {refTable}",
"webview.unsupported.array": "Unsupported array shapes are currently raw-YAML-only in the form preview.",
"webview.unsupported.type": "{type} fields are currently raw-YAML-only.",
@@ -134,10 +136,12 @@ 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}.",
@@ -228,6 +232,8 @@ const zhCnMessages = {
"webview.hint.itemMinLength": "元素最小长度:{value}",
"webview.hint.itemMaxLength": "元素最大长度:{value}",
"webview.hint.itemPattern": "元素正则模式:{value}",
+ "webview.hint.minProperties": "最少属性数:{value}",
+ "webview.hint.maxProperties": "最多属性数:{value}",
"webview.hint.refTable": "引用表:{refTable}",
"webview.unsupported.array": "当前表单预览暂不支持这种数组结构,请改用原始 YAML。",
"webview.unsupported.type": "当前表单预览暂不支持 {type} 字段,请改用原始 YAML。",
@@ -238,10 +244,12 @@ 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}。",
diff --git a/tools/gframework-config-tool/src/localizationKeys.js b/tools/gframework-config-tool/src/localizationKeys.js
index 3e315ff9..622743ce 100644
--- a/tools/gframework-config-tool/src/localizationKeys.js
+++ b/tools/gframework-config-tool/src/localizationKeys.js
@@ -9,10 +9,12 @@ const ValidationMessageKeys = Object.freeze({
maximumViolation: "validation.maximumViolation",
maxItemsViolation: "validation.maxItemsViolation",
maxLengthViolation: "validation.maxLengthViolation",
+ maxPropertiesViolation: "validation.maxPropertiesViolation",
minimumViolation: "validation.minimumViolation",
multipleOfViolation: "validation.multipleOfViolation",
minItemsViolation: "validation.minItemsViolation",
minLengthViolation: "validation.minLengthViolation",
+ minPropertiesViolation: "validation.minPropertiesViolation",
missingRequired: "validation.missingRequired",
patternViolation: "validation.patternViolation",
uniqueItemsViolation: "validation.uniqueItemsViolation",
diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js
index 3b793650..e8b1518e 100644
--- a/tools/gframework-config-tool/test/configValidation.test.js
+++ b/tools/gframework-config-tool/test/configValidation.test.js
@@ -308,6 +308,41 @@ tags:
assert.match(diagnostics[1].message, /at most 3 items|最多只能包含 3 个元素/u);
});
+test("validateParsedConfig should report object property-count mismatches", () => {
+ const schema = parseSchemaContent(`
+ {
+ "type": "object",
+ "minProperties": 2,
+ "maxProperties": 3,
+ "properties": {
+ "reward": {
+ "type": "object",
+ "minProperties": 2,
+ "maxProperties": 2,
+ "properties": {
+ "gold": { "type": "integer" },
+ "currency": { "type": "string" },
+ "tier": { "type": "string" }
+ }
+ }
+ }
+ }
+ `);
+ const yaml = parseTopLevelYaml(`
+reward:
+ gold: 10
+ currency: coin
+ tier: epic
+`);
+
+ const diagnostics = validateParsedConfig(schema, yaml);
+ const messages = diagnostics.map((diagnostic) => diagnostic.message);
+
+ assert.equal(diagnostics.length, 2);
+ assert.ok(messages.some((message) => /at least 2 properties|至少需要包含 2 个属性/u.test(message)));
+ assert.ok(messages.some((message) => /reward.*at most 2 properties|reward.*最多只能包含 2 个子属性/u.test(message)));
+});
+
test("validateParsedConfig should report multipleOf and uniqueItems violations", () => {
const schema = parseSchemaContent(`
{
@@ -615,6 +650,31 @@ test("parseSchemaContent should capture multipleOf and uniqueItems metadata", ()
assert.equal(schema.properties.dropRates.items.multipleOf, 0.5);
});
+test("parseSchemaContent should capture object property-count metadata", () => {
+ const schema = parseSchemaContent(`
+ {
+ "type": "object",
+ "minProperties": 2,
+ "maxProperties": 4,
+ "properties": {
+ "reward": {
+ "type": "object",
+ "minProperties": 1,
+ "maxProperties": 2,
+ "properties": {
+ "gold": { "type": "integer" }
+ }
+ }
+ }
+ }
+ `);
+
+ assert.equal(schema.minProperties, 2);
+ assert.equal(schema.maxProperties, 4);
+ assert.equal(schema.properties.reward.minProperties, 1);
+ assert.equal(schema.properties.reward.maxProperties, 2);
+});
+
test("parseSchemaContent should reject invalid pattern declarations instead of dropping them", () => {
assert.throws(
() => parseSchemaContent(`
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 2/4] =?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(
[
From 1a50d7af39801f3a8f6385f3921e884468065810 Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Fri, 10 Apr 2026 10:20:40 +0800
Subject: [PATCH 3/4] =?UTF-8?q?test(config):=20=E6=B7=BB=E5=8A=A0=20YAML?=
=?UTF-8?q?=20=E9=85=8D=E7=BD=AE=E5=8A=A0=E8=BD=BD=E5=99=A8=E5=8D=95?=
=?UTF-8?q?=E5=85=83=E6=B5=8B=E8=AF=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 验证 YAML 文件扫描和注册功能
- 测试配置表注册选项对象支持
- 验证空选项对象异常处理
- 测试配置目录不存在时的错误处理
- 验证部分加载失败时的回滚机制
- 测试非法 YAML 文件的错误处理
- 验证 schema 校验功能包括必填字段检查
- 测试类型不匹配的字段校验
- 验证标量 enum 限制校验
- 测试数值范围约束校验包括最小值最大值
- 验证数值特殊约束如 exclusiveMinimum/exclusiveMaximum
- 测试 multipleOf 约束校验
- 验证大数值和科学计数法处理
- 测试字符串长度和正则模式校验
- 验证数组元素数量和唯一性校验
- 测试未知字段检测
- 验证嵌套对象和对象属性数量校验
---
.../Config/YamlConfigLoaderTests.cs | 54 +++++++++++++++++++
1 file changed, 54 insertions(+)
diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
index 5d78e9b9..40c6ab67 100644
--- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
+++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
@@ -1409,6 +1409,60 @@ public class YamlConfigLoaderTests
});
}
+ ///
+ /// 验证对象字段将 maxProperties 声明为非整数数值时,会在 schema 解析阶段被拒绝。
+ ///
+ [Test]
+ public void LoadAsync_Should_Throw_When_Object_MaxProperties_Constraint_Is_Not_Integer()
+ {
+ CreateConfigFile(
+ "monster/slime.yaml",
+ """
+ id: 1
+ name: Slime
+ reward:
+ gold: 10
+ currency: coin
+ """);
+ CreateSchemaFile(
+ "schemas/monster.schema.json",
+ """
+ {
+ "type": "object",
+ "required": ["id", "name", "reward"],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "reward": {
+ "type": "object",
+ "maxProperties": 1.5,
+ "properties": {
+ "gold": { "type": "integer" },
+ "currency": { "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("maxProperties"));
+ Assert.That(exception.Message, Does.Contain("non-negative integer"));
+ Assert.That(registry.Count, Is.EqualTo(0));
+ });
+ }
+
///
/// 验证对象字段将 minProperties 声明为大于 maxProperties 时,会在 schema 解析阶段被拒绝。
///
From a49c99c528d26e57d622219d882f5b6436530aa1 Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Fri, 10 Apr 2026 10:45:22 +0800
Subject: [PATCH 4/4] =?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=8A=9F=E8=83=BD=E6=A8=A1=E5=9D=97?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 实现配置架构解析器,支持对象、数组和标量类型的递归解析
- 添加 YAML 配置文件解析和注释提取功能
- 实现配置验证诊断系统,支持多种数据类型的校验
- 添加表单更新应用功能,支持标量和数组值的批量编辑
- 实现配置示例生成功能,包含架构描述作为 YAML 注释
- 添加国际化支持,提供中英文验证消息本地化
- 实现精确十进制运算,确保数值约束验证的准确性
- 添加批处理数组值解析和枚举值标准化功能
---
.../src/configValidation.js | 8 +++++++
.../src/localization.js | 4 ++++
.../test/localization.test.js | 23 +++++++++++++++++++
3 files changed, 35 insertions(+)
diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js
index abf64f60..868f513d 100644
--- a/tools/gframework-config-tool/src/configValidation.js
+++ b/tools/gframework-config-tool/src/configValidation.js
@@ -1091,6 +1091,10 @@ function localizeValidationMessage(key, localizer, params) {
}
if (key === ValidationMessageKeys.minPropertiesViolation) {
+ if (localizer && typeof localizer.t === "function" && params.displayPath) {
+ return localizer.t(key, params);
+ }
+
return formatObjectPropertyCountMessage(
params.displayPath,
params.value,
@@ -1099,6 +1103,10 @@ function localizeValidationMessage(key, localizer, params) {
}
if (key === ValidationMessageKeys.maxPropertiesViolation) {
+ if (localizer && typeof localizer.t === "function" && params.displayPath) {
+ return localizer.t(key, params);
+ }
+
return formatObjectPropertyCountMessage(
params.displayPath,
params.value,
diff --git a/tools/gframework-config-tool/src/localization.js b/tools/gframework-config-tool/src/localization.js
index 839177e9..c6e807f4 100644
--- a/tools/gframework-config-tool/src/localization.js
+++ b/tools/gframework-config-tool/src/localization.js
@@ -136,10 +136,12 @@ 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}.",
@@ -241,10 +243,12 @@ 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}。",
diff --git a/tools/gframework-config-tool/test/localization.test.js b/tools/gframework-config-tool/test/localization.test.js
index 503eae15..2fb40ceb 100644
--- a/tools/gframework-config-tool/test/localization.test.js
+++ b/tools/gframework-config-tool/test/localization.test.js
@@ -1,6 +1,7 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {createLocalizer} = require("../src/localization");
+const {ValidationMessageKeys} = require("../src/localizationKeys");
test("createLocalizer should default to English strings", () => {
const localizer = createLocalizer("en");
@@ -34,3 +35,25 @@ test("createLocalizer should fall back to English for Traditional Chinese locale
localizer.t("message.batchEditUpdated", {count: 2, domain: "monster"}),
"Batch updated 2 config file(s) in 'monster'.");
});
+
+test("createLocalizer should expose object property-count validation keys in English", () => {
+ const localizer = createLocalizer("en");
+
+ assert.equal(
+ localizer.t(ValidationMessageKeys.minPropertiesViolation, {displayPath: "reward", value: 2}),
+ "Property 'reward' must contain at least 2 properties.");
+ assert.equal(
+ localizer.t(ValidationMessageKeys.maxPropertiesViolation, {displayPath: "reward", value: 3}),
+ "Property 'reward' must contain at most 3 properties.");
+});
+
+test("createLocalizer should expose object property-count validation keys in Simplified Chinese", () => {
+ const localizer = createLocalizer("zh-cn");
+
+ assert.equal(
+ localizer.t(ValidationMessageKeys.minPropertiesViolation, {displayPath: "reward", value: 2}),
+ "对象属性“reward”至少需要包含 2 个子属性。");
+ assert.equal(
+ localizer.t(ValidationMessageKeys.maxPropertiesViolation, {displayPath: "reward", value: 3}),
+ "对象属性“reward”最多只能包含 3 个子属性。");
+});