diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderDependentRequiredTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderDependentRequiredTests.cs index e9ae7602..d9978c61 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderDependentRequiredTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderDependentRequiredTests.cs @@ -229,6 +229,57 @@ public sealed class YamlConfigLoaderDependentRequiredTests }); } + /// + /// 验证 dependentRequired 的 schema 诊断会保留对象路径原始大小写,避免作者难以定位大小写敏感的坏元数据。 + /// + [Test] + public void LoadAsync_Should_Preserve_Object_Path_Casing_In_DependentRequired_Diagnostics() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + Reward: + ItemId: potion + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "Reward"], + "properties": { + "id": { "type": "integer" }, + "Reward": { + "type": "object", + "properties": { + "ItemId": { "type": "string" }, + "ItemCount": { "type": "integer" } + }, + "dependentRequired": { + "ItemId": [42] + } + } + } + } + """); + + var loader = CreateCaseSensitiveRewardLoader(); + var registry = CreateRegistry(); + + 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("Property 'ItemId' in property 'Reward'")); + Assert.That(exception.Message, Does.Not.Contain("property 'reward'")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + /// /// 验证 dependentRequired 只能引用同一对象内已声明的字段。 /// @@ -317,6 +368,17 @@ public sealed class YamlConfigLoaderDependentRequiredTests static config => config.Id); } + /// + /// 创建使用大小写敏感对象路径的加载器,验证 schema 诊断不会篡改原始字段名。 + /// + /// 已注册 PascalCase 奖励节点的加载器。 + private YamlConfigLoader CreateCaseSensitiveRewardLoader() + { + return new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + } + /// /// 创建新的配置注册表,确保每个用例从干净状态开始。 /// @@ -357,4 +419,36 @@ public sealed class YamlConfigLoaderDependentRequiredTests /// public int ItemCount { get; set; } } + + /// + /// 用于验证大小写敏感字段路径诊断的配置类型。 + /// + private sealed class MonsterPascalCaseRewardConfigStub + { + /// + /// 获取或设置主键。 + /// + public int Id { get; set; } + + /// + /// 获取或设置使用 PascalCase 字段名的奖励对象。 + /// + public PascalCaseRewardConfigStub Reward { get; set; } = new(); + } + + /// + /// 表示使用 PascalCase 字段路径的奖励节点。 + /// + private sealed class PascalCaseRewardConfigStub + { + /// + /// 获取或设置掉落物 ID。 + /// + public string ItemId { get; set; } = string.Empty; + + /// + /// 获取或设置掉落物数量。 + /// + public int ItemCount { get; set; } + } } diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index 51ea0a3c..e523c32e 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -1631,7 +1631,7 @@ internal static class YamlConfigSchemaValidator throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, - $"Property '{dependency.Name}' in {DescribeObjectSchemaTarget(propertyPath).ToLowerInvariant()} of schema file '{schemaPath}' must declare 'dependentRequired' as an array of sibling property names.", + $"Property '{dependency.Name}' in {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'dependentRequired' as an array of sibling property names.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } @@ -1645,7 +1645,7 @@ internal static class YamlConfigSchemaValidator throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, - $"Property '{dependency.Name}' in {DescribeObjectSchemaTarget(propertyPath).ToLowerInvariant()} of schema file '{schemaPath}' must declare 'dependentRequired' entries as strings.", + $"Property '{dependency.Name}' in {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'dependentRequired' entries as strings.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } @@ -1656,7 +1656,7 @@ internal static class YamlConfigSchemaValidator throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, - $"Property '{dependency.Name}' in {DescribeObjectSchemaTarget(propertyPath).ToLowerInvariant()} of schema file '{schemaPath}' cannot declare blank 'dependentRequired' entries.", + $"Property '{dependency.Name}' in {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' cannot declare blank 'dependentRequired' entries.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } @@ -2073,6 +2073,19 @@ internal static class YamlConfigSchemaValidator : $"Property '{propertyPath}'"; } + /// + /// 为插入句中位置的对象级 schema 关键字构造稳定描述。 + /// 这里只调整语法前缀大小写,不改变真实字段路径,避免诊断消息把 schema 作者声明的大小写一起改写。 + /// + /// 对象字段路径。 + /// 可直接拼接到句中介词后的对象主体描述。 + private static string DescribeObjectSchemaTargetInClause(string propertyPath) + { + return string.IsNullOrWhiteSpace(propertyPath) + ? "root object" + : $"property '{propertyPath}'"; + } + /// /// 读取数组去重约束。 /// diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs index e6fa5cc4..7b449fa9 100644 --- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -644,6 +644,47 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator string displayPath, JsonElement element, out Diagnostic? diagnostic) + { + return TryTraverseSchemaRecursively( + filePath, + displayPath, + element, + static (currentFilePath, currentDisplayPath, currentElement, schemaType) => + { + if (string.IsNullOrWhiteSpace(schemaType)) + { + return (true, (Diagnostic?)null); + } + + return TryValidateStringFormatMetadata( + currentFilePath, + currentDisplayPath, + currentElement, + schemaType, + out var currentDiagnostic) + ? (true, (Diagnostic?)null) + : (false, currentDiagnostic); + }, + out diagnostic); + } + + /// + /// 以统一顺序递归遍历 schema 树,并把每个节点交给调用方提供的校验逻辑。 + /// 该遍历覆盖对象属性、not 子 schema、数组 itemscontains, + /// 避免不同关键字验证器在同一棵 schema 树上各自维护一份容易漂移的递归流程。 + /// + /// Schema 文件路径。 + /// 逻辑字段路径。 + /// 当前 schema 节点。 + /// 当前节点的关键字校验回调。 + /// 失败时返回的诊断。 + /// 当前节点树是否通过指定关键字的校验。 + private static bool TryTraverseSchemaRecursively( + string filePath, + string displayPath, + JsonElement element, + Func nodeValidator, + out Diagnostic? diagnostic) { diagnostic = null; if (element.ValueKind != JsonValueKind.Object) @@ -656,11 +697,13 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator typeElement.ValueKind == JsonValueKind.String) { schemaType = typeElement.GetString() ?? string.Empty; - if (!string.IsNullOrWhiteSpace(schemaType) && - !TryValidateStringFormatMetadata(filePath, displayPath, element, schemaType, out diagnostic)) - { - return false; - } + } + + var nodeValidationResult = nodeValidator(filePath, displayPath, element, schemaType); + if (!nodeValidationResult.IsValid) + { + diagnostic = nodeValidationResult.Diagnostic; + return false; } if (string.Equals(schemaType, "object", StringComparison.Ordinal) && @@ -669,10 +712,11 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator { foreach (var property in propertiesElement.EnumerateObject()) { - if (!TryValidateStringFormatMetadataRecursively( + if (!TryTraverseSchemaRecursively( filePath, CombinePath(displayPath, property.Name), property.Value, + nodeValidator, out diagnostic)) { return false; @@ -682,10 +726,11 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator if (element.TryGetProperty("not", out var notElement) && notElement.ValueKind == JsonValueKind.Object && - !TryValidateStringFormatMetadataRecursively( + !TryTraverseSchemaRecursively( filePath, $"{displayPath}[not]", notElement, + nodeValidator, out diagnostic)) { return false; @@ -698,17 +743,23 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator if (element.TryGetProperty("items", out var itemsElement) && itemsElement.ValueKind == JsonValueKind.Object && - !TryValidateStringFormatMetadataRecursively(filePath, $"{displayPath}[]", itemsElement, out diagnostic)) + !TryTraverseSchemaRecursively( + filePath, + $"{displayPath}[]", + itemsElement, + nodeValidator, + out diagnostic)) { return false; } if (element.TryGetProperty("contains", out var containsElement) && containsElement.ValueKind == JsonValueKind.Object && - !TryValidateStringFormatMetadataRecursively( + !TryTraverseSchemaRecursively( filePath, $"{displayPath}[contains]", containsElement, + nodeValidator, out diagnostic)) { return false; @@ -733,76 +784,26 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator JsonElement element, out Diagnostic? diagnostic) { - diagnostic = null; - if (element.ValueKind != JsonValueKind.Object) - { - return true; - } - - var schemaType = string.Empty; - if (element.TryGetProperty("type", out var typeElement) && - typeElement.ValueKind == JsonValueKind.String) - { - schemaType = typeElement.GetString() ?? string.Empty; - if (string.Equals(schemaType, "object", StringComparison.Ordinal) && - !TryValidateDependentRequiredMetadata(filePath, displayPath, element, out diagnostic)) + return TryTraverseSchemaRecursively( + filePath, + displayPath, + element, + static (currentFilePath, currentDisplayPath, currentElement, schemaType) => { - return false; - } - } - - if (string.Equals(schemaType, "object", StringComparison.Ordinal) && - element.TryGetProperty("properties", out var propertiesElement) && - propertiesElement.ValueKind == JsonValueKind.Object) - { - foreach (var property in propertiesElement.EnumerateObject()) - { - if (!TryValidateDependentRequiredMetadataRecursively( - filePath, - CombinePath(displayPath, property.Name), - property.Value, - out diagnostic)) + if (!string.Equals(schemaType, "object", StringComparison.Ordinal)) { - return false; + return (true, (Diagnostic?)null); } - } - } - if (element.TryGetProperty("not", out var notElement) && - notElement.ValueKind == JsonValueKind.Object && - !TryValidateDependentRequiredMetadataRecursively( - filePath, - $"{displayPath}[not]", - notElement, - out diagnostic)) - { - return false; - } - - if (!string.Equals(schemaType, "array", StringComparison.Ordinal)) - { - return true; - } - - if (element.TryGetProperty("items", out var itemsElement) && - itemsElement.ValueKind == JsonValueKind.Object && - !TryValidateDependentRequiredMetadataRecursively(filePath, $"{displayPath}[]", itemsElement, out diagnostic)) - { - return false; - } - - if (element.TryGetProperty("contains", out var containsElement) && - containsElement.ValueKind == JsonValueKind.Object && - !TryValidateDependentRequiredMetadataRecursively( - filePath, - $"{displayPath}[contains]", - containsElement, - out diagnostic)) - { - return false; - } - - return true; + return TryValidateDependentRequiredMetadata( + currentFilePath, + currentDisplayPath, + currentElement, + out var currentDiagnostic) + ? (true, (Diagnostic?)null) + : (false, currentDiagnostic); + }, + out diagnostic); } /// diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index f6d52fca..d7fc30ee 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -1734,12 +1734,12 @@ reward: itemId: potion `); - assert.deepEqual(validateParsedConfig(schema, yaml), [ - { - severity: "error", - message: "Property 'reward.itemCount' is required when sibling property 'reward.itemId' is present." - } - ]); + const diagnostics = validateParsedConfig(schema, yaml); + + assert.equal(diagnostics.length, 1); + assert.equal(diagnostics[0].severity, "error"); + assert.match(diagnostics[0].message, /reward\.itemCount/u); + assert.match(diagnostics[0].message, /reward\.itemId/u); }); test("validateParsedConfig should accept missing dependentRequired targets when the trigger is absent", () => { diff --git a/tools/gframework-config-tool/test/localization.test.js b/tools/gframework-config-tool/test/localization.test.js index b5cd2008..f97606cd 100644 --- a/tools/gframework-config-tool/test/localization.test.js +++ b/tools/gframework-config-tool/test/localization.test.js @@ -86,6 +86,18 @@ test("createLocalizer should expose dependentRequired validation keys", () => { const englishLocalizer = createLocalizer("en"); const chineseLocalizer = createLocalizer("zh-cn"); + assert.equal( + englishLocalizer.t("webview.hint.dependentRequired", { + trigger: "reward.itemId", + dependencies: "reward.itemCount, reward.bonusCount" + }), + "When reward.itemId is set: require reward.itemCount, reward.bonusCount"); + assert.equal( + chineseLocalizer.t("webview.hint.dependentRequired", { + trigger: "reward.itemId", + dependencies: "reward.itemCount, reward.bonusCount" + }), + "当 reward.itemId 出现时:还必须声明 reward.itemCount, reward.bonusCount"); assert.equal( englishLocalizer.t(ValidationMessageKeys.dependentRequiredViolation, { displayPath: "reward.itemCount",