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( [