diff --git a/GFramework.Game.SourceGenerators/AnalyzerReleases.Unshipped.md b/GFramework.Game.SourceGenerators/AnalyzerReleases.Unshipped.md index 3a2574f9..4d7f7481 100644 --- a/GFramework.Game.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/GFramework.Game.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -15,3 +15,4 @@ GF_ConfigSchema_008 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics GF_ConfigSchema_009 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics GF_ConfigSchema_010 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics + GF_ConfigSchema_011 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics diff --git a/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs index f712ca0f..45180c9c 100644 --- a/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -8,8 +8,8 @@ namespace GFramework.Game.SourceGenerators.Config; /// 支持嵌套对象、对象数组、标量数组,以及可映射的 default / enum / const / ref-table 元数据。 /// 当前共享子集也会把 multipleOfuniqueItems、 /// contains / minContains / maxContains、 -/// minPropertiesmaxPropertiesdependentRequired -/// 与稳定字符串 format 子集写入生成代码文档, +/// minPropertiesmaxPropertiesdependentRequired、 +/// dependentSchemas 与稳定字符串 format 子集写入生成代码文档, /// 让消费者能直接在强类型 API 上看到运行时生效的约束。 /// [Generator] @@ -151,6 +151,15 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return SchemaParseResult.FromDiagnostic(dependentRequiredDiagnostic!); } + if (!TryValidateDependentSchemasMetadataRecursively( + file.Path, + "", + root, + out var dependentSchemasDiagnostic)) + { + return SchemaParseResult.FromDiagnostic(dependentSchemasDiagnostic!); + } + var entityName = ToPascalCase(GetSchemaBaseName(file.Path)); var rootObject = ParseObjectSpec( file.Path, @@ -672,7 +681,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator /// /// 以统一顺序递归遍历 schema 树,并把每个节点交给调用方提供的校验逻辑。 - /// 该遍历覆盖对象属性、not 子 schema、数组 itemscontains, + /// 该遍历覆盖对象属性、dependentSchemas / not 子 schema、 + /// 数组 itemscontains, /// 避免不同关键字验证器在同一棵 schema 树上各自维护一份容易漂移的递归流程。 /// /// Schema 文件路径。 @@ -726,6 +736,29 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator } } + if (string.Equals(schemaType, "object", StringComparison.Ordinal) && + element.TryGetProperty("dependentSchemas", out var dependentSchemasElement) && + dependentSchemasElement.ValueKind == JsonValueKind.Object) + { + foreach (var dependentSchema in dependentSchemasElement.EnumerateObject()) + { + if (dependentSchema.Value.ValueKind != JsonValueKind.Object) + { + continue; + } + + if (!TryTraverseSchemaRecursively( + filePath, + $"{displayPath}[dependentSchemas:{dependentSchema.Name}]", + dependentSchema.Value, + nodeValidator, + out diagnostic)) + { + return false; + } + } + } + if (element.TryGetProperty("not", out var notElement) && notElement.ValueKind == JsonValueKind.Object && !TryTraverseSchemaRecursively( @@ -772,7 +805,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator /// /// 递归验证 schema 树中的对象级 dependentRequired 元数据。 - /// 该遍历会覆盖根节点、not 子 schema、数组元素与 contains 子 schema, + /// 该遍历会覆盖根节点、dependentSchemas / not 子 schema、 + /// 数组元素与 contains 子 schema, /// 避免生成器在对象字段依赖规则上比运行时和工具侧更宽松。 /// /// Schema 文件路径。 @@ -924,6 +958,170 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return true; } + /// + /// 验证当前 schema 节点是否以运行时支持的方式声明了 dependentSchemas。 + /// 只有 object 节点允许挂载该关键字;一旦关键字出现,就继续复用对象节点的形状校验, + /// 保证发布到 XML 文档和运行时的约束解释范围保持一致。 + /// + /// Schema 文件路径。 + /// 逻辑字段路径。 + /// 当前 schema 节点。 + /// 当前节点声明的 schema 类型。 + /// 失败时返回的诊断。 + /// 当前节点上的 dependentSchemas 声明是否有效。 + private static bool TryValidateDependentSchemasDeclaration( + string filePath, + string displayPath, + JsonElement element, + string? schemaType, + out Diagnostic? diagnostic) + { + diagnostic = null; + if (!element.TryGetProperty("dependentSchemas", out _)) + { + return true; + } + + if (!string.Equals(schemaType, "object", StringComparison.Ordinal)) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidDependentSchemasMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + "Only object schemas can declare 'dependentSchemas'."); + return false; + } + + return TryValidateDependentSchemasMetadata(filePath, displayPath, element, out diagnostic); + } + + /// + /// 递归验证 schema 树中的对象级 dependentSchemas 元数据。 + /// 该遍历会覆盖根节点、not、数组元素、contains 与嵌套 dependentSchemas, + /// 确保生成器对条件对象子 schema 的接受范围不会比运行时更宽松。 + /// + /// Schema 文件路径。 + /// 逻辑字段路径。 + /// 当前 schema 节点。 + /// 失败时返回的诊断。 + /// 当前节点树的 dependentSchemas 元数据是否有效。 + private static bool TryValidateDependentSchemasMetadataRecursively( + string filePath, + string displayPath, + JsonElement element, + out Diagnostic? diagnostic) + { + return TryTraverseSchemaRecursively( + filePath, + displayPath, + element, + static (currentFilePath, currentDisplayPath, currentElement, schemaType) => + { + return TryValidateDependentSchemasDeclaration( + currentFilePath, + currentDisplayPath, + currentElement, + schemaType, + out var currentDiagnostic) + ? (true, (Diagnostic?)null) + : (false, currentDiagnostic); + }, + out diagnostic); + } + + /// + /// 验证单个对象 schema 节点上的 dependentSchemas 元数据。 + /// 生成器当前只接受“已声明 sibling 字段触发 object 子 schema”的形状, + /// 避免 XML 文档描述出运行时无法识别的条件 schema。 + /// + /// Schema 文件路径。 + /// 逻辑字段路径。 + /// 当前对象 schema 节点。 + /// 失败时返回的诊断。 + /// 当前对象上的 dependentSchemas 元数据是否有效。 + private static bool TryValidateDependentSchemasMetadata( + string filePath, + string displayPath, + JsonElement element, + out Diagnostic? diagnostic) + { + diagnostic = null; + if (!element.TryGetProperty("dependentSchemas", out var dependentSchemasElement)) + { + return true; + } + + if (dependentSchemasElement.ValueKind != JsonValueKind.Object) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidDependentSchemasMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + "The 'dependentSchemas' value must be an object."); + return false; + } + + if (!element.TryGetProperty("properties", out var propertiesElement) || + propertiesElement.ValueKind != JsonValueKind.Object) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidDependentSchemasMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + "Object schemas using 'dependentSchemas' must also declare an object-valued 'properties' map."); + return false; + } + + var declaredProperties = new HashSet( + propertiesElement + .EnumerateObject() + .Select(static property => property.Name), + StringComparer.Ordinal); + + foreach (var dependency in dependentSchemasElement.EnumerateObject()) + { + if (!declaredProperties.Contains(dependency.Name)) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidDependentSchemasMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + $"Trigger property '{dependency.Name}' is not declared in the same object schema."); + return false; + } + + if (dependency.Value.ValueKind != JsonValueKind.Object) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidDependentSchemasMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + $"Property '{dependency.Name}' must declare 'dependentSchemas' as an object-valued schema."); + return false; + } + + if (!dependency.Value.TryGetProperty("type", out var dependentSchemaTypeElement) || + dependentSchemaTypeElement.ValueKind != JsonValueKind.String || + !string.Equals(dependentSchemaTypeElement.GetString(), "object", StringComparison.Ordinal)) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidDependentSchemasMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + $"Property '{dependency.Name}' must declare an object-typed 'dependentSchemas' schema."); + return false; + } + } + + return true; + } + /// /// 判断给定 format 名称是否属于当前共享支持子集。 /// @@ -3158,6 +3356,12 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator { parts.Add($"dependentRequired = {dependentRequiredDocumentation}"); } + + var dependentSchemasDocumentation = TryBuildDependentSchemasDocumentation(element); + if (dependentSchemasDocumentation is not null) + { + parts.Add($"dependentSchemas = {dependentSchemasDocumentation}"); + } } return parts.Count > 0 ? string.Join(", ", parts) : null; @@ -3204,6 +3408,41 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator : null; } + /// + /// 将对象 dependentSchemas 关系整理成 XML 文档可读字符串。 + /// + /// 对象 schema 节点。 + /// 格式化后的 dependentSchemas 说明。 + private static string? TryBuildDependentSchemasDocumentation(JsonElement element) + { + if (!element.TryGetProperty("dependentSchemas", out var dependentSchemasElement) || + dependentSchemasElement.ValueKind != JsonValueKind.Object) + { + return null; + } + + var parts = new List(); + foreach (var dependency in dependentSchemasElement.EnumerateObject()) + { + if (dependency.Value.ValueKind != JsonValueKind.Object) + { + continue; + } + + var summary = TryBuildInlineSchemaSummary(dependency.Value, includeRequiredProperties: true); + if (summary is null) + { + continue; + } + + parts.Add($"{dependency.Name} => {summary}"); + } + + return parts.Count > 0 + ? $"{{ {string.Join("; ", parts)} }}" + : null; + } + /// /// 将数组 contains 子 schema 整理成 XML 文档可读字符串。 /// 输出优先保持紧凑,只展示消费者在强类型 API 上最需要看到的匹配摘要。 @@ -3242,8 +3481,13 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator /// 该摘要复用现有 enum / const / 约束文档构造器,避免 contains / not 与主属性文档逐渐漂移。 /// /// 内联子 schema。 + /// + /// 为对象摘要额外输出 required 信息时返回 。 + /// /// 格式化后的摘要字符串。 - private static string? TryBuildInlineSchemaSummary(JsonElement schemaElement) + private static string? TryBuildInlineSchemaSummary( + JsonElement schemaElement, + bool includeRequiredProperties = false) { if (!schemaElement.TryGetProperty("type", out var typeElement) || typeElement.ValueKind != JsonValueKind.String) @@ -3258,6 +3502,16 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator } var details = new List(); + if (includeRequiredProperties && + schemaType == "object") + { + var requiredDocumentation = TryBuildRequiredPropertiesDocumentation(schemaElement); + if (requiredDocumentation is not null) + { + details.Add(requiredDocumentation); + } + } + var enumDocumentation = TryBuildEnumDocumentation(schemaElement, schemaType!); if (enumDocumentation is not null) { @@ -3281,6 +3535,30 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator : $"{schemaType} ({string.Join(", ", details)})"; } + /// + /// 将对象 schema 的 required 字段整理成紧凑说明。 + /// + /// 对象 schema 节点。 + /// 格式化后的 required 说明。 + private static string? TryBuildRequiredPropertiesDocumentation(JsonElement element) + { + if (!element.TryGetProperty("required", out var requiredElement) || + requiredElement.ValueKind != JsonValueKind.Array) + { + return null; + } + + var requiredProperties = requiredElement + .EnumerateArray() + .Where(static item => item.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(item.GetString())) + .Select(static item => item.GetString()!) + .Distinct(StringComparer.Ordinal) + .ToArray(); + return requiredProperties.Length == 0 + ? null + : $"required = [{string.Join(", ", requiredProperties)}]"; + } + /// /// 将 const 值整理成 XML 文档可读字符串。 /// diff --git a/GFramework.Game.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs b/GFramework.Game.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs index 04d1c02e..da58e3d9 100644 --- a/GFramework.Game.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs +++ b/GFramework.Game.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs @@ -118,4 +118,15 @@ public static class ConfigSchemaDiagnostics SourceGeneratorsConfigCategory, DiagnosticSeverity.Error, true); + + /// + /// schema 对象节点的 dependentSchemas 元数据无效。 + /// + public static readonly DiagnosticDescriptor InvalidDependentSchemasMetadata = new( + "GF_ConfigSchema_011", + "Config schema uses invalid dependentSchemas metadata", + "Property '{1}' in schema file '{0}' uses invalid 'dependentSchemas' metadata: {2}", + SourceGeneratorsConfigCategory, + DiagnosticSeverity.Error, + true); } diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderDependentSchemasTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderDependentSchemasTests.cs new file mode 100644 index 00000000..66a1e1a9 --- /dev/null +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderDependentSchemasTests.cs @@ -0,0 +1,411 @@ +using System.IO; +using GFramework.Game.Abstractions.Config; +using GFramework.Game.Config; + +namespace GFramework.Game.Tests.Config; + +/// +/// 验证 YAML 配置加载器对对象级 dependentSchemas 约束的运行时行为。 +/// +[TestFixture] +public sealed class YamlConfigLoaderDependentSchemasTests +{ + private const string DefaultRewardPropertiesJson = """ + { + "itemId": { "type": "string" }, + "itemCount": { "type": "integer" }, + "bonus": { "type": "integer" } + } + """; + + private const string DefaultDependentSchemasJson = """ + { + "itemId": { + "type": "object", + "required": ["itemCount"], + "properties": { + "itemCount": { "type": "integer" } + } + } + } + """; + + private string? _rootPath; + + /// + /// 为每个用例创建隔离的临时目录,避免不同 dependentSchemas 场景互相污染。 + /// + [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); + } + } + + /// + /// 验证触发字段出现但条件 schema 未满足时,运行时会拒绝当前对象。 + /// + [Test] + public void LoadAsync_Should_Throw_When_DependentSchema_Is_Not_Satisfied() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + itemId: potion + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema(DefaultRewardPropertiesJson, DefaultDependentSchemasJson)); + + 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("dependentSchemas")); + Assert.That(exception.Message, Does.Contain("reward.itemId")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证触发字段缺席时,不会误触发 dependentSchemas 检查。 + /// + [Test] + public async Task LoadAsync_Should_Accept_When_DependentSchemas_Trigger_Is_Absent() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + bonus: 2 + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema(DefaultRewardPropertiesJson, DefaultDependentSchemasJson)); + + var loader = CreateMonsterRewardLoader(); + var registry = CreateRegistry(); + + await loader.LoadAsync(registry); + + var table = registry.GetTable("monster"); + Assert.That(table.Count, Is.EqualTo(1)); + } + + /// + /// 验证触发字段出现且条件 schema 满足时,可以保留对象上的额外同级字段并正常通过加载。 + /// + [Test] + public async Task LoadAsync_Should_Accept_When_DependentSchema_Is_Satisfied() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + itemId: potion + itemCount: 3 + bonus: 1 + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema(DefaultRewardPropertiesJson, DefaultDependentSchemasJson)); + + 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)); + }); + } + + /// + /// 验证非对象 dependentSchemas 声明会在 schema 解析阶段被拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_DependentSchemas_Is_Not_An_Object() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + itemId: potion + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema( + """ + { + "itemId": { "type": "string" }, + "itemCount": { "type": "integer" } + } + """, + """ + ["itemId"] + """)); + + 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 'dependentSchemas' as an object")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证 dependentSchemas 的触发字段必须在同级 properties 中显式声明。 + /// + [Test] + public void LoadAsync_Should_Throw_When_DependentSchemas_Trigger_Is_Not_Declared() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + itemId: potion + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema( + """ + { + "itemCount": { "type": "integer" } + } + """, + """ + { + "itemId": { + "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("dependentSchemas' for undeclared property 'itemId'")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证 dependentSchemas 只接受 object-typed 条件子 schema。 + /// + [Test] + public void LoadAsync_Should_Throw_When_DependentSchemas_Schema_Is_Not_Object_Typed() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + itemId: potion + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema( + """ + { + "itemId": { "type": "string" } + } + """, + """ + { + "itemId": { + "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[dependentSchemas:itemId]")); + Assert.That(exception.Message, Does.Contain("object-typed 'dependentSchemas' 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 dependentSchemasJson) + { + return $$""" + { + "type": "object", + "required": ["id", "reward"], + "properties": { + "id": { "type": "integer" }, + "reward": { + "type": "object", + "properties": {{rewardPropertiesJson}}, + "dependentSchemas": {{dependentSchemasJson}} + } + } + } + """; + } + + 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}")); + } + + /// + /// 创建用于对象 dependentSchemas 场景的加载器。 + /// + /// 已注册测试表与 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(); + } + + /// + /// 用于对象 dependentSchemas 回归测试的最小配置类型。 + /// + private sealed class MonsterDependentSchemasConfigStub + { + /// + /// 获取或设置主键。 + /// + public int Id { get; set; } + + /// + /// 获取或设置奖励对象。 + /// + public DependentSchemasRewardConfigStub Reward { get; set; } = new(); + } + + /// + /// 表示对象 dependentSchemas 回归测试中的奖励节点。 + /// + private sealed class DependentSchemasRewardConfigStub + { + /// + /// 获取或设置掉落物 ID。 + /// + public string ItemId { get; set; } = string.Empty; + + /// + /// 获取或设置掉落物数量。 + /// + public int ItemCount { get; set; } + + /// + /// 获取或设置额外奖励值。 + /// + public int Bonus { get; set; } + } +} diff --git a/GFramework.Game.Tests/Config/YamlConfigSchemaValidatorTests.cs b/GFramework.Game.Tests/Config/YamlConfigSchemaValidatorTests.cs index 11b26894..26acb434 100644 --- a/GFramework.Game.Tests/Config/YamlConfigSchemaValidatorTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigSchemaValidatorTests.cs @@ -9,7 +9,7 @@ namespace GFramework.Game.Tests.Config; [TestFixture] public sealed class YamlConfigSchemaValidatorTests { - private string _rootPath = null!; + private string? _rootPath; /// /// 为每个测试准备独立临时目录。 @@ -27,7 +27,8 @@ public sealed class YamlConfigSchemaValidatorTests [TearDown] public void TearDown() { - if (Directory.Exists(_rootPath)) + if (!string.IsNullOrEmpty(_rootPath) && + Directory.Exists(_rootPath)) { Directory.Delete(_rootPath, true); } @@ -70,6 +71,61 @@ public sealed class YamlConfigSchemaValidatorTests Assert.That(schema.ReferencedTableNames, Is.EqualTo(new[] { "ally", "item", "weapon" })); } + /// + /// 验证条件子 schema 复用同一条 ref-table 字段时,不会把同一引用重复写入结果。 + /// + [Test] + public void ValidateAndCollectReferences_Should_Not_Duplicate_Reference_Usages_From_DependentSchemas() + { + var schemaPath = CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "itemId": { + "type": "string", + "x-gframework-ref-table": "item" + } + }, + "dependentSchemas": { + "itemId": { + "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 文件。 /// @@ -80,6 +136,8 @@ public sealed class YamlConfigSchemaValidatorTests string relativePath, string content) { + ArgumentNullException.ThrowIfNull(_rootPath); + var fullPath = Path.Combine(_rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar)); var directoryPath = Path.GetDirectoryName(fullPath); if (!string.IsNullOrWhiteSpace(directoryPath)) diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index e523c32e..cb527344 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -10,9 +10,10 @@ namespace GFramework.Game.Config; /// 并通过递归遍历方式覆盖嵌套对象、对象数组、标量数组与深层 enum / 引用约束。 /// 当前共享子集额外支持 multipleOfuniqueItems、 /// contains / minContains / maxContains、 -/// minPropertiesmaxPropertiesdependentRequired +/// minPropertiesmaxPropertiesdependentRequired、 +/// dependentSchemas /// 与稳定字符串 format 子集,让数值步进、数组去重、数组匹配计数、 -/// 对象属性数量与对象内字段依赖规则在运行时与生成器 / 工具侧保持一致。 +/// 对象属性数量、对象内字段依赖以及条件对象子 schema 在运行时与生成器 / 工具侧保持一致。 /// internal static class YamlConfigSchemaValidator { @@ -607,7 +608,7 @@ internal static class YamlConfigSchemaValidator } /// - /// 为 contains / not 这类内联子 schema 构建稳定的诊断路径。 + /// 为 contains / not / dependentSchemas 这类内联子 schema 构建稳定的诊断路径。 /// /// 当前节点路径。 /// 内联子 schema 后缀。 @@ -797,7 +798,14 @@ internal static class YamlConfigSchemaValidator displayPath: requiredPath); } - ValidateObjectConstraints(tableName, yamlPath, displayPath, seenProperties, schemaNode); + ValidateObjectConstraints( + tableName, + yamlPath, + displayPath, + mappingNode, + seenProperties, + schemaNode, + references); ValidateAllowedValues(tableName, yamlPath, displayPath, mappingNode, schemaNode); ValidateConstantValue(tableName, yamlPath, displayPath, mappingNode, schemaNode); @@ -805,19 +813,28 @@ internal static class YamlConfigSchemaValidator } /// - /// 校验对象节点声明的属性数量约束。 + /// 校验对象节点声明的数量约束与条件对象约束。 + /// 该阶段除了检查 minProperties / maxProperties,还会复用同一份 sibling 集合处理 + /// dependentRequired,并在 dependentSchemas 命中时以 focused constraint block 语义 + /// 对整个 做额外试匹配。 /// /// 所属配置表名称。 /// YAML 文件路径。 /// 对象字段路径;根对象时为空。 + /// 当前 YAML 对象节点;用于让条件子 schema 在完整对象视图上做匹配。 /// 当前对象已出现的属性集合。 /// 对象 schema 节点。 + /// + /// 可选的跨表引用收集器;当 dependentSchemas 命中且匹配成功时,只会回写该条件分支新增的引用。 + /// private static void ValidateObjectConstraints( string tableName, string yamlPath, string displayPath, + YamlMappingNode mappingNode, HashSet seenProperties, - YamlConfigSchemaNode schemaNode) + YamlConfigSchemaNode schemaNode, + ICollection? references) { var constraints = schemaNode.ObjectConstraints; if (constraints is null) @@ -861,15 +878,47 @@ internal static class YamlConfigSchemaValidator $"Maximum property count: {constraints.MaxProperties.Value.ToString(CultureInfo.InvariantCulture)}."); } - if (constraints.DependentRequired is null || - constraints.DependentRequired.Count == 0) + if (constraints.DependentRequired is not null && + constraints.DependentRequired.Count > 0) + { + // Reuse the collected sibling-name set so the main validation path and + // the contains/not matcher both interpret object dependencies identically. + foreach (var dependency in constraints.DependentRequired) + { + if (!seenProperties.Contains(dependency.Key)) + { + continue; + } + + var triggerPath = CombineDisplayPath(displayPath, dependency.Key); + foreach (var dependentProperty in dependency.Value) + { + if (seenProperties.Contains(dependentProperty)) + { + continue; + } + + var requiredPath = CombineDisplayPath(displayPath, dependentProperty); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.MissingRequiredProperty, + tableName, + $"Property '{requiredPath}' in config file '{yamlPath}' is required when sibling property '{triggerPath}' is present.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: requiredPath, + detail: + $"Dependent requirement: when '{triggerPath}' exists, '{requiredPath}' must also be declared."); + } + } + } + + if (constraints.DependentSchemas is null || + constraints.DependentSchemas.Count == 0) { return; } - // Reuse the collected sibling-name set so the main validation path and - // the contains/not matcher both interpret object dependencies identically. - foreach (var dependency in constraints.DependentRequired) + foreach (var dependency in constraints.DependentSchemas) { if (!seenProperties.Contains(dependency.Key)) { @@ -877,24 +926,33 @@ internal static class YamlConfigSchemaValidator } var triggerPath = CombineDisplayPath(displayPath, dependency.Key); - foreach (var dependentProperty in dependency.Value) - { - if (seenProperties.Contains(dependentProperty)) - { - continue; - } - - var requiredPath = CombineDisplayPath(displayPath, dependentProperty); - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.MissingRequiredProperty, + // 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, - $"Property '{requiredPath}' in config file '{yamlPath}' is required when sibling property '{triggerPath}' is present.", - yamlPath: yamlPath, - schemaPath: schemaNode.SchemaPathHint, - displayPath: requiredPath, - detail: - $"Dependent requirement: when '{triggerPath}' exists, '{requiredPath}' must also be declared."); + 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."); } } @@ -1562,6 +1620,7 @@ internal static class YamlConfigSchemaValidator element, "maxProperties"); var dependentRequired = ParseDependentRequiredConstraints(tableName, schemaPath, propertyPath, element, properties); + var dependentSchemas = ParseDependentSchemasConstraints(tableName, schemaPath, propertyPath, element, properties); if (minProperties.HasValue && maxProperties.HasValue && minProperties.Value > maxProperties.Value) { @@ -1574,9 +1633,9 @@ internal static class YamlConfigSchemaValidator displayPath: GetDiagnosticPath(propertyPath)); } - return !minProperties.HasValue && !maxProperties.HasValue && dependentRequired is null + return !minProperties.HasValue && !maxProperties.HasValue && dependentRequired is null && dependentSchemas is null ? null - : new YamlConfigObjectConstraints(minProperties, maxProperties, dependentRequired); + : new YamlConfigObjectConstraints(minProperties, maxProperties, dependentRequired, dependentSchemas); } /// @@ -1688,6 +1747,86 @@ internal static class YamlConfigSchemaValidator : dependentRequired; } + /// + /// 解析对象节点声明的 dependentSchemas 条件 schema。 + /// 当前实现把它作为“当触发字段出现时,当前对象还必须额外满足一段内联 schema”来解释, + /// 因此触发字段仍限制在当前对象已声明的属性内,而具体约束则继续复用现有递归节点解析逻辑。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 对象字段路径。 + /// Schema 节点。 + /// 当前对象已声明的属性集合。 + /// 归一化后的触发字段到条件 schema 的映射;未声明时返回空。 + private static IReadOnlyDictionary? ParseDependentSchemasConstraints( + string tableName, + string schemaPath, + string propertyPath, + JsonElement element, + IReadOnlyDictionary properties) + { + if (!element.TryGetProperty("dependentSchemas", out var dependentSchemasElement)) + { + return null; + } + + if (dependentSchemasElement.ValueKind != JsonValueKind.Object) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' must declare 'dependentSchemas' as an object.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + var dependentSchemas = new Dictionary(StringComparer.Ordinal); + foreach (var dependency in dependentSchemasElement.EnumerateObject()) + { + if (!properties.ContainsKey(dependency.Name)) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' declares 'dependentSchemas' for undeclared property '{dependency.Name}'.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + if (dependency.Value.ValueKind != JsonValueKind.Object) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{dependency.Name}' in {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'dependentSchemas' as an object-valued schema.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + var dependencySchemaPath = BuildNestedSchemaPath(propertyPath, $"dependentSchemas:{dependency.Name}"); + var dependencySchemaNode = ParseNode( + tableName, + schemaPath, + dependencySchemaPath, + dependency.Value); + if (dependencySchemaNode.NodeType != YamlConfigSchemaPropertyType.Object) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{dependency.Name}' in {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare an object-typed 'dependentSchemas' schema.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(dependencySchemaPath)); + } + + dependentSchemas[dependency.Name] = dependencySchemaNode; + } + + return dependentSchemas.Count == 0 + ? null + : dependentSchemas; + } + /// /// 读取数值区间约束。 /// @@ -3007,10 +3146,7 @@ internal static class YamlConfigSchemaValidator if (references is not null && matchedReferences is not null) { - foreach (var referenceUsage in matchedReferences) - { - references.Add(referenceUsage); - } + AddUniqueReferenceUsages(references, matchedReferences); } return true; @@ -3022,6 +3158,50 @@ internal static class YamlConfigSchemaValidator } } + /// + /// 将试匹配分支采集到的引用回写到外层集合,并按结构化标识去重。 + /// + /// 外层引用集合。 + /// 当前成功匹配分支采集到的引用。 + private static void AddUniqueReferenceUsages( + ICollection references, + IEnumerable matchedReferences) + { + foreach (var referenceUsage in matchedReferences) + { + if (!ContainsReferenceUsage(references, referenceUsage)) + { + references.Add(referenceUsage); + } + } + } + + /// + /// 判断外层引用集合中是否已经存在同一条引用使用记录。 + /// + /// 要检查的引用集合。 + /// 当前待合并的引用记录。 + /// 当集合中已存在语义相同的记录时返回 + private static bool ContainsReferenceUsage( + IEnumerable references, + YamlConfigReferenceUsage candidate) + { + foreach (var referenceUsage in references) + { + if (string.Equals(referenceUsage.YamlPath, candidate.YamlPath, StringComparison.Ordinal) && + string.Equals(referenceUsage.SchemaPath, candidate.SchemaPath, StringComparison.Ordinal) && + string.Equals(referenceUsage.PropertyPath, candidate.PropertyPath, StringComparison.Ordinal) && + string.Equals(referenceUsage.RawValue, candidate.RawValue, StringComparison.Ordinal) && + string.Equals(referenceUsage.ReferencedTableName, candidate.ReferencedTableName, StringComparison.Ordinal) && + referenceUsage.ValueType == candidate.ValueType) + { + return true; + } + } + + return false; + } + /// /// 校验节点是否命中了 not 声明的禁用 schema。 /// 与 contains 不同,not 会沿用主校验链的严格对象语义,避免把“声明属性子集”误当成完整命中。 @@ -3435,6 +3615,15 @@ internal static class YamlConfigSchemaValidator { CollectReferencedTableNames(containsNode, referencedTableNames); } + + var dependentSchemas = node.ObjectConstraints?.DependentSchemas; + if (dependentSchemas is not null) + { + foreach (var dependentSchemaNode in dependentSchemas.Values) + { + CollectReferencedTableNames(dependentSchemaNode, referencedTableNames); + } + } } /// @@ -4028,7 +4217,7 @@ internal sealed class YamlConfigAllowedValue } /// -/// 表示一个对象节点上声明的属性数量约束与字段依赖约束。 +/// 表示一个对象节点上声明的属性数量约束、字段依赖约束与条件子 schema。 /// 该模型将对象级约束与数组 / 标量约束拆开保存,避免运行时节点继续暴露无关成员。 /// internal sealed class YamlConfigObjectConstraints @@ -4039,14 +4228,17 @@ internal sealed class YamlConfigObjectConstraints /// 最小属性数量约束。 /// 最大属性数量约束。 /// 对象内字段依赖约束。 + /// 对象内条件 schema 约束。 public YamlConfigObjectConstraints( int? minProperties, int? maxProperties, - IReadOnlyDictionary>? dependentRequired) + IReadOnlyDictionary>? dependentRequired, + IReadOnlyDictionary? dependentSchemas) { MinProperties = minProperties; MaxProperties = maxProperties; DependentRequired = dependentRequired; + DependentSchemas = dependentSchemas; } /// @@ -4064,6 +4256,12 @@ internal sealed class YamlConfigObjectConstraints /// 键表示“触发字段”,值表示“触发字段出现后还必须存在的同级字段集合”。 /// public IReadOnlyDictionary>? DependentRequired { get; } + + /// + /// 获取对象内条件 schema 约束。 + /// 键表示“触发字段”,值表示“触发字段出现后当前对象还必须满足的额外 schema 子树”。 + /// + public IReadOnlyDictionary? DependentSchemas { get; } } /// diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs index c6e74afc..5229394e 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs @@ -473,6 +473,223 @@ public class SchemaConfigGeneratorTests }); } + /// + /// 验证对象 dependentSchemas 会写入生成 XML 文档。 + /// + [Test] + public void Run_Should_Write_DependentSchemas_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" } + }, + "dependentSchemas": { + "itemId": { + "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: dependentSchemas = { itemId => object (required = [itemCount]) }.")); + } + + /// + /// 验证生成器会拒绝非 object-typed 的 dependentSchemas 子 schema。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_DependentSchemas_Schema_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": { + "itemId": { "type": "string" } + }, + "dependentSchemas": { + "itemId": { + "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_011")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("reward")); + Assert.That(diagnostic.GetMessage(), Does.Contain("object-typed 'dependentSchemas' schema")); + }); + } + + /// + /// 验证只有 object 节点允许声明 dependentSchemas。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_NonObject_Schema_Declares_DependentSchemas() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id", "tag"], + "properties": { + "id": { "type": "integer" }, + "tag": { + "type": "string", + "dependentSchemas": { + "itemId": { + "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_011")); + 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 'dependentSchemas'.")); + }); + } + + /// + /// 验证 dependentSchemas 子 schema 内的非法 format 也会在生成阶段直接给出诊断。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_DependentSchemas_Schema_Uses_Format_On_Non_String_Node() + { + 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" } + }, + "dependentSchemas": { + "itemId": { + "type": "object", + "properties": { + "bonus": { + "type": "integer", + "format": "uuid" + } + } + } + } + } + } + } + """; + + 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_009")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("reward[dependentSchemas:itemId].bonus")); + Assert.That(diagnostic.GetMessage(), Does.Contain("Only 'string' properties can declare 'format'.")); + }); + } + /// /// 验证深层不支持的数组嵌套会带着完整字段路径产生命名明确的诊断。 /// diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index c00f2a2c..0cd514a2 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -12,7 +12,7 @@ - JSON Schema 作为结构描述 - 一对象一文件的目录组织 - 运行时只读查询 -- Runtime / Generator / Tooling 共享支持 `enum`、`const`、`not`、`minimum`、`maximum`、`exclusiveMinimum`、`exclusiveMaximum`、`multipleOf`、`minLength`、`maxLength`、`pattern`、`format`(当前稳定子集:`date`、`date-time`、`duration`、`email`、`time`、`uri`、`uuid`)、`minItems`、`maxItems`、`uniqueItems`、`contains`、`minContains`、`maxContains`、`minProperties`、`maxProperties`、`dependentRequired` +- Runtime / Generator / Tooling 共享支持 `enum`、`const`、`not`、`minimum`、`maximum`、`exclusiveMinimum`、`exclusiveMaximum`、`multipleOf`、`minLength`、`maxLength`、`pattern`、`format`(当前稳定子集:`date`、`date-time`、`duration`、`email`、`time`、`uri`、`uuid`)、`minItems`、`maxItems`、`uniqueItems`、`contains`、`minContains`、`maxContains`、`minProperties`、`maxProperties`、`dependentRequired`、`dependentSchemas` - Source Generator 生成配置类型、表包装、单表注册/访问辅助,以及项目级聚合注册目录 - VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口 @@ -725,6 +725,7 @@ var loader = new YamlConfigLoader("config-root") - 数组字段违反 `contains` / `minContains` / `maxContains` - 对象字段违反 `minProperties` / `maxProperties` - 对象字段违反 `dependentRequired` +- 对象字段违反 `dependentSchemas` - 标量 / 对象 / 数组字段违反 `const` - 标量 / 对象 / 数组字段命中 `not` - 标量 / 对象 / 数组字段违反 `enum` @@ -790,6 +791,7 @@ if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var re - `contains` / `minContains` / `maxContains`:供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;当前会按同一套递归 schema 规则统计“有多少数组元素匹配 contains 子 schema”,其中仅声明 `contains` 时默认至少需要 1 个匹配元素 - `minProperties` / `maxProperties`:供运行时校验、VS Code 校验、对象 section 表单 hint 和生成代码 XML 文档复用;根对象与嵌套对象都会按实际属性数量执行同一套约束 - `dependentRequired`:供运行时校验、VS Code 校验、对象 section 表单 hint 和生成代码 XML 文档复用;当前只表达“当对象内某个字段出现时,还必须同时声明哪些同级字段”,不会改变生成类型形状 +- `dependentSchemas`:供运行时校验、VS Code 校验、对象 section 表单 hint 和生成代码 XML 文档复用;当前只接受“已声明 sibling 字段触发 object 子 schema”的形状,不改变生成类型形状,并按 focused constraint block 语义允许条件子 schema 未声明的额外同级字段继续存在 这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。 @@ -886,7 +888,7 @@ var hotReload = loader.EnableHotReload( - 对带 `x-gframework-ref-table` 的字段提供引用 schema / 配置域 / 引用文件跳转入口 - 对空配置文件提供基于 schema 的示例 YAML 初始化入口 - 对同一配置域内的多份 YAML 文件执行批量字段更新 -- 在表单入口中显示 `title / description / default / const / enum / x-gframework-ref-table(UI 中显示为 ref-table) / multipleOf / pattern / format / uniqueItems / contains / minContains / maxContains / minProperties / maxProperties / dependentRequired` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息 +- 在表单入口中显示 `title / description / default / const / enum / x-gframework-ref-table(UI 中显示为 ref-table) / multipleOf / pattern / format / uniqueItems / contains / minContains / maxContains / minProperties / maxProperties / dependentRequired / dependentSchemas` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息 当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。 diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js index 19f33b91..fcbe8e9e 100644 --- a/tools/gframework-config-tool/src/configValidation.js +++ b/tools/gframework-config-tool/src/configValidation.js @@ -1134,6 +1134,7 @@ function parseSchemaNode(rawNode, displayPath) { properties[key] = parseSchemaNode(propertyNode, joinPropertyPath(displayPath, key)); } const dependentRequired = parseDependentRequiredMetadata(value.dependentRequired, displayPath, properties); + const dependentSchemas = parseDependentSchemasMetadata(value.dependentSchemas, displayPath, properties); return applyEnumMetadata(applyConstMetadata({ type: "object", @@ -1143,6 +1144,7 @@ function parseSchemaNode(rawNode, displayPath) { minProperties: metadata.minProperties, maxProperties: metadata.maxProperties, dependentRequired, + dependentSchemas, title: metadata.title, description: metadata.description, defaultValue: metadata.defaultValue, @@ -1322,6 +1324,56 @@ function parseDependentRequiredMetadata(rawDependentRequired, displayPath, prope : undefined; } +/** + * Parse one object-level `dependentSchemas` map and keep it aligned with the + * runtime's "declared siblings trigger object-typed inline schemas" contract. + * + * @param {unknown} rawDependentSchemas Raw dependentSchemas node. + * @param {string} displayPath Parent schema path. + * @param {Record} properties Declared object properties. + * @returns {Record | undefined} Normalized dependency schema map. + */ +function parseDependentSchemasMetadata(rawDependentSchemas, displayPath, properties) { + if (rawDependentSchemas === undefined) { + return undefined; + } + + if (!rawDependentSchemas || + typeof rawDependentSchemas !== "object" || + Array.isArray(rawDependentSchemas)) { + throw new Error(`Schema property '${displayPath}' must declare 'dependentSchemas' as an object.`); + } + + const normalized = {}; + for (const [triggerProperty, rawDependencySchema] of Object.entries(rawDependentSchemas)) { + if (!Object.prototype.hasOwnProperty.call(properties, triggerProperty)) { + throw new Error( + `Schema property '${displayPath}' declares 'dependentSchemas' for undeclared property '${triggerProperty}'.`); + } + + if (!rawDependencySchema || + typeof rawDependencySchema !== "object" || + Array.isArray(rawDependencySchema)) { + throw new Error( + `Schema property '${displayPath}' must declare 'dependentSchemas' for '${triggerProperty}' as an object-valued schema.`); + } + + const dependencySchema = parseSchemaNode( + rawDependencySchema, + `${displayPath}[dependentSchemas:${triggerProperty}]`); + if (dependencySchema.type !== "object") { + throw new Error( + `Schema property '${displayPath}' must declare an object-typed 'dependentSchemas' schema for '${triggerProperty}'.`); + } + + normalized[triggerProperty] = dependencySchema; + } + + return Object.keys(normalized).length > 0 + ? normalized + : undefined; +} + /** * Validate one schema node against one YAML node. * @@ -1689,6 +1741,32 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca } } + if (schemaNode.dependentSchemas && typeof schemaNode.dependentSchemas === "object") { + for (const [triggerProperty, dependentSchema] of getTriggeredDependentSchemas(schemaNode, yamlNode)) { + if (matchesSchemaNode(dependentSchema, yamlNode, true)) { + continue; + } + + const localizedMessage = localizeValidationMessage( + ValidationMessageKeys.dependentSchemasViolation, + localizer, + { + displayPath: displayPath || "", + triggerProperty: joinPropertyPath(displayPath, triggerProperty) + }); + + if (reportedMessages.has(localizedMessage)) { + continue; + } + + diagnostics.push({ + severity: "error", + message: localizedMessage + }); + reportedMessages.add(localizedMessage); + } + } + if (typeof schemaNode.minProperties === "number" && propertyCount < schemaNode.minProperties) { diagnostics.push({ @@ -1716,6 +1794,32 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca validateNotSchemaMatch(schemaNode, yamlNode, displayPath, diagnostics, localizer); } +/** + * Enumerate object-level `dependentSchemas` entries whose trigger property is + * present on the current YAML object. + * + * @param {SchemaNode} schemaNode Schema node. + * @param {YamlNode} yamlNode YAML node. + * @returns {Array<[string, SchemaNode]>} Triggered dependent schema entries. + */ +function getTriggeredDependentSchemas(schemaNode, yamlNode) { + if (!schemaNode.dependentSchemas || + typeof schemaNode.dependentSchemas !== "object" || + !yamlNode || + yamlNode.kind !== "object") { + return []; + } + + const triggeredSchemas = []; + for (const [triggerProperty, dependentSchema] of Object.entries(schemaNode.dependentSchemas)) { + if (yamlNode.map.has(triggerProperty)) { + triggeredSchemas.push([triggerProperty, dependentSchema]); + } + } + + return triggeredSchemas; +} + /** * Test whether one YAML node satisfies one schema node without emitting user-facing diagnostics. * This is used by array `contains`, where object sub-schemas must behave like @@ -1790,6 +1894,12 @@ function matchesSchemaNodeInternal(schemaNode, yamlNode, allowUnknownObjectPrope } } + for (const [, dependentSchema] of getTriggeredDependentSchemas(schemaNode, yamlNode)) { + if (!matchesSchemaNodeInternal(dependentSchema, yamlNode, true)) { + return false; + } + } + if (typeof schemaNode.minProperties === "number" && propertyCount < schemaNode.minProperties) { return false; @@ -2203,6 +2313,8 @@ function localizeValidationMessage(key, localizer, params) { return `属性“${params.displayPath}”必须匹配固定值 ${params.value}。`; case ValidationMessageKeys.dependentRequiredViolation: return `属性“${params.triggerProperty}”存在时,必须同时声明属性“${params.displayPath}”。`; + case ValidationMessageKeys.dependentSchemasViolation: + return `对象“${params.displayPath}”在属性“${params.triggerProperty}”存在时,必须满足对应的 dependent schema。`; case ValidationMessageKeys.expectedArray: return `属性“${params.displayPath}”应为数组。`; case ValidationMessageKeys.expectedScalarShape: @@ -2255,6 +2367,8 @@ function localizeValidationMessage(key, localizer, params) { return `Property '${params.displayPath}' must match constant value ${params.value}.`; case ValidationMessageKeys.dependentRequiredViolation: return `Property '${params.displayPath}' is required when sibling property '${params.triggerProperty}' is present.`; + case ValidationMessageKeys.dependentSchemasViolation: + return `Object '${params.displayPath}' must satisfy the dependent schema triggered by sibling property '${params.triggerProperty}'.`; case ValidationMessageKeys.expectedArray: return `Property '${params.displayPath}' is expected to be an array.`; case ValidationMessageKeys.expectedScalarShape: @@ -3004,6 +3118,7 @@ module.exports = { * minProperties?: number, * maxProperties?: number, * dependentRequired?: Record, + * dependentSchemas?: Record, * 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 6f7ae9df..3c7266d3 100644 --- a/tools/gframework-config-tool/src/extension.js +++ b/tools/gframework-config-tool/src/extension.js @@ -1575,10 +1575,54 @@ function getScalarArrayValue(yamlNode) { .map((item) => unquoteScalar(item.value || "")); } +/** + * Render one compact inline-schema summary for form hints. + * + * @param {{type?: string, required?: string[], enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, refTable?: string}} schema Parsed inline schema metadata. + * @param {boolean} includeRequiredProperties Whether object `required` members should be surfaced. + * @returns {string} Localized summary. + */ +function describeInlineSchemaForHint(schema, includeRequiredProperties = false) { + const parts = []; + if (schema.type) { + parts.push(schema.type); + } + + if (includeRequiredProperties && + Array.isArray(schema.required) && + schema.required.length > 0) { + parts.push(localizer.t("webview.hint.required", { + properties: schema.required.join(", ") + })); + } + + if (schema.constValue !== undefined) { + parts.push(localizer.t("webview.hint.const", { + value: schema.constDisplayValue ?? schema.constValue + })); + } else if (Array.isArray(schema.enumValues) && schema.enumValues.length > 0) { + parts.push(localizer.t("webview.hint.allowed", { + values: schema.enumValues.join(", ") + })); + } else if (schema.pattern) { + parts.push(localizer.t("webview.hint.pattern", { + value: schema.pattern + })); + } + + if (schema.refTable) { + parts.push(localizer.t("webview.hint.refTable", { + refTable: schema.refTable + })); + } + + return parts.join(", ") || localizer.t("webview.objectArray.item"); +} + /** * 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, dependentRequired?: 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, 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. @@ -1668,6 +1712,17 @@ function renderFieldHint(propertySchema, isArrayField, includeDescription = true } } + if (propertySchema.type === "object" && + propertySchema.dependentSchemas && + typeof propertySchema.dependentSchemas === "object") { + for (const [trigger, dependentSchema] of Object.entries(propertySchema.dependentSchemas)) { + hints.push(escapeHtml(localizer.t("webview.hint.dependentSchemas", { + trigger, + schema: describeInlineSchemaForHint(dependentSchema, 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 a371f303..9a60d215 100644 --- a/tools/gframework-config-tool/src/localization.js +++ b/tools/gframework-config-tool/src/localization.js @@ -121,6 +121,7 @@ const enMessages = { "webview.hint.minContains": "Min contains: {value}", "webview.hint.maxContains": "Max contains: {value}", "webview.hint.uniqueItems": "Items must be unique", + "webview.hint.required": "Required: {properties}", "webview.hint.itemMinimum": "Item minimum: {value}", "webview.hint.itemConst": "Item const: {value}", "webview.hint.itemExclusiveMinimum": "Item exclusive minimum: {value}", @@ -134,6 +135,7 @@ const enMessages = { "webview.hint.minProperties": "Min properties: {value}", "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.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.", @@ -141,6 +143,7 @@ const enMessages = { "webview.unsupported.nestedObjectArray": "Nested object-array fields are currently raw-YAML-only inside the object-array editor.", [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}'.", [ValidationMessageKeys.exclusiveMaximumViolation]: "Property '{displayPath}' must be less than {value}.", [ValidationMessageKeys.exclusiveMinimumViolation]: "Property '{displayPath}' must be greater than {value}.", [ValidationMessageKeys.maximumViolation]: "Property '{displayPath}' must be less than or equal to {value}.", @@ -242,6 +245,7 @@ const zhCnMessages = { "webview.hint.minContains": "最少 contains 匹配数:{value}", "webview.hint.maxContains": "最多 contains 匹配数:{value}", "webview.hint.uniqueItems": "元素必须唯一", + "webview.hint.required": "必填字段:{properties}", "webview.hint.itemMinimum": "元素最小值:{value}", "webview.hint.itemConst": "元素固定值:{value}", "webview.hint.itemExclusiveMinimum": "元素开区间最小值:{value}", @@ -255,6 +259,7 @@ const zhCnMessages = { "webview.hint.minProperties": "最少属性数:{value}", "webview.hint.maxProperties": "最多属性数:{value}", "webview.hint.dependentRequired": "当 {trigger} 出现时:还必须声明 {dependencies}", + "webview.hint.dependentSchemas": "当 {trigger} 出现时:还必须满足 {schema}", "webview.hint.refTable": "引用表:{refTable}", "webview.unsupported.array": "当前表单预览暂不支持这种数组结构,请改用原始 YAML。", "webview.unsupported.type": "当前表单预览暂不支持 {type} 字段,请改用原始 YAML。", @@ -262,6 +267,7 @@ const zhCnMessages = { "webview.unsupported.nestedObjectArray": "对象数组编辑器内暂不支持更深层的对象数组字段,请改用原始 YAML。", [ValidationMessageKeys.constMismatch]: "属性“{displayPath}”必须匹配固定值 {value}。", [ValidationMessageKeys.dependentRequiredViolation]: "属性“{triggerProperty}”存在时,必须同时声明属性“{displayPath}”。", + [ValidationMessageKeys.dependentSchemasViolation]: "对象“{displayPath}”在属性“{triggerProperty}”存在时,必须满足对应的 dependent schema。", [ValidationMessageKeys.exclusiveMaximumViolation]: "属性“{displayPath}”必须小于 {value}。", [ValidationMessageKeys.exclusiveMinimumViolation]: "属性“{displayPath}”必须大于 {value}。", [ValidationMessageKeys.maximumViolation]: "属性“{displayPath}”必须小于或等于 {value}。", diff --git a/tools/gframework-config-tool/src/localizationKeys.js b/tools/gframework-config-tool/src/localizationKeys.js index a1a3c43b..8ea4cd99 100644 --- a/tools/gframework-config-tool/src/localizationKeys.js +++ b/tools/gframework-config-tool/src/localizationKeys.js @@ -1,5 +1,6 @@ const ValidationMessageKeys = Object.freeze({ constMismatch: "validation.constMismatch", + dependentSchemasViolation: "validation.dependentSchemasViolation", enumMismatch: "validation.enumMismatch", exclusiveMaximumViolation: "validation.exclusiveMaximumViolation", exclusiveMinimumViolation: "validation.exclusiveMinimumViolation", diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index d7fc30ee..091a813a 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -1691,6 +1691,107 @@ test("parseSchemaContent should reject dependentRequired targets outside the sam ); }); +test("parseSchemaContent should capture dependentSchemas metadata", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "itemId": { "type": "string" }, + "itemCount": { "type": "integer" } + }, + "dependentSchemas": { + "itemId": { + "type": "object", + "required": ["itemCount"], + "properties": { + "itemId": { "type": "string" }, + "itemCount": { "type": "integer" } + } + } + } + } + } + } + `); + + assert.equal(schema.properties.reward.dependentSchemas.itemId.type, "object"); + assert.deepEqual(schema.properties.reward.dependentSchemas.itemId.required, ["itemCount"]); +}); + +test("parseSchemaContent should reject non-object dependentSchemas declarations", () => { + assert.throws( + () => parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "itemId": { "type": "string" }, + "itemCount": { "type": "integer" } + }, + "dependentSchemas": ["itemId"] + } + } + } + `), + /must declare 'dependentSchemas' as an object/u + ); +}); + +test("parseSchemaContent should reject dependentSchemas triggers outside the same object schema", () => { + assert.throws( + () => parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "itemId": { "type": "string" } + }, + "dependentSchemas": { + "itemCount": { + "type": "object", + "properties": {} + } + } + } + } + } + `), + /dependentSchemas' for undeclared property 'itemCount'/u + ); +}); + +test("parseSchemaContent should reject non-object-typed dependentSchemas sub-schemas", () => { + assert.throws( + () => parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "itemId": { "type": "string" } + }, + "dependentSchemas": { + "itemId": { + "type": "string", + "const": "potion" + } + } + } + } + } + `), + /object-typed 'dependentSchemas' schema/u + ); +}); + test("parseSchemaContent should capture not sub-schema metadata", () => { const schema = parseSchemaContent(` { @@ -1768,6 +1869,79 @@ reward: assert.deepEqual(validateParsedConfig(schema, yaml), []); }); +test("validateParsedConfig should report dependentSchemas violations", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "itemId": { "type": "string" }, + "itemCount": { "type": "integer" } + }, + "dependentSchemas": { + "itemId": { + "type": "object", + "required": ["itemCount"], + "properties": { + "itemId": { "type": "string" }, + "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, /dependent schema/u); + assert.match(diagnostics[0].message, /reward\.itemId/u); +}); + +test("validateParsedConfig should accept satisfied dependentSchemas", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "itemId": { "type": "string" }, + "itemCount": { "type": "integer" }, + "bonus": { "type": "integer" } + }, + "dependentSchemas": { + "itemId": { + "type": "object", + "required": ["itemCount"], + "properties": { + "itemCount": { "type": "integer" } + } + } + } + } + } + } + `); + 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 f97606cd..c3dd07f4 100644 --- a/tools/gframework-config-tool/test/localization.test.js +++ b/tools/gframework-config-tool/test/localization.test.js @@ -111,3 +111,38 @@ test("createLocalizer should expose dependentRequired validation keys", () => { }), "属性“reward.itemId”存在时,必须同时声明属性“reward.itemCount”。"); }); + +test("createLocalizer should expose dependentSchemas validation keys", () => { + const englishLocalizer = createLocalizer("en"); + const chineseLocalizer = createLocalizer("zh-cn"); + + assert.equal( + englishLocalizer.t("webview.hint.required", { + properties: "itemCount, bonusCount" + }), + "Required: itemCount, bonusCount"); + assert.equal( + englishLocalizer.t("webview.hint.dependentSchemas", { + trigger: "reward.itemId", + schema: "object, Required: itemCount" + }), + "When reward.itemId is set: satisfy object, Required: itemCount"); + assert.equal( + chineseLocalizer.t("webview.hint.dependentSchemas", { + trigger: "reward.itemId", + schema: "object, 必填字段:itemCount" + }), + "当 reward.itemId 出现时:还必须满足 object, 必填字段:itemCount"); + assert.equal( + englishLocalizer.t(ValidationMessageKeys.dependentSchemasViolation, { + displayPath: "reward", + triggerProperty: "reward.itemId" + }), + "Object 'reward' must satisfy the dependent schema triggered by sibling property 'reward.itemId'."); + assert.equal( + chineseLocalizer.t(ValidationMessageKeys.dependentSchemasViolation, { + displayPath: "reward", + triggerProperty: "reward.itemId" + }), + "对象“reward”在属性“reward.itemId”存在时,必须满足对应的 dependent schema。"); +});