From 6ed4d8da1a7c0af8a9b0bd150c5f4a3b374dd7c1 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Fri, 17 Apr 2026 14:17:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=20schema=20=E8=AF=8A=E6=96=AD=E5=92=8C=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ConfigSchemaDiagnostics 类提供配置 schema 代码生成相关诊断 - 添加 JSON 解析错误、根对象检查、ID字段要求等12种诊断规则 - 实现配置验证逻辑,支持整数、数字、布尔值、邮箱等格式校验 - 添加日期时间、持续时间、URI、UUID 等字符串格式验证 - 实现 YAML 解析和注释提取功能 - 提供配置样本生成和批量编辑更新功能 - 添加模式匹配和格式验证的正则表达式支持 --- .../Config/SchemaConfigGenerator.cs | 224 ++++++++- .../Diagnostics/ConfigSchemaDiagnostics.cs | 11 + .../Config/YamlConfigLoaderAllOfTests.cs | 451 ++++++++++++++++++ .../Config/YamlConfigSchemaValidatorTests.cs | 55 +++ .../Config/YamlConfigSchemaValidator.cs | 185 ++++++- .../Config/SchemaConfigGeneratorTests.cs | 212 ++++++++ .../src/configValidation.js | 84 ++++ tools/gframework-config-tool/src/extension.js | 11 +- .../src/localization.js | 4 + .../src/localizationKeys.js | 1 + .../test/configValidation.test.js | 182 +++++++ .../test/localization.test.js | 28 ++ 12 files changed, 1417 insertions(+), 31 deletions(-) create mode 100644 GFramework.Game.Tests/Config/YamlConfigLoaderAllOfTests.cs diff --git a/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs index 45180c9c..20947d00 100644 --- a/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -9,8 +9,8 @@ namespace GFramework.Game.SourceGenerators.Config; /// 当前共享子集也会把 multipleOfuniqueItems、 /// contains / minContains / maxContains、 /// minPropertiesmaxPropertiesdependentRequired、 -/// dependentSchemas 与稳定字符串 format 子集写入生成代码文档, -/// 让消费者能直接在强类型 API 上看到运行时生效的约束。 +/// dependentSchemasallOf 与稳定字符串 format 子集写入生成代码文档, +/// 让消费者能直接在强类型 API 上看到运行时生效且不改变生成类型形状的约束。 /// [Generator] public sealed class SchemaConfigGenerator : IIncrementalGenerator @@ -160,6 +160,15 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return SchemaParseResult.FromDiagnostic(dependentSchemasDiagnostic!); } + if (!TryValidateAllOfMetadataRecursively( + file.Path, + "", + root, + out var allOfDiagnostic)) + { + return SchemaParseResult.FromDiagnostic(allOfDiagnostic!); + } + var entityName = ToPascalCase(GetSchemaBaseName(file.Path)); var rootObject = ParseObjectSpec( file.Path, @@ -681,8 +690,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator /// /// 以统一顺序递归遍历 schema 树,并把每个节点交给调用方提供的校验逻辑。 - /// 该遍历覆盖对象属性、dependentSchemas / not 子 schema、 - /// 数组 itemscontains, + /// 该遍历覆盖对象属性、dependentSchemas / allOf / not 子 schema、 +/// 数组 itemscontains, /// 避免不同关键字验证器在同一棵 schema 树上各自维护一份容易漂移的递归流程。 /// /// Schema 文件路径。 @@ -759,6 +768,33 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator } } + if (string.Equals(schemaType, "object", StringComparison.Ordinal) && + element.TryGetProperty("allOf", out var allOfElement) && + allOfElement.ValueKind == JsonValueKind.Array) + { + var allOfIndex = 0; + foreach (var allOfSchema in allOfElement.EnumerateArray()) + { + if (allOfSchema.ValueKind != JsonValueKind.Object) + { + allOfIndex++; + continue; + } + + if (!TryTraverseSchemaRecursively( + filePath, + $"{displayPath}[allOf:{allOfIndex}]", + allOfSchema, + nodeValidator, + out diagnostic)) + { + return false; + } + + allOfIndex++; + } + } + if (element.TryGetProperty("not", out var notElement) && notElement.ValueKind == JsonValueKind.Object && !TryTraverseSchemaRecursively( @@ -1122,6 +1158,144 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return true; } + /// + /// 验证当前 schema 节点是否以运行时支持的方式声明了 allOf。 + /// 当前共享子集只接受 object 节点上的 object-typed inline schema 数组, + /// 以便把它们解释成 focused constraint block,而不引入额外的类型合并语义。 + /// + /// Schema 文件路径。 + /// 逻辑字段路径。 + /// 当前 schema 节点。 + /// 当前节点声明的 schema 类型。 + /// 失败时返回的诊断。 + /// 当前节点上的 allOf 声明是否有效。 + private static bool TryValidateAllOfDeclaration( + string filePath, + string displayPath, + JsonElement element, + string? schemaType, + out Diagnostic? diagnostic) + { + diagnostic = null; + if (!element.TryGetProperty("allOf", out _)) + { + return true; + } + + if (!string.Equals(schemaType, "object", StringComparison.Ordinal)) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidAllOfMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + "Only object schemas can declare 'allOf'."); + return false; + } + + return TryValidateAllOfMetadata(filePath, displayPath, element, out diagnostic); + } + + /// + /// 递归验证 schema 树中的对象级 allOf 元数据。 + /// 该遍历会覆盖根节点、not、数组元素、containsdependentSchemas + /// 与嵌套 allOf,确保生成器对组合约束的接受范围与运行时保持一致。 + /// + /// Schema 文件路径。 + /// 逻辑字段路径。 + /// 当前 schema 节点。 + /// 失败时返回的诊断。 + /// 当前节点树的 allOf 元数据是否有效。 + private static bool TryValidateAllOfMetadataRecursively( + string filePath, + string displayPath, + JsonElement element, + out Diagnostic? diagnostic) + { + return TryTraverseSchemaRecursively( + filePath, + displayPath, + element, + static (currentFilePath, currentDisplayPath, currentElement, schemaType) => + { + return TryValidateAllOfDeclaration( + currentFilePath, + currentDisplayPath, + currentElement, + schemaType, + out var currentDiagnostic) + ? (true, (Diagnostic?)null) + : (false, currentDiagnostic); + }, + out diagnostic); + } + + /// + /// 验证单个对象 schema 节点上的 allOf 元数据。 + /// 生成器当前只接受 object-typed inline schema 数组, + /// 避免 XML 文档描述出运行时不会按 focused constraint block 解释的组合形状。 + /// + /// Schema 文件路径。 + /// 逻辑字段路径。 + /// 当前对象 schema 节点。 + /// 失败时返回的诊断。 + /// 当前对象上的 allOf 元数据是否有效。 + private static bool TryValidateAllOfMetadata( + string filePath, + string displayPath, + JsonElement element, + out Diagnostic? diagnostic) + { + diagnostic = null; + if (!element.TryGetProperty("allOf", out var allOfElement)) + { + return true; + } + + if (allOfElement.ValueKind != JsonValueKind.Array) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidAllOfMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + "The 'allOf' value must be an array."); + return false; + } + + var allOfIndex = 0; + foreach (var allOfSchema in allOfElement.EnumerateArray()) + { + if (allOfSchema.ValueKind != JsonValueKind.Object) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidAllOfMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + $"Entry #{allOfIndex + 1} in 'allOf' must be an object-valued schema."); + return false; + } + + if (!allOfSchema.TryGetProperty("type", out var allOfTypeElement) || + allOfTypeElement.ValueKind != JsonValueKind.String || + !string.Equals(allOfTypeElement.GetString(), "object", StringComparison.Ordinal)) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidAllOfMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + $"Entry #{allOfIndex + 1} in 'allOf' must declare an object-typed schema."); + return false; + } + + allOfIndex++; + } + + return true; + } + /// /// 判断给定 format 名称是否属于当前共享支持子集。 /// @@ -3215,7 +3389,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator } /// - /// 将 shared schema 子集中的范围、步进、长度、数组数量 / 去重 / contains 与对象属性数量约束整理成 XML 文档可读字符串。 + /// 将 shared schema 子集中的范围、步进、长度、数组数量 / 去重 / contains、 + /// 对象属性数量 / dependent* / allOf 约束整理成 XML 文档可读字符串。 /// /// Schema 节点。 /// 标量类型。 @@ -3362,6 +3537,12 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator { parts.Add($"dependentSchemas = {dependentSchemasDocumentation}"); } + + var allOfDocumentation = TryBuildAllOfDocumentation(element); + if (allOfDocumentation is not null) + { + parts.Add($"allOf = {allOfDocumentation}"); + } } return parts.Count > 0 ? string.Join(", ", parts) : null; @@ -3443,6 +3624,39 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator : null; } + /// + /// 将对象 allOf 组合约束整理成 XML 文档可读字符串。 + /// + /// 对象 schema 节点。 + /// 格式化后的 allOf 说明。 + private static string? TryBuildAllOfDocumentation(JsonElement element) + { + if (!element.TryGetProperty("allOf", out var allOfElement) || + allOfElement.ValueKind != JsonValueKind.Array) + { + return null; + } + + var parts = new List(); + foreach (var allOfSchema in allOfElement.EnumerateArray()) + { + if (allOfSchema.ValueKind != JsonValueKind.Object) + { + continue; + } + + var summary = TryBuildInlineSchemaSummary(allOfSchema, includeRequiredProperties: true); + if (summary is not null) + { + parts.Add(summary); + } + } + + return parts.Count > 0 + ? $"[ {string.Join("; ", parts)} ]" + : null; + } + /// /// 将数组 contains 子 schema 整理成 XML 文档可读字符串。 /// 输出优先保持紧凑,只展示消费者在强类型 API 上最需要看到的匹配摘要。 diff --git a/GFramework.Game.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs b/GFramework.Game.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs index da58e3d9..c39d275f 100644 --- a/GFramework.Game.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs +++ b/GFramework.Game.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs @@ -129,4 +129,15 @@ public static class ConfigSchemaDiagnostics SourceGeneratorsConfigCategory, DiagnosticSeverity.Error, true); + + /// + /// schema 对象节点的 allOf 元数据无效。 + /// + public static readonly DiagnosticDescriptor InvalidAllOfMetadata = new( + "GF_ConfigSchema_012", + "Config schema uses invalid allOf metadata", + "Property '{1}' in schema file '{0}' uses invalid 'allOf' metadata: {2}", + SourceGeneratorsConfigCategory, + DiagnosticSeverity.Error, + true); } diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderAllOfTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderAllOfTests.cs new file mode 100644 index 00000000..e92ae060 --- /dev/null +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderAllOfTests.cs @@ -0,0 +1,451 @@ +using System.IO; +using GFramework.Game.Abstractions.Config; +using GFramework.Game.Config; + +namespace GFramework.Game.Tests.Config; + +/// +/// 验证 YAML 配置加载器对对象级 allOf 组合约束的运行时行为。 +/// +[TestFixture] +public sealed class YamlConfigLoaderAllOfTests +{ + private const string DefaultRewardPropertiesJson = """ + { + "itemId": { "type": "string" }, + "itemCount": { "type": "integer" }, + "bonus": { "type": "integer" } + } + """; + + private const string DefaultAllOfJson = """ + [ + { + "type": "object", + "required": ["itemCount"], + "properties": { + "itemCount": { "type": "integer" } + } + }, + { + "type": "object", + "properties": { + "itemCount": { + "type": "integer", + "minimum": 2 + } + } + } + ] + """; + + private string? _rootPath; + + /// + /// 为每个用例创建隔离的临时目录,避免不同 allOf 场景互相污染。 + /// + [SetUp] + public void SetUp() + { + _rootPath = Path.Combine(Path.GetTempPath(), "GFramework.ConfigTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_rootPath); + } + + /// + /// 清理当前测试创建的目录,避免本地临时文件堆积。 + /// + [TearDown] + public void TearDown() + { + if (!string.IsNullOrEmpty(_rootPath) && + Directory.Exists(_rootPath)) + { + Directory.Delete(_rootPath, true); + } + } + + /// + /// 验证当前对象未满足任一 allOf 条目时,运行时会拒绝加载。 + /// + [Test] + public void LoadAsync_Should_Throw_When_AllOf_Entry_Is_Not_Satisfied() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + bonus: 1 + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema(DefaultRewardPropertiesJson, DefaultAllOfJson)); + + var loader = CreateMonsterRewardLoader(); + 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.ConstraintViolation)); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("reward")); + Assert.That(exception.Message, Does.Contain("allOf")); + Assert.That(exception.Message, Does.Contain("entry #1")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证对象满足全部 allOf 条目时,可以保留未在 focused block 中重复声明的同级字段。 + /// + [Test] + public async Task LoadAsync_Should_Accept_When_All_AllOf_Entries_Are_Satisfied() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + itemId: potion + itemCount: 3 + bonus: 1 + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema(DefaultRewardPropertiesJson, DefaultAllOfJson)); + + var loader = CreateMonsterRewardLoader(); + var registry = CreateRegistry(); + + await loader.LoadAsync(registry); + + var table = registry.GetTable("monster"); + var reward = table.Get(1).Reward; + Assert.Multiple(() => + { + Assert.That(table.Count, Is.EqualTo(1)); + Assert.That(reward.ItemId, Is.EqualTo("potion")); + Assert.That(reward.ItemCount, Is.EqualTo(3)); + Assert.That(reward.Bonus, Is.EqualTo(1)); + }); + } + + /// + /// 验证非数组 allOf 声明会在 schema 解析阶段被拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_AllOf_Is_Not_An_Array() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + itemCount: 3 + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema( + DefaultRewardPropertiesJson, + """ + { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + } + } + """)); + + var loader = CreateMonsterRewardLoader(); + 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("must declare 'allOf' as an array")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证只有对象字段允许声明 allOf。 + /// + [Test] + public void LoadAsync_Should_Throw_When_NonObject_Schema_Declares_AllOf() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + tag: elite + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "tag"], + "properties": { + "id": { "type": "integer" }, + "tag": { + "type": "string", + "allOf": [ + { + "type": "object", + "properties": {} + } + ] + } + } + } + """); + + ArgumentNullException.ThrowIfNull(_rootPath); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable( + "monster", + "monster", + "schemas/monster.schema.json", + static config => config.Id); + 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("tag")); + Assert.That(exception.Message, Does.Contain("can only declare 'allOf' on object schemas")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证 allOf 条目必须是对象值 schema。 + /// + [Test] + public void LoadAsync_Should_Throw_When_AllOf_Entry_Is_Not_Object_Valued() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + itemCount: 3 + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema( + DefaultRewardPropertiesJson, + """ + [123] + """)); + + var loader = CreateMonsterRewardLoader(); + 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("allOf' entries as object-valued schemas")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证 allOf 条目只接受 object-typed schema。 + /// + [Test] + public void LoadAsync_Should_Throw_When_AllOf_Entry_Is_Not_Object_Typed() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + itemCount: 3 + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema( + DefaultRewardPropertiesJson, + """ + [ + { + "type": "string", + "const": "potion" + } + ] + """)); + + var loader = CreateMonsterRewardLoader(); + 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[allOf[0]]")); + Assert.That(exception.Message, Does.Contain("object-typed schema")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 在测试目录下写入配置文件,并自动创建缺失目录。 + /// + /// 相对根目录的配置文件路径。 + /// 要写入的 YAML 或 schema 内容。 + private void CreateConfigFile(string relativePath, string content) + { + ArgumentNullException.ThrowIfNull(_rootPath); + + var filePath = Path.Combine(_rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar)); + var directoryPath = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(directoryPath)) + { + Directory.CreateDirectory(directoryPath); + } + + File.WriteAllText(filePath, content); + } + + /// + /// 写入测试 schema 文件,复用统一的测试文件创建逻辑。 + /// + /// schema 相对路径。 + /// schema JSON 内容。 + private void CreateSchemaFile(string relativePath, string content) + { + CreateConfigFile(relativePath, content); + } + + private static string BuildMonsterConfigYaml(string rewardYaml) + { + return $$""" + id: 1 + reward: + {{IndentLines(rewardYaml, 2)}} + """; + } + + private static string BuildMonsterSchema( + string rewardPropertiesJson, + string allOfJson) + { + return $$""" + { + "type": "object", + "required": ["id", "reward"], + "properties": { + "id": { "type": "integer" }, + "reward": { + "type": "object", + "properties": {{rewardPropertiesJson}}, + "allOf": {{allOfJson}} + } + } + } + """; + } + + private static string IndentLines(string text, int indentLevel) + { + var indentation = new string(' ', indentLevel); + var lines = text + .Trim() + .Split('\n', StringSplitOptions.None) + .Select(static line => line.TrimEnd('\r')); + + return string.Join( + Environment.NewLine, + lines.Select(line => $"{indentation}{line}")); + } + + /// + /// 创建用于对象 allOf 场景的加载器。 + /// + /// 已注册测试表与 schema 路径的加载器。 + private YamlConfigLoader CreateMonsterRewardLoader() + { + ArgumentNullException.ThrowIfNull(_rootPath); + + return new YamlConfigLoader(_rootPath) + .RegisterTable( + "monster", + "monster", + "schemas/monster.schema.json", + static config => config.Id); + } + + /// + /// 创建新的配置注册表,确保每个用例从干净状态开始。 + /// + /// 空的配置注册表。 + private static ConfigRegistry CreateRegistry() + { + return new ConfigRegistry(); + } + + /// + /// 用于对象 allOf 回归测试的最小配置类型。 + /// + private sealed class MonsterAllOfConfigStub + { + /// + /// 获取或设置主键。 + /// + public int Id { get; set; } + + /// + /// 获取或设置奖励对象。 + /// + public AllOfRewardConfigStub Reward { get; set; } = new(); + } + + /// + /// 表示对象 allOf 回归测试中的奖励节点。 + /// + private sealed class AllOfRewardConfigStub + { + /// + /// 获取或设置掉落物 ID。 + /// + public string ItemId { get; set; } = string.Empty; + + /// + /// 获取或设置掉落物数量。 + /// + public int ItemCount { get; set; } + + /// + /// 获取或设置额外奖励值。 + /// + public int Bonus { get; set; } + } + + /// + /// 用于非对象 allOf 场景回归测试的最小配置类型。 + /// + private sealed class MonsterTagConfigStub + { + /// + /// 获取或设置主键。 + /// + public int Id { get; set; } + + /// + /// 获取或设置标签。 + /// + public string Tag { get; set; } = string.Empty; + } +} diff --git a/GFramework.Game.Tests/Config/YamlConfigSchemaValidatorTests.cs b/GFramework.Game.Tests/Config/YamlConfigSchemaValidatorTests.cs index 26acb434..7a61a7e9 100644 --- a/GFramework.Game.Tests/Config/YamlConfigSchemaValidatorTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigSchemaValidatorTests.cs @@ -126,6 +126,61 @@ public sealed class YamlConfigSchemaValidatorTests }); } + /// + /// 验证 allOf focused block 复用同一条 ref-table 字段时,不会把同一引用重复写入结果。 + /// + [Test] + public void ValidateAndCollectReferences_Should_Not_Duplicate_Reference_Usages_From_AllOf() + { + var schemaPath = CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "itemId": { + "type": "string", + "x-gframework-ref-table": "item" + } + }, + "allOf": [ + { + "type": "object", + "properties": { + "itemId": { + "type": "string", + "x-gframework-ref-table": "item" + } + } + } + ] + } + } + } + """); + var schema = YamlConfigSchemaValidator.Load("monster", schemaPath); + + var references = YamlConfigSchemaValidator.ValidateAndCollectReferences( + "monster", + schema, + "monster/slime.yaml", + """ + reward: + itemId: potion + """); + + Assert.That(references, Has.Count.EqualTo(1)); + Assert.Multiple(() => + { + Assert.That(references[0].DisplayPath, Is.EqualTo("reward.itemId")); + Assert.That(references[0].ReferencedTableName, Is.EqualTo("item")); + Assert.That(references[0].RawValue, Is.EqualTo("potion")); + }); + } + /// /// 在临时目录中创建 schema 文件。 /// diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index cb527344..e6307401 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -11,9 +11,9 @@ namespace GFramework.Game.Config; /// 当前共享子集额外支持 multipleOfuniqueItems、 /// contains / minContains / maxContains、 /// minPropertiesmaxPropertiesdependentRequired、 -/// dependentSchemas +/// dependentSchemasallOf /// 与稳定字符串 format 子集,让数值步进、数组去重、数组匹配计数、 -/// 对象属性数量、对象内字段依赖以及条件对象子 schema 在运行时与生成器 / 工具侧保持一致。 +/// 对象属性数量、对象内字段依赖、条件对象子 schema 与对象组合约束在运行时与生成器 / 工具侧保持一致。 /// internal static class YamlConfigSchemaValidator { @@ -325,6 +325,16 @@ internal static class YamlConfigSchemaValidator var typeName = typeElement.GetString() ?? string.Empty; var referenceTableName = TryGetReferenceTableName(tableName, schemaPath, propertyPath, element); + if (!string.Equals(typeName, "object", StringComparison.Ordinal) && + element.TryGetProperty("allOf", out _)) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' can only declare 'allOf' on object schemas.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } var parsedNode = typeName switch { @@ -825,7 +835,8 @@ internal static class YamlConfigSchemaValidator /// 当前对象已出现的属性集合。 /// 对象 schema 节点。 /// - /// 可选的跨表引用收集器;当 dependentSchemas 命中且匹配成功时,只会回写该条件分支新增的引用。 + /// 可选的跨表引用收集器;当 dependentSchemasallOf 命中且匹配成功时, + /// 只会回写对应内联分支新增的引用。 /// private static void ValidateObjectConstraints( string tableName, @@ -912,47 +923,81 @@ internal static class YamlConfigSchemaValidator } } - if (constraints.DependentSchemas is null || - constraints.DependentSchemas.Count == 0) + if (constraints.DependentSchemas is not null && + constraints.DependentSchemas.Count > 0) + { + foreach (var dependency in constraints.DependentSchemas) + { + if (!seenProperties.Contains(dependency.Key)) + { + continue; + } + + var triggerPath = CombineDisplayPath(displayPath, dependency.Key); + // dependentSchemas acts as an additional conditional constraint block on the + // current object. Keep undeclared sibling fields outside the dependent sub-schema + // from blocking the match so schema authors can express focused follow-up rules. + // The trial matcher merges only new reference usages back into the outer collector, + // so re-checking the same scalar via a conditional sub-schema does not duplicate + // cross-table validation work later in the loader pipeline. + if (TryMatchSchemaNode( + tableName, + yamlPath, + displayPath, + mappingNode, + dependency.Value, + references, + allowUnknownObjectProperties: true)) + { + continue; + } + + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"{subject} in config file '{yamlPath}' must satisfy the 'dependentSchemas' schema triggered by sibling property '{triggerPath}'.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + detail: + $"Dependent schema: when '{triggerPath}' exists, the current object must satisfy the corresponding inline schema."); + } + } + + if (constraints.AllOfSchemas is null || + constraints.AllOfSchemas.Count == 0) { return; } - foreach (var dependency in constraints.DependentSchemas) + for (var index = 0; index < constraints.AllOfSchemas.Count; index++) { - if (!seenProperties.Contains(dependency.Key)) - { - continue; - } - - var triggerPath = CombineDisplayPath(displayPath, dependency.Key); - // dependentSchemas acts as an additional conditional constraint block on the - // current object. Keep undeclared sibling fields outside the dependent sub-schema - // from blocking the match so schema authors can express focused follow-up rules. - // The trial matcher merges only new reference usages back into the outer collector, - // so re-checking the same scalar via a conditional sub-schema does not duplicate - // cross-table validation work later in the loader pipeline. + var allOfSchema = constraints.AllOfSchemas[index]; + // allOf follows the same focused constraint block semantics as dependentSchemas: + // the inline schema may validate a subset of the current object without forcing + // unrelated sibling fields to be restated. if (TryMatchSchemaNode( tableName, yamlPath, displayPath, mappingNode, - dependency.Value, + allOfSchema, references, allowUnknownObjectProperties: true)) { continue; } + var allOfEntryNumber = index + 1; throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.ConstraintViolation, tableName, - $"{subject} in config file '{yamlPath}' must satisfy the 'dependentSchemas' schema triggered by sibling property '{triggerPath}'.", + $"{subject} in config file '{yamlPath}' must satisfy all 'allOf' schemas, but entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} did not match.", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: GetDiagnosticPath(displayPath), detail: - $"Dependent schema: when '{triggerPath}' exists, the current object must satisfy the corresponding inline schema."); + $"allOf entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} must match the current object."); } } @@ -1621,6 +1666,7 @@ internal static class YamlConfigSchemaValidator "maxProperties"); var dependentRequired = ParseDependentRequiredConstraints(tableName, schemaPath, propertyPath, element, properties); var dependentSchemas = ParseDependentSchemasConstraints(tableName, schemaPath, propertyPath, element, properties); + var allOfSchemas = ParseAllOfConstraints(tableName, schemaPath, propertyPath, element); if (minProperties.HasValue && maxProperties.HasValue && minProperties.Value > maxProperties.Value) { @@ -1633,9 +1679,10 @@ internal static class YamlConfigSchemaValidator displayPath: GetDiagnosticPath(propertyPath)); } - return !minProperties.HasValue && !maxProperties.HasValue && dependentRequired is null && dependentSchemas is null + return !minProperties.HasValue && !maxProperties.HasValue && dependentRequired is null && dependentSchemas is null && + allOfSchemas is null ? null - : new YamlConfigObjectConstraints(minProperties, maxProperties, dependentRequired, dependentSchemas); + : new YamlConfigObjectConstraints(minProperties, maxProperties, dependentRequired, dependentSchemas, allOfSchemas); } /// @@ -1827,6 +1874,76 @@ internal static class YamlConfigSchemaValidator : dependentSchemas; } + /// + /// 解析对象节点声明的 allOf 组合约束。 + /// 当前实现仅接受 object-typed 内联 schema,并把每个条目当成 focused constraint block + /// 叠加到当前对象上,而不是参与属性合并或改变生成类型形状。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 对象字段路径。 + /// Schema 节点。 + /// 归一化后的 allOf schema 列表;未声明或为空时返回空。 + private static IReadOnlyList? ParseAllOfConstraints( + string tableName, + string schemaPath, + string propertyPath, + JsonElement element) + { + if (!element.TryGetProperty("allOf", out var allOfElement)) + { + return null; + } + + if (allOfElement.ValueKind != JsonValueKind.Array) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' must declare 'allOf' as an array of object-valued schemas.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + var allOfSchemas = new List(); + var allOfIndex = 0; + foreach (var allOfSchemaElement in allOfElement.EnumerateArray()) + { + if (allOfSchemaElement.ValueKind != JsonValueKind.Object) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' must declare 'allOf' entries as object-valued schemas.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + var allOfSchemaPath = BuildNestedSchemaPath(propertyPath, $"allOf[{allOfIndex.ToString(CultureInfo.InvariantCulture)}]"); + var allOfSchemaNode = ParseNode( + tableName, + schemaPath, + allOfSchemaPath, + allOfSchemaElement); + if (allOfSchemaNode.NodeType != YamlConfigSchemaPropertyType.Object) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Entry #{(allOfIndex + 1).ToString(CultureInfo.InvariantCulture)} in 'allOf' for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare an object-typed schema.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(allOfSchemaPath)); + } + + allOfSchemas.Add(allOfSchemaNode); + allOfIndex++; + } + + return allOfSchemas.Count == 0 + ? null + : allOfSchemas; + } + /// /// 读取数值区间约束。 /// @@ -3624,6 +3741,15 @@ internal static class YamlConfigSchemaValidator CollectReferencedTableNames(dependentSchemaNode, referencedTableNames); } } + + var allOfSchemas = node.ObjectConstraints?.AllOfSchemas; + if (allOfSchemas is not null) + { + foreach (var allOfSchemaNode in allOfSchemas) + { + CollectReferencedTableNames(allOfSchemaNode, referencedTableNames); + } + } } /// @@ -4217,7 +4343,7 @@ internal sealed class YamlConfigAllowedValue } /// -/// 表示一个对象节点上声明的属性数量约束、字段依赖约束与条件子 schema。 +/// 表示一个对象节点上声明的属性数量约束、字段依赖约束、条件子 schema 与组合约束。 /// 该模型将对象级约束与数组 / 标量约束拆开保存,避免运行时节点继续暴露无关成员。 /// internal sealed class YamlConfigObjectConstraints @@ -4229,16 +4355,19 @@ internal sealed class YamlConfigObjectConstraints /// 最大属性数量约束。 /// 对象内字段依赖约束。 /// 对象内条件 schema 约束。 + /// 对象内组合 schema 约束。 public YamlConfigObjectConstraints( int? minProperties, int? maxProperties, IReadOnlyDictionary>? dependentRequired, - IReadOnlyDictionary? dependentSchemas) + IReadOnlyDictionary? dependentSchemas, + IReadOnlyList? allOfSchemas) { MinProperties = minProperties; MaxProperties = maxProperties; DependentRequired = dependentRequired; DependentSchemas = dependentSchemas; + AllOfSchemas = allOfSchemas; } /// @@ -4262,6 +4391,12 @@ internal sealed class YamlConfigObjectConstraints /// 键表示“触发字段”,值表示“触发字段出现后当前对象还必须满足的额外 schema 子树”。 /// public IReadOnlyDictionary? DependentSchemas { get; } + + /// + /// 获取对象内 allOf 组合约束。 + /// 每个条目都表示“当前对象还必须额外满足的 focused constraint block”。 + /// + public IReadOnlyList? AllOfSchemas { get; } } /// diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs index 5229394e..7e3d2d45 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs @@ -690,6 +690,218 @@ public class SchemaConfigGeneratorTests }); } + /// + /// 验证对象 allOf 会写入生成 XML 文档。 + /// + [Test] + public void Run_Should_Write_AllOf_Constraint_Into_Generated_Documentation() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id", "reward"], + "properties": { + "id": { "type": "integer" }, + "reward": { + "type": "object", + "properties": { + "itemId": { "type": "string" }, + "itemCount": { "type": "integer" } + }, + "allOf": [ + { + "type": "object", + "required": ["itemCount"], + "properties": { + "itemCount": { "type": "integer" } + } + } + ] + } + } + } + """; + + 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("Constraints: allOf = [ object (required = [itemCount]) ].")); + } + + /// + /// 验证只有 object 节点允许声明 allOf。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_NonObject_Schema_Declares_AllOf() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id", "tag"], + "properties": { + "id": { "type": "integer" }, + "tag": { + "type": "string", + "allOf": [ + { + "type": "object", + "properties": {} + } + ] + } + } + } + """; + + var result = SchemaGeneratorTestDriver.Run( + source, + ("monster.schema.json", schema)); + + var diagnostic = result.Results.Single().Diagnostics.Single(); + + Assert.Multiple(() => + { + Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_012")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("tag")); + Assert.That(diagnostic.GetMessage(), Does.Contain("Only object schemas can declare 'allOf'.")); + }); + } + + /// + /// 验证生成器会拒绝非数组的 allOf 声明。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_AllOf_Is_Not_An_Array() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id", "reward"], + "properties": { + "id": { "type": "integer" }, + "reward": { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + }, + "allOf": { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + } + } + } + } + } + """; + + var result = SchemaGeneratorTestDriver.Run( + source, + ("monster.schema.json", schema)); + + var diagnostic = result.Results.Single().Diagnostics.Single(); + + Assert.Multiple(() => + { + Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_012")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("reward")); + Assert.That(diagnostic.GetMessage(), Does.Contain("The 'allOf' value must be an array.")); + }); + } + + /// + /// 验证生成器会拒绝非 object-typed 的 allOf 条目。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_AllOf_Entry_Is_Not_Object_Typed() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id", "reward"], + "properties": { + "id": { "type": "integer" }, + "reward": { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + }, + "allOf": [ + { + "type": "string", + "const": "potion" + } + ] + } + } + } + """; + + var result = SchemaGeneratorTestDriver.Run( + source, + ("monster.schema.json", schema)); + + var diagnostic = result.Results.Single().Diagnostics.Single(); + + Assert.Multiple(() => + { + Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_012")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("reward")); + Assert.That(diagnostic.GetMessage(), Does.Contain("Entry #1 in 'allOf' must declare an object-typed schema.")); + }); + } + /// /// 验证深层不支持的数组嵌套会带着完整字段路径产生命名明确的诊断。 /// diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js index fcbe8e9e..92458dad 100644 --- a/tools/gframework-config-tool/src/configValidation.js +++ b/tools/gframework-config-tool/src/configValidation.js @@ -1125,6 +1125,10 @@ function parseSchemaNode(rawNode, displayPath) { : undefined }; + if (value.allOf !== undefined && type !== "object") { + throw new Error(`Only object schemas can declare 'allOf' at '${displayPath}'.`); + } + if (type === "object") { const required = Array.isArray(value.required) ? value.required.filter((item) => typeof item === "string") @@ -1135,6 +1139,7 @@ function parseSchemaNode(rawNode, displayPath) { } const dependentRequired = parseDependentRequiredMetadata(value.dependentRequired, displayPath, properties); const dependentSchemas = parseDependentSchemasMetadata(value.dependentSchemas, displayPath, properties); + const allOf = parseAllOfSchemaNodes(value.allOf, displayPath); return applyEnumMetadata(applyConstMetadata({ type: "object", @@ -1145,6 +1150,7 @@ function parseSchemaNode(rawNode, displayPath) { maxProperties: metadata.maxProperties, dependentRequired, dependentSchemas, + allOf, title: metadata.title, description: metadata.description, defaultValue: metadata.defaultValue, @@ -1374,6 +1380,45 @@ function parseDependentSchemasMetadata(rawDependentSchemas, displayPath, propert : undefined; } +/** + * Parse one object-level `allOf` list and keep it aligned with the runtime's + * focused-constraint-block contract. + * + * @param {unknown} rawAllOf Raw `allOf` node. + * @param {string} displayPath Parent schema path. + * @returns {SchemaNode[] | undefined} Normalized allOf schema list. + */ +function parseAllOfSchemaNodes(rawAllOf, displayPath) { + if (rawAllOf === undefined) { + return undefined; + } + + if (!Array.isArray(rawAllOf)) { + throw new Error(`Schema property '${displayPath}' must declare 'allOf' as an array.`); + } + + const normalized = []; + for (let index = 0; index < rawAllOf.length; index += 1) { + const rawAllOfSchema = rawAllOf[index]; + if (!rawAllOfSchema || typeof rawAllOfSchema !== "object" || Array.isArray(rawAllOfSchema)) { + throw new Error( + `Schema property '${displayPath}' must declare 'allOf' entry #${index + 1} as an object-valued schema.`); + } + + if (rawAllOfSchema.type !== "object") { + throw new Error( + `Schema property '${displayPath}' must declare object-typed schemas in 'allOf' entry #${index + 1}.`); + } + + const allOfSchema = parseSchemaNode(rawAllOfSchema, `${displayPath}[allOf:${index}]`); + normalized.push(allOfSchema); + } + + return normalized.length > 0 + ? normalized + : undefined; +} + /** * Validate one schema node against one YAML node. * @@ -1767,6 +1812,32 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca } } + if (Array.isArray(schemaNode.allOf)) { + for (let index = 0; index < schemaNode.allOf.length; index += 1) { + if (matchesSchemaNode(schemaNode.allOf[index], yamlNode, true)) { + continue; + } + + const localizedMessage = localizeValidationMessage( + ValidationMessageKeys.allOfViolation, + localizer, + { + displayPath: displayPath || "", + index: String(index + 1) + }); + + if (reportedMessages.has(localizedMessage)) { + continue; + } + + diagnostics.push({ + severity: "error", + message: localizedMessage + }); + reportedMessages.add(localizedMessage); + } + } + if (typeof schemaNode.minProperties === "number" && propertyCount < schemaNode.minProperties) { diagnostics.push({ @@ -1900,6 +1971,14 @@ function matchesSchemaNodeInternal(schemaNode, yamlNode, allowUnknownObjectPrope } } + if (Array.isArray(schemaNode.allOf)) { + for (const allOfSchema of schemaNode.allOf) { + if (!matchesSchemaNodeInternal(allOfSchema, yamlNode, true)) { + return false; + } + } + } + if (typeof schemaNode.minProperties === "number" && propertyCount < schemaNode.minProperties) { return false; @@ -2309,6 +2388,8 @@ function localizeValidationMessage(key, localizer, params) { if (localizer && localizer.isChinese) { switch (key) { + case ValidationMessageKeys.allOfViolation: + return `对象“${params.displayPath}”必须满足全部 \`allOf\` schema,第 ${params.index} 项未匹配。`; case ValidationMessageKeys.constMismatch: return `属性“${params.displayPath}”必须匹配固定值 ${params.value}。`; case ValidationMessageKeys.dependentRequiredViolation: @@ -2363,6 +2444,8 @@ function localizeValidationMessage(key, localizer, params) { } switch (key) { + case ValidationMessageKeys.allOfViolation: + return `Object '${params.displayPath}' must satisfy all 'allOf' schemas; entry #${params.index} did not match.`; case ValidationMessageKeys.constMismatch: return `Property '${params.displayPath}' must match constant value ${params.value}.`; case ValidationMessageKeys.dependentRequiredViolation: @@ -3119,6 +3202,7 @@ module.exports = { * maxProperties?: number, * dependentRequired?: Record, * dependentSchemas?: Record, + * allOf?: SchemaNode[], * 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 3c7266d3..51453727 100644 --- a/tools/gframework-config-tool/src/extension.js +++ b/tools/gframework-config-tool/src/extension.js @@ -1622,7 +1622,7 @@ function describeInlineSchemaForHint(schema, includeRequiredProperties = false) /** * Render human-facing metadata hints for one schema field. * - * @param {{type?: string, description?: string, defaultValue?: string, constValue?: string, constDisplayValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string, format?: string, minItems?: number, maxItems?: number, minContains?: number, maxContains?: number, minProperties?: number, maxProperties?: number, required?: string[], dependentRequired?: Record, dependentSchemas?: Record, uniqueItems?: boolean, enumValues?: string[], contains?: {type?: string, enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, format?: string, refTable?: string}, items?: {enumValues?: string[], constValue?: string, constDisplayValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string, format?: string}, refTable?: string}} propertySchema Property schema metadata. + * @param {{type?: string, description?: string, defaultValue?: string, constValue?: string, constDisplayValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string, format?: string, minItems?: number, maxItems?: number, minContains?: number, maxContains?: number, minProperties?: number, maxProperties?: number, required?: string[], dependentRequired?: Record, dependentSchemas?: Record, allOf?: Array<{type?: string, required?: string[], enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, refTable?: string}>, uniqueItems?: boolean, enumValues?: string[], contains?: {type?: string, enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, format?: string, refTable?: string}, items?: {enumValues?: string[], constValue?: string, constDisplayValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string, format?: 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. @@ -1723,6 +1723,15 @@ function renderFieldHint(propertySchema, isArrayField, includeDescription = true } } + if (propertySchema.type === "object" && + Array.isArray(propertySchema.allOf)) { + for (const allOfSchema of propertySchema.allOf) { + hints.push(escapeHtml(localizer.t("webview.hint.allOf", { + schema: describeInlineSchemaForHint(allOfSchema, true) + }))); + } + } + 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 9a60d215..099e41f7 100644 --- a/tools/gframework-config-tool/src/localization.js +++ b/tools/gframework-config-tool/src/localization.js @@ -136,11 +136,13 @@ const enMessages = { "webview.hint.maxProperties": "Max properties: {value}", "webview.hint.dependentRequired": "When {trigger} is set: require {dependencies}", "webview.hint.dependentSchemas": "When {trigger} is set: satisfy {schema}", + "webview.hint.allOf": "Also satisfy: {schema}", "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.", "webview.unsupported.objectArrayMixed": "Object-array items must be mappings. Use raw YAML if the current file mixes scalar and object items.", "webview.unsupported.nestedObjectArray": "Nested object-array fields are currently raw-YAML-only inside the object-array editor.", + [ValidationMessageKeys.allOfViolation]: "Object '{displayPath}' must satisfy all 'allOf' schemas; entry #{index} did not match.", [ValidationMessageKeys.constMismatch]: "Property '{displayPath}' must match constant value {value}.", [ValidationMessageKeys.dependentRequiredViolation]: "Property '{displayPath}' is required when sibling property '{triggerProperty}' is present.", [ValidationMessageKeys.dependentSchemasViolation]: "Object '{displayPath}' must satisfy the dependent schema triggered by sibling property '{triggerProperty}'.", @@ -260,11 +262,13 @@ const zhCnMessages = { "webview.hint.maxProperties": "最多属性数:{value}", "webview.hint.dependentRequired": "当 {trigger} 出现时:还必须声明 {dependencies}", "webview.hint.dependentSchemas": "当 {trigger} 出现时:还必须满足 {schema}", + "webview.hint.allOf": "还必须满足:{schema}", "webview.hint.refTable": "引用表:{refTable}", "webview.unsupported.array": "当前表单预览暂不支持这种数组结构,请改用原始 YAML。", "webview.unsupported.type": "当前表单预览暂不支持 {type} 字段,请改用原始 YAML。", "webview.unsupported.objectArrayMixed": "对象数组中的每一项都必须是映射对象。如果当前文件混用了标量项和对象项,请改用原始 YAML。", "webview.unsupported.nestedObjectArray": "对象数组编辑器内暂不支持更深层的对象数组字段,请改用原始 YAML。", + [ValidationMessageKeys.allOfViolation]: "对象“{displayPath}”必须满足全部 `allOf` schema,第 {index} 项未匹配。", [ValidationMessageKeys.constMismatch]: "属性“{displayPath}”必须匹配固定值 {value}。", [ValidationMessageKeys.dependentRequiredViolation]: "属性“{triggerProperty}”存在时,必须同时声明属性“{displayPath}”。", [ValidationMessageKeys.dependentSchemasViolation]: "对象“{displayPath}”在属性“{triggerProperty}”存在时,必须满足对应的 dependent schema。", diff --git a/tools/gframework-config-tool/src/localizationKeys.js b/tools/gframework-config-tool/src/localizationKeys.js index 8ea4cd99..407baf3a 100644 --- a/tools/gframework-config-tool/src/localizationKeys.js +++ b/tools/gframework-config-tool/src/localizationKeys.js @@ -1,4 +1,5 @@ const ValidationMessageKeys = Object.freeze({ + allOfViolation: "validation.allOfViolation", constMismatch: "validation.constMismatch", dependentSchemasViolation: "validation.dependentSchemasViolation", enumMismatch: "validation.enumMismatch", diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index 091a813a..8b6289ec 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -1792,6 +1792,107 @@ test("parseSchemaContent should reject non-object-typed dependentSchemas sub-sch ); }); +test("parseSchemaContent should capture allOf metadata", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "itemId": { "type": "string" }, + "itemCount": { "type": "integer" } + }, + "allOf": [ + { + "type": "object", + "required": ["itemCount"], + "properties": { + "itemCount": { "type": "integer" } + } + } + ] + } + } + } + `); + + assert.equal(schema.properties.reward.allOf[0].type, "object"); + assert.deepEqual(schema.properties.reward.allOf[0].required, ["itemCount"]); +}); + +test("parseSchemaContent should reject allOf declarations on non-object schemas", () => { + assert.throws( + () => parseSchemaContent(` + { + "type": "object", + "properties": { + "tag": { + "type": "string", + "allOf": [ + { + "type": "object", + "properties": {} + } + ] + } + } + } + `), + /Only object schemas can declare 'allOf'/u + ); +}); + +test("parseSchemaContent should reject non-array allOf declarations", () => { + assert.throws( + () => parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + }, + "allOf": { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + } + } + } + } + } + `), + /must declare 'allOf' as an array/u + ); +}); + +test("parseSchemaContent should reject non-object-typed allOf sub-schemas", () => { + assert.throws( + () => parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + }, + "allOf": [ + { + "type": "string", + "const": "potion" + } + ] + } + } + } + `), + /object-typed schemas in 'allOf' entry #1/u + ); +}); + test("parseSchemaContent should capture not sub-schema metadata", () => { const schema = parseSchemaContent(` { @@ -1942,6 +2043,87 @@ reward: assert.deepEqual(validateParsedConfig(schema, yaml), []); }); +test("validateParsedConfig should report allOf violations", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "itemId": { "type": "string" }, + "itemCount": { "type": "integer" } + }, + "allOf": [ + { + "type": "object", + "required": ["itemCount"], + "properties": { + "itemCount": { "type": "integer" } + } + } + ] + } + } + } + `); + const yaml = parseTopLevelYaml(` +reward: + itemId: potion +`); + + const diagnostics = validateParsedConfig(schema, yaml); + + assert.equal(diagnostics.length, 1); + assert.equal(diagnostics[0].severity, "error"); + assert.match(diagnostics[0].message, /allOf/u); + assert.match(diagnostics[0].message, /#1|第 1 项/u); +}); + +test("validateParsedConfig should accept satisfied allOf constraints", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "itemId": { "type": "string" }, + "itemCount": { "type": "integer" }, + "bonus": { "type": "integer" } + }, + "allOf": [ + { + "type": "object", + "required": ["itemCount"], + "properties": { + "itemCount": { "type": "integer" } + } + }, + { + "type": "object", + "properties": { + "itemCount": { + "type": "integer", + "minimum": 2 + } + } + } + ] + } + } + } + `); + const yaml = parseTopLevelYaml(` +reward: + itemId: potion + itemCount: 3 + bonus: 1 +`); + + assert.deepEqual(validateParsedConfig(schema, yaml), []); +}); + test("parseSchemaContent should reject non-object not declarations", () => { assert.throws( () => parseSchemaContent(` diff --git a/tools/gframework-config-tool/test/localization.test.js b/tools/gframework-config-tool/test/localization.test.js index c3dd07f4..e3969560 100644 --- a/tools/gframework-config-tool/test/localization.test.js +++ b/tools/gframework-config-tool/test/localization.test.js @@ -146,3 +146,31 @@ test("createLocalizer should expose dependentSchemas validation keys", () => { }), "对象“reward”在属性“reward.itemId”存在时,必须满足对应的 dependent schema。"); }); + +test("createLocalizer should expose allOf validation keys", () => { + const englishLocalizer = createLocalizer("en"); + const chineseLocalizer = createLocalizer("zh-cn"); + + assert.equal( + englishLocalizer.t("webview.hint.allOf", { + schema: "object, Required: itemCount" + }), + "Also satisfy: object, Required: itemCount"); + assert.equal( + chineseLocalizer.t("webview.hint.allOf", { + schema: "object, 必填字段:itemCount" + }), + "还必须满足:object, 必填字段:itemCount"); + assert.equal( + englishLocalizer.t(ValidationMessageKeys.allOfViolation, { + displayPath: "reward", + index: "1" + }), + "Object 'reward' must satisfy all 'allOf' schemas; entry #1 did not match."); + assert.equal( + chineseLocalizer.t(ValidationMessageKeys.allOfViolation, { + displayPath: "reward", + index: "1" + }), + "对象“reward”必须满足全部 `allOf` schema,第 1 项未匹配。"); +});