diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs index 7f115f61..1d0c4a10 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -1039,6 +1039,523 @@ public class YamlConfigLoaderTests }); } + /// + /// 验证数组声明 contains 后,默认至少要有一个匹配元素。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Array_Violates_Default_Contains_Match_Count() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + dropRates: + - 1 + - 2 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "dropRates"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "dropRates": { + "type": "array", + "contains": { + "type": "integer", + "const": 5 + }, + "items": { + "type": "integer" + } + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.ConstraintViolation)); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("dropRates")); + Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("0")); + Assert.That(exception.Message, Does.Contain("at least 1 items matching the 'contains' schema")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证数组声明 minContains 后,会按匹配数量而不是总元素数做约束判断。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Array_Violates_MinContains() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + dropRates: + - 5 + - 7 + - 9 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "dropRates"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "dropRates": { + "type": "array", + "minContains": 2, + "contains": { + "type": "integer", + "const": 5 + }, + "items": { + "type": "integer" + } + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.ConstraintViolation)); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("dropRates")); + Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("1")); + Assert.That(exception.Message, Does.Contain("at least 2 items matching the 'contains' schema")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证数组声明 maxContains 后,会拒绝匹配元素过多的序列。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Array_Violates_MaxContains() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + dropRates: + - 5 + - 5 + - 7 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "dropRates"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "dropRates": { + "type": "array", + "maxContains": 1, + "contains": { + "type": "integer", + "const": 5 + }, + "items": { + "type": "integer" + } + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.ConstraintViolation)); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("dropRates")); + Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("2")); + Assert.That(exception.Message, Does.Contain("at most 1 items matching the 'contains' schema")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证匹配数量刚好等于 minContains / maxContains 时会被视为合法边界。 + /// + [Test] + public async Task LoadAsync_Should_Accept_Array_When_Contains_Match_Count_Equals_Min_And_Max_Bounds() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + dropRates: + - 5 + - 7 + - 5 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "dropRates"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "dropRates": { + "type": "array", + "minContains": 2, + "maxContains": 2, + "contains": { + "type": "integer", + "const": 5 + }, + "items": { + "type": "integer" + } + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + await loader.LoadAsync(registry); + + var table = registry.GetTable("monster"); + + Assert.Multiple(() => + { + Assert.That(table.Count, Is.EqualTo(1)); + Assert.That(table.Get(1).DropRates, Is.EqualTo(new[] { 5, 7, 5 })); + }); + } + + /// + /// 验证数组字段将 contains 声明为非对象 schema 时,会在 schema 解析阶段被拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Contains_Is_Not_Object_Schema() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + dropRates: + - 5 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "dropRates"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "dropRates": { + "type": "array", + "contains": 5, + "items": { + "type": "integer" + } + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.SchemaUnsupported)); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("dropRates")); + Assert.That(exception.Message, Does.Contain("'contains' as an object-valued schema")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证数组字段将 contains 声明为嵌套数组 schema 时,会在 schema 解析阶段被拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Contains_Uses_Nested_Array_Schema() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + dropRates: + - 5 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "dropRates"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "dropRates": { + "type": "array", + "contains": { + "type": "array", + "items": { + "type": "integer" + } + }, + "items": { + "type": "integer" + } + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.SchemaUnsupported)); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("dropRates")); + Assert.That(exception.Message, Does.Contain("unsupported nested array 'contains' schemas")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证对象数组的 contains 试匹配会按声明属性子集工作,而不会因额外字段误判为不匹配。 + /// + [Test] + public async Task LoadAsync_Should_Accept_Object_Array_When_Contains_Matches_Declared_Subset_Properties() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + entries: + - + id: 1 + weight: 2 + - + id: 2 + weight: 3 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "entries"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "entries": { + "type": "array", + "minContains": 1, + "contains": { + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "integer", + "const": 1 + } + } + }, + "items": { + "type": "object", + "required": ["id", "weight"], + "properties": { + "id": { "type": "integer" }, + "weight": { "type": "integer" } + } + } + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + await loader.LoadAsync(registry); + + var table = registry.GetTable("monster"); + + Assert.Multiple(() => + { + Assert.That(table.Count, Is.EqualTo(1)); + Assert.That(table.Get(1).Entries.Count, Is.EqualTo(2)); + Assert.That(table.Get(1).Entries[0].Id, Is.EqualTo(1)); + Assert.That(table.Get(1).Entries[0].Weight, Is.EqualTo(2)); + }); + } + + /// + /// 验证数组在未声明 contains 时不能单独使用 minContains。 + /// + [Test] + public void LoadAsync_Should_Throw_When_MinContains_Is_Declared_Without_Contains() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + dropRates: + - 5 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "dropRates"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "dropRates": { + "type": "array", + "minContains": 1, + "items": { + "type": "integer" + } + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.SchemaUnsupported)); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("dropRates")); + Assert.That(exception.Message, Does.Contain("minContains")); + Assert.That(exception.Message, Does.Contain("without a companion 'contains' schema")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证数组字段将 minContains 声明为大于 maxContains 时,会在 schema 解析阶段被拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Array_Contains_Count_Constraints_Are_Inverted() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + dropRates: + - 5 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "dropRates"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "dropRates": { + "type": "array", + "minContains": 2, + "maxContains": 1, + "contains": { + "type": "integer", + "const": 5 + }, + "items": { + "type": "integer" + } + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.SchemaUnsupported)); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("dropRates")); + Assert.That(exception.Message, Does.Contain("minContains")); + Assert.That(exception.Message, Does.Contain("greater than 'maxContains'")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + /// /// 验证 uniqueItems 的归一化键不会把带分隔符的不同对象值误判为重复项。 /// @@ -2064,6 +2581,190 @@ public class YamlConfigLoaderTests }); } + /// + /// 验证仅声明在 contains 子 schema 里的跨表引用也会参与整批加载校验。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Contains_Matched_Reference_Target_Is_Missing() + { + CreateConfigFile( + "item/potion.yaml", + """ + id: potion + name: Potion + """); + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + dropItemIds: + - potion + - missing_item + """); + CreateSchemaFile( + "schemas/item.schema.json", + """ + { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" } + } + } + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "dropItemIds"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "dropItemIds": { + "type": "array", + "minContains": 1, + "contains": { + "type": "string", + "x-gframework-ref-table": "item" + }, + "items": { + "type": "string" + } + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("item", "item", "schemas/item.schema.json", + static config => config.Id) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.ReferencedKeyNotFound)); + Assert.That(exception.Diagnostic.TableName, Is.EqualTo("monster")); + Assert.That(exception.Diagnostic.ReferencedTableName, Is.EqualTo("item")); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("dropItemIds[1]")); + Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("missing_item")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证依赖关系仅来自 contains 子 schema 时,热重载仍会追踪该依赖并在目标表破坏引用后回滚。 + /// + [Test] + public async Task EnableHotReload_Should_Keep_Previous_State_When_Contains_Reference_Dependency_Breaks() + { + CreateConfigFile( + "item/potion.yaml", + """ + id: potion + name: Potion + """); + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + dropItemIds: + - potion + """); + CreateSchemaFile( + "schemas/item.schema.json", + """ + { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" } + } + } + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "dropItemIds"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "dropItemIds": { + "type": "array", + "minContains": 1, + "contains": { + "type": "string", + "x-gframework-ref-table": "item" + }, + "items": { + "type": "string" + } + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("item", "item", "schemas/item.schema.json", + static config => config.Id) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + await loader.LoadAsync(registry); + + var reloadFailureTaskSource = + new TaskCompletionSource<(string TableName, Exception Exception)>(TaskCreationOptions + .RunContinuationsAsynchronously); + var hotReload = loader.EnableHotReload( + registry, + onTableReloadFailed: (tableName, exception) => + reloadFailureTaskSource.TrySetResult((tableName, exception)), + debounceDelay: TimeSpan.FromMilliseconds(150)); + + try + { + CreateConfigFile( + "item/potion.yaml", + """ + id: elixir + name: Elixir + """); + + var failure = await WaitForTaskWithinAsync(reloadFailureTaskSource.Task, TimeSpan.FromSeconds(5)); + var diagnosticException = failure.Exception as ConfigLoadException; + + Assert.Multiple(() => + { + Assert.That(failure.TableName, Is.EqualTo("item")); + Assert.That(diagnosticException, Is.Not.Null); + Assert.That(diagnosticException!.Diagnostic.FailureKind, + Is.EqualTo(ConfigLoadFailureKind.ReferencedKeyNotFound)); + Assert.That(diagnosticException.Diagnostic.TableName, Is.EqualTo("monster")); + Assert.That(diagnosticException.Diagnostic.ReferencedTableName, Is.EqualTo("item")); + Assert.That(diagnosticException.Diagnostic.DisplayPath, Is.EqualTo("dropItemIds[0]")); + Assert.That(diagnosticException.Diagnostic.RawValue, Is.EqualTo("potion")); + Assert.That(registry.GetTable("item").ContainsKey("potion"), Is.True); + Assert.That(registry.GetTable("monster").Get(1).DropItemIds, + Is.EqualTo(new[] { "potion" })); + }); + } + finally + { + hotReload.UnRegister(); + } + } + /// /// 验证启用热重载后,配置文件内容变更会刷新已注册配置表。 /// @@ -2498,7 +3199,7 @@ public class YamlConfigLoaderTests /// /// 获取或设置掉落率列表。 /// - public IReadOnlyList DropRates { get; set; } = Array.Empty(); + public List DropRates { get; set; } = new(); } /// @@ -2575,6 +3276,43 @@ public class YamlConfigLoaderTests public List Entries { get; set; } = new(); } + /// + /// 用于对象数组 contains 子集匹配回归测试的最小配置类型。 + /// + private sealed class MonsterWeightedEntryArrayConfigStub + { + /// + /// 获取或设置主键。 + /// + public int Id { get; set; } + + /// + /// 获取或设置名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 获取或设置对象数组条目。 + /// + public List Entries { get; set; } = new(); + } + + /// + /// 表示对象数组 contains 子集匹配回归测试中的条目元素。 + /// + private sealed class WeightedEntryConfigStub + { + /// + /// 获取或设置条目标识。 + /// + public int Id { get; set; } + + /// + /// 获取或设置权重。 + /// + public int Weight { get; set; } + } + /// /// 表示对象数组中的阶段元素。 /// diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index 613fe827..8c588e56 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -9,8 +9,9 @@ namespace GFramework.Game.Config; /// 该校验器与当前配置生成器、VS Code 工具支持的 schema 子集保持一致, /// 并通过递归遍历方式覆盖嵌套对象、对象数组、标量数组与深层 enum / 引用约束。 /// 当前共享子集额外支持 multipleOfuniqueItems、 +/// contains / minContains / maxContains、 /// minPropertiesmaxProperties, -/// 让数值步进、数组去重和对象属性数量规则在运行时与生成器 / 工具侧保持一致。 +/// 让数值步进、数组去重、数组匹配计数和对象属性数量规则在运行时与生成器 / 工具侧保持一致。 /// internal static class YamlConfigSchemaValidator { @@ -435,22 +436,42 @@ internal static class YamlConfigSchemaValidator /// 实际 YAML 节点。 /// 对应的 schema 节点。 /// 已收集的跨表引用。 + /// + /// 是否允许对象节点出现当前 schema 子树未声明的额外字段。 + /// 该开关仅用于 contains 试匹配,让对象子 schema 可以按“声明属性子集匹配”工作; + /// 正常加载主链路仍保持未知字段即失败的严格语义。 + /// private static void ValidateNode( string tableName, string yamlPath, string displayPath, YamlNode node, YamlConfigSchemaNode schemaNode, - ICollection? references) + ICollection? references, + bool allowUnknownObjectProperties = false) { switch (schemaNode.NodeType) { case YamlConfigSchemaPropertyType.Object: - ValidateObjectNode(tableName, yamlPath, displayPath, node, schemaNode, references); + ValidateObjectNode( + tableName, + yamlPath, + displayPath, + node, + schemaNode, + references, + allowUnknownObjectProperties); return; case YamlConfigSchemaPropertyType.Array: - ValidateArrayNode(tableName, yamlPath, displayPath, node, schemaNode, references); + ValidateArrayNode( + tableName, + yamlPath, + displayPath, + node, + schemaNode, + references, + allowUnknownObjectProperties); return; case YamlConfigSchemaPropertyType.Integer: @@ -481,13 +502,17 @@ internal static class YamlConfigSchemaValidator /// 实际 YAML 节点。 /// 对象 schema 节点。 /// 已收集的跨表引用。 + /// + /// 是否允许当前对象包含 schema 子树未声明的额外字段。 + /// private static void ValidateObjectNode( string tableName, string yamlPath, string displayPath, YamlNode node, YamlConfigSchemaNode schemaNode, - ICollection? references) + ICollection? references, + bool allowUnknownObjectProperties) { if (node is not YamlMappingNode mappingNode) { @@ -533,6 +558,11 @@ internal static class YamlConfigSchemaValidator if (schemaNode.Properties is null || !schemaNode.Properties.TryGetValue(propertyName, out var propertySchema)) { + if (allowUnknownObjectProperties) + { + continue; + } + throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.UnknownProperty, tableName, @@ -542,7 +572,14 @@ internal static class YamlConfigSchemaValidator displayPath: propertyPath); } - ValidateNode(tableName, yamlPath, propertyPath, entry.Value, propertySchema, references); + ValidateNode( + tableName, + yamlPath, + propertyPath, + entry.Value, + propertySchema, + references, + allowUnknownObjectProperties); } if (schemaNode.RequiredProperties is null) @@ -639,13 +676,17 @@ internal static class YamlConfigSchemaValidator /// 实际 YAML 节点。 /// 数组 schema 节点。 /// 已收集的跨表引用。 + /// + /// 是否允许数组元素内的对象节点包含 schema 子树未声明的额外字段。 + /// private static void ValidateArrayNode( string tableName, string yamlPath, string displayPath, YamlNode node, YamlConfigSchemaNode schemaNode, - ICollection? references) + ICollection? references, + bool allowUnknownObjectProperties) { if (node is not YamlSequenceNode sequenceNode) { @@ -682,10 +723,12 @@ internal static class YamlConfigSchemaValidator $"{displayPath}[{itemIndex}]", sequenceNode.Children[itemIndex], schemaNode.ItemNode, - references); + references, + allowUnknownObjectProperties); } ValidateArrayUniqueItemsConstraint(tableName, yamlPath, displayPath, sequenceNode, schemaNode); + ValidateArrayContainsConstraints(tableName, yamlPath, displayPath, sequenceNode, schemaNode, references); ValidateConstantValue(tableName, yamlPath, displayPath, sequenceNode, schemaNode); } @@ -1152,7 +1195,7 @@ internal static class YamlConfigSchemaValidator } /// - /// 解析数组节点支持的元素数量约束。 + /// 解析数组节点支持的元素数量、去重与 contains 匹配数量约束。 /// /// 所属配置表名称。 /// Schema 文件路径。 @@ -1168,6 +1211,7 @@ internal static class YamlConfigSchemaValidator var minItems = TryParseArrayLengthConstraint(tableName, schemaPath, propertyPath, element, "minItems"); var maxItems = TryParseArrayLengthConstraint(tableName, schemaPath, propertyPath, element, "maxItems"); var uniqueItems = TryParseUniqueItemsConstraint(tableName, schemaPath, propertyPath, element); + var containsConstraints = ParseArrayContainsConstraints(tableName, schemaPath, propertyPath, element); if (minItems.HasValue && maxItems.HasValue && minItems.Value > maxItems.Value) { @@ -1179,9 +1223,77 @@ internal static class YamlConfigSchemaValidator displayPath: GetDiagnosticPath(propertyPath)); } - return !minItems.HasValue && !maxItems.HasValue && !uniqueItems + return !minItems.HasValue && !maxItems.HasValue && !uniqueItems && containsConstraints is null ? null - : new YamlConfigArrayConstraints(minItems, maxItems, uniqueItems); + : new YamlConfigArrayConstraints(minItems, maxItems, uniqueItems, containsConstraints); + } + + /// + /// 解析数组节点声明的 contains 约束及其匹配数量边界。 + /// 运行时会把 contains 解析成独立的 schema 子树,后续逐项复用同一套递归校验逻辑判断“是否匹配”。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 数组字段路径。 + /// Schema 节点。 + /// 数组 contains 约束模型;未声明时返回空。 + private static YamlConfigArrayContainsConstraints? ParseArrayContainsConstraints( + string tableName, + string schemaPath, + string propertyPath, + JsonElement element) + { + var minContains = TryParseArrayLengthConstraint(tableName, schemaPath, propertyPath, element, "minContains"); + var maxContains = TryParseArrayLengthConstraint(tableName, schemaPath, propertyPath, element, "maxContains"); + if (!element.TryGetProperty("contains", out var containsElement)) + { + if (minContains.HasValue || maxContains.HasValue) + { + var keywordName = minContains.HasValue ? "minContains" : "maxContains"; + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' declares '{keywordName}' without a companion 'contains' schema.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + return null; + } + + if (containsElement.ValueKind != JsonValueKind.Object) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' must declare 'contains' as an object-valued schema.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + var containsNode = ParseNode(tableName, schemaPath, $"{propertyPath}[contains]", containsElement); + if (containsNode.NodeType == YamlConfigSchemaPropertyType.Array) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' uses unsupported nested array 'contains' schemas.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + var effectiveMinContains = minContains ?? 1; + if (maxContains.HasValue && effectiveMinContains > maxContains.Value) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' declares 'minContains' greater than 'maxContains'.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + return new YamlConfigArrayContainsConstraints(containsNode, minContains, maxContains); } /// @@ -2073,6 +2185,155 @@ internal static class YamlConfigSchemaValidator } } + /// + /// 校验数组是否满足 contains 声明的匹配数量边界。 + /// 该实现会对每个数组项复用同一套递归校验逻辑做“非抛出式匹配”,避免 contains 与主校验链各自维护不同的 schema 解释规则。 + /// + /// 所属配置表名称。 + /// YAML 文件路径。 + /// 字段路径。 + /// 实际数组节点。 + /// 数组 schema 节点。 + /// 匹配成功的 contains 子树所声明的跨表引用收集器。 + private static void ValidateArrayContainsConstraints( + string tableName, + string yamlPath, + string displayPath, + YamlSequenceNode sequenceNode, + YamlConfigSchemaNode schemaNode, + ICollection? references) + { + var containsConstraints = schemaNode.ArrayConstraints?.ContainsConstraints; + if (containsConstraints is null) + { + return; + } + + var matchingCount = CountMatchingContainsItems( + tableName, + yamlPath, + displayPath, + sequenceNode, + containsConstraints.ContainsNode, + references); + var rawValue = matchingCount.ToString(CultureInfo.InvariantCulture); + var requiredMinContains = containsConstraints.MinContains ?? 1; + if (matchingCount < requiredMinContains) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' must contain at least {requiredMinContains} items matching the 'contains' schema, but the current YAML sequence contains {matchingCount}.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: rawValue, + detail: $"Minimum matching contains count: {requiredMinContains}."); + } + + if (containsConstraints.MaxContains.HasValue && + matchingCount > containsConstraints.MaxContains.Value) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' must contain at most {containsConstraints.MaxContains.Value} items matching the 'contains' schema, but the current YAML sequence contains {matchingCount}.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: rawValue, + detail: $"Maximum matching contains count: {containsConstraints.MaxContains.Value}."); + } + } + + /// + /// 统计当前数组中有多少元素满足 contains 子 schema。 + /// 非预期内部错误会继续抛出,只有正常的 schema 不匹配才会被当成“当前元素不计数”。 + /// + /// 所属配置表名称。 + /// YAML 文件路径。 + /// 数组字段路径。 + /// 实际数组节点。 + /// contains 子 schema。 + /// 匹配成功元素的可选跨表引用收集器。 + /// 匹配 contains 子 schema 的元素数量。 + private static int CountMatchingContainsItems( + string tableName, + string yamlPath, + string displayPath, + YamlSequenceNode sequenceNode, + YamlConfigSchemaNode containsNode, + ICollection? references) + { + var matchingCount = 0; + for (var itemIndex = 0; itemIndex < sequenceNode.Children.Count; itemIndex++) + { + if (IsArrayItemMatchingContains( + tableName, + yamlPath, + $"{displayPath}[{itemIndex}]", + sequenceNode.Children[itemIndex], + containsNode, + references)) + { + matchingCount++; + } + } + + return matchingCount; + } + + /// + /// 判断单个数组元素是否满足 contains 子 schema。 + /// contains 的语义是“尝试匹配”,因此普通约束失败会返回 ,但内部意外状态仍会继续抛出。 + /// + /// 所属配置表名称。 + /// YAML 文件路径。 + /// 当前数组元素路径。 + /// 实际 YAML 元素。 + /// contains 子 schema。 + /// 当前元素匹配成功后要写回的可选跨表引用收集器。 + /// 当前元素是否匹配 contains 子 schema。 + private static bool IsArrayItemMatchingContains( + string tableName, + string yamlPath, + string displayPath, + YamlNode itemNode, + YamlConfigSchemaNode containsNode, + ICollection? references) + { + // contains 的“试匹配”不能把失败元素的引用泄漏给外层,但匹配成功的元素仍需要参与 + // 跨表引用收集,否则仅声明在 contains 子 schema 里的 ref-table 会被运行时遗漏。 + List? matchedReferences = references is null ? null : new(); + + try + { + ValidateNode( + tableName, + yamlPath, + displayPath, + itemNode, + containsNode, + matchedReferences, + allowUnknownObjectProperties: true); + + if (references is not null && + matchedReferences is not null) + { + foreach (var referenceUsage in matchedReferences) + { + references.Add(referenceUsage); + } + } + + return true; + } + catch (ConfigLoadException exception) when (exception.Diagnostic.FailureKind != ConfigLoadFailureKind.UnexpectedFailure) + { + return false; + } + } + /// /// 将一个已通过结构校验的 YAML 节点归一化为可比较字符串。 /// 该键同时服务于 uniqueItemsconst, @@ -2436,6 +2697,12 @@ internal static class YamlConfigSchemaValidator { CollectReferencedTableNames(node.ItemNode, referencedTableNames); } + + var containsNode = node.ArrayConstraints?.ContainsConstraints?.ContainsNode; + if (containsNode is not null) + { + CollectReferencedTableNames(containsNode, referencedTableNames); + } } /// @@ -3100,7 +3367,7 @@ internal sealed class YamlConfigStringConstraints } /// -/// 表示一个数组节点上声明的元素数量或去重约束。 +/// 表示一个数组节点上声明的元素数量、去重与 contains 匹配计数约束。 /// 该模型与标量约束拆分保存,避免数组节点继续共享不适用的标量字段。 /// internal sealed class YamlConfigArrayConstraints @@ -3111,11 +3378,17 @@ internal sealed class YamlConfigArrayConstraints /// 最小元素数量约束。 /// 最大元素数量约束。 /// 是否要求数组元素唯一。 - public YamlConfigArrayConstraints(int? minItems, int? maxItems, bool uniqueItems) + /// 数组 contains 约束;未声明时为空。 + public YamlConfigArrayConstraints( + int? minItems, + int? maxItems, + bool uniqueItems, + YamlConfigArrayContainsConstraints? containsConstraints) { MinItems = minItems; MaxItems = maxItems; UniqueItems = uniqueItems; + ContainsConstraints = containsConstraints; } /// @@ -3132,6 +3405,51 @@ internal sealed class YamlConfigArrayConstraints /// 获取是否要求数组元素唯一。 /// public bool UniqueItems { get; } + + /// + /// 获取数组 contains 约束;未声明时返回空。 + /// + public YamlConfigArrayContainsConstraints? ContainsConstraints { get; } +} + +/// +/// 表示数组节点声明的 contains 匹配约束。 +/// 该模型把 contains 子 schema 与匹配数量边界聚合在一起,避免数组节点再额外散落多组相关成员。 +/// +internal sealed class YamlConfigArrayContainsConstraints +{ + /// + /// 初始化数组 contains 约束模型。 + /// + /// contains 子 schema。 + /// 最小匹配数量;为 时按 JSON Schema 语义默认 1。 + /// 最大匹配数量。 + public YamlConfigArrayContainsConstraints( + YamlConfigSchemaNode containsNode, + int? minContains, + int? maxContains) + { + ArgumentNullException.ThrowIfNull(containsNode); + + ContainsNode = containsNode; + MinContains = minContains; + MaxContains = maxContains; + } + + /// + /// 获取 contains 子 schema。 + /// + public YamlConfigSchemaNode ContainsNode { get; } + + /// + /// 获取最小匹配数量;未显式声明时返回空,由调用方按默认值 1 解释。 + /// + public int? MinContains { get; } + + /// + /// 获取最大匹配数量。 + /// + public int? MaxContains { get; } } /// diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs index 161ae01b..71c0065c 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs @@ -103,7 +103,13 @@ public class SchemaConfigGeneratorSnapshotTests "type": "array", "minItems": 1, "maxItems": 3, + "minContains": 1, + "maxContains": 2, "uniqueItems": true, + "contains": { + "type": "string", + "const": "potion" + }, "items": { "type": "string", "minLength": 3, diff --git a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt index a33fd12d..5f3aa997 100644 --- a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt +++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt @@ -48,7 +48,7 @@ public sealed partial class MonsterConfig /// /// Schema property path: 'dropItems'. /// Allowed values: potion, slime_gel. - /// Constraints: minItems = 1, maxItems = 3, uniqueItems = true. + /// Constraints: minItems = 1, maxItems = 3, uniqueItems = true, contains = string (const = "potion"), minContains = 1, maxContains = 2. /// References config table: 'item'. /// Item constraints: minLength = 3, maxLength = 12. /// Generated default initializer: = new string[] { "potion" }; diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs index 787297f1..078bd09d 100644 --- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -7,6 +7,7 @@ namespace GFramework.SourceGenerators.Config; /// 当前实现聚焦 AI-First 配置系统共享的最小 schema 子集, /// 支持嵌套对象、对象数组、标量数组,以及可映射的 default / enum / const / ref-table 元数据。 /// 当前共享子集也会把 multipleOfuniqueItems、 +/// contains / minContains / maxContains、 /// minPropertiesmaxProperties 写入生成代码文档, /// 让消费者能直接在强类型 API 上看到运行时生效的约束。 /// @@ -2442,7 +2443,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator } /// - /// 将 shared schema 子集中的范围、步进、长度、数组数量 / 去重与对象属性数量约束整理成 XML 文档可读字符串。 + /// 将 shared schema 子集中的范围、步进、长度、数组数量 / 去重 / contains 与对象属性数量约束整理成 XML 文档可读字符串。 /// /// Schema 节点。 /// 标量类型。 @@ -2526,6 +2527,27 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator parts.Add("uniqueItems = true"); } + if (schemaType == "array") + { + var containsDocumentation = TryBuildContainsDocumentation(element); + if (containsDocumentation is not null) + { + parts.Add($"contains = {containsDocumentation}"); + } + } + + if (schemaType == "array" && + TryGetNonNegativeInt32(element, "minContains", out var minContains)) + { + parts.Add($"minContains = {minContains.ToString(CultureInfo.InvariantCulture)}"); + } + + if (schemaType == "array" && + TryGetNonNegativeInt32(element, "maxContains", out var maxContains)) + { + parts.Add($"maxContains = {maxContains.ToString(CultureInfo.InvariantCulture)}"); + } + if (schemaType == "object" && TryGetNonNegativeInt32(element, "minProperties", out var minProperties)) { @@ -2541,6 +2563,67 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return parts.Count > 0 ? string.Join(", ", parts) : null; } + /// + /// 将数组 contains 子 schema 整理成 XML 文档可读字符串。 + /// 输出优先保持紧凑,只展示消费者在强类型 API 上最需要看到的匹配摘要。 + /// + /// 数组 schema 节点。 + /// 格式化后的 contains 说明。 + private static string? TryBuildContainsDocumentation(JsonElement element) + { + if (!element.TryGetProperty("contains", out var containsElement) || + containsElement.ValueKind != JsonValueKind.Object) + { + return null; + } + + return TryBuildContainsSchemaSummary(containsElement); + } + + /// + /// 为 contains 子 schema 生成紧凑摘要。 + /// 该摘要复用现有 enum / const / 约束文档构造器,避免 contains 与主属性文档逐渐漂移。 + /// + /// contains 子 schema。 + /// 格式化后的摘要字符串。 + private static string? TryBuildContainsSchemaSummary(JsonElement containsElement) + { + if (!containsElement.TryGetProperty("type", out var typeElement) || + typeElement.ValueKind != JsonValueKind.String) + { + return null; + } + + var schemaType = typeElement.GetString(); + if (string.IsNullOrWhiteSpace(schemaType)) + { + return null; + } + + var details = new List(); + var enumDocumentation = TryBuildEnumDocumentation(containsElement, schemaType!); + if (enumDocumentation is not null) + { + details.Add($"enum = {enumDocumentation}"); + } + + var constraintDocumentation = TryBuildConstraintDocumentation(containsElement, schemaType!); + if (constraintDocumentation is not null) + { + details.Add(constraintDocumentation); + } + + var refTable = TryGetMetadataString(containsElement, "x-gframework-ref-table"); + if (!string.IsNullOrWhiteSpace(refTable)) + { + details.Add($"ref-table = {refTable}"); + } + + return details.Count == 0 + ? schemaType + : $"{schemaType} ({string.Join(", ", details)})"; + } + /// /// 将 const 值整理成 XML 文档可读字符串。 /// diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index b1fa31c9..6157ab7e 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 共享支持 `const`、`minimum`、`maximum`、`exclusiveMinimum`、`exclusiveMaximum`、`multipleOf`、`minLength`、`maxLength`、`pattern`、`minItems`、`maxItems`、`uniqueItems`、`minProperties`、`maxProperties` +- Runtime / Generator / Tooling 共享支持 `const`、`minimum`、`maximum`、`exclusiveMinimum`、`exclusiveMaximum`、`multipleOf`、`minLength`、`maxLength`、`pattern`、`minItems`、`maxItems`、`uniqueItems`、`contains`、`minContains`、`maxContains`、`minProperties`、`maxProperties` - Source Generator 生成配置类型、表包装、单表注册/访问辅助,以及项目级聚合注册目录 - VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口 @@ -657,6 +657,7 @@ var loader = new YamlConfigLoader("config-root") - 字符串字段违反 `pattern` - 数组字段违反 `minItems` / `maxItems` - 数组字段违反 `uniqueItems` +- 数组字段违反 `contains` / `minContains` / `maxContains` - 对象字段违反 `minProperties` / `maxProperties` - 标量 / 对象 / 数组字段违反 `const` - 标量 `enum` 不匹配 @@ -714,6 +715,7 @@ if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var re - `pattern`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用;当前按 C# `CultureInvariant` 与 JS Unicode `u` 模式解释,非法模式会在 schema 解析阶段直接报错 - `minItems` / `maxItems`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用 - `uniqueItems`:供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;对象数组会按 schema 归一化后的结构比较重复项,而不是依赖 YAML 字段顺序 +- `contains` / `minContains` / `maxContains`:供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;当前会按同一套递归 schema 规则统计“有多少数组元素匹配 contains 子 schema”,其中仅声明 `contains` 时默认至少需要 1 个匹配元素 - `minProperties` / `maxProperties`:供运行时校验、VS Code 校验、对象 section 表单 hint 和生成代码 XML 文档复用;根对象与嵌套对象都会按实际属性数量执行同一套约束 这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。 @@ -811,7 +813,7 @@ var hotReload = loader.EnableHotReload( - 对带 `x-gframework-ref-table` 的字段提供引用 schema / 配置域 / 引用文件跳转入口 - 对空配置文件提供基于 schema 的示例 YAML 初始化入口 - 对同一配置域内的多份 YAML 文件执行批量字段更新 -- 在表单入口中显示 `title / description / default / const / enum / ref-table / multipleOf / uniqueItems / minProperties / maxProperties` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息 +- 在表单入口中显示 `title / description / default / const / enum / ref-table / multipleOf / uniqueItems / contains / minContains / maxContains / minProperties / maxProperties` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息 当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。 diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js index f465617f..77b39183 100644 --- a/tools/gframework-config-tool/src/configValidation.js +++ b/tools/gframework-config-tool/src/configValidation.js @@ -860,6 +860,8 @@ function parseSchemaNode(rawNode, displayPath) { patternRegex: patternMetadata ? patternMetadata.regex : undefined, minItems: normalizeSchemaNonNegativeInteger(value.minItems), maxItems: normalizeSchemaNonNegativeInteger(value.maxItems), + minContains: normalizeSchemaNonNegativeInteger(value.minContains), + maxContains: normalizeSchemaNonNegativeInteger(value.maxContains), minProperties: normalizeSchemaNonNegativeInteger(value.minProperties), maxProperties: normalizeSchemaNonNegativeInteger(value.maxProperties), uniqueItems: normalizeSchemaBoolean(value.uniqueItems), @@ -892,6 +894,27 @@ function parseSchemaNode(rawNode, displayPath) { if (type === "array") { const itemNode = parseSchemaNode(value.items || {}, joinArrayTemplatePath(displayPath)); + const containsNode = value.contains && typeof value.contains === "object" + ? parseSchemaNode(value.contains, joinArrayTemplatePath(displayPath)) + : undefined; + if (!containsNode && + (typeof metadata.minContains === "number" || typeof metadata.maxContains === "number")) { + throw new Error(`Schema property '${displayPath}' declares 'minContains' or 'maxContains' without 'contains'.`); + } + + if (containsNode && containsNode.type === "array") { + throw new Error(`Schema property '${displayPath}' uses unsupported nested array 'contains' schemas.`); + } + + const effectiveMinContains = containsNode + ? (typeof metadata.minContains === "number" ? metadata.minContains : 1) + : undefined; + if (containsNode && + typeof metadata.maxContains === "number" && + effectiveMinContains > metadata.maxContains) { + throw new Error(`Schema property '${displayPath}' declares 'minContains' greater than 'maxContains'.`); + } + return applyConstMetadata({ type: "array", displayPath, @@ -900,8 +923,15 @@ function parseSchemaNode(rawNode, displayPath) { defaultValue: metadata.defaultValue, minItems: metadata.minItems, maxItems: metadata.maxItems, + minContains: containsNode + ? metadata.minContains + : undefined, + maxContains: containsNode + ? metadata.maxContains + : undefined, uniqueItems: metadata.uniqueItems === true, refTable: metadata.refTable, + contains: containsNode, items: itemNode }, value.const, displayPath); } @@ -993,6 +1023,8 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) } const comparableItems = []; + const containsCandidateItems = []; + let hasStructurallyInvalidArrayItems = false; for (let index = 0; index < yamlNode.items.length; index += 1) { const diagnosticsBeforeValidation = diagnostics.length; validateNode( @@ -1002,8 +1034,14 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) diagnostics, localizer); + if (isStructurallyCompatibleWithSchemaNode(schemaNode.items, yamlNode.items[index])) { + containsCandidateItems.push({index, node: yamlNode.items[index]}); + } else { + hasStructurallyInvalidArrayItems = true; + } + // Keep uniqueItems focused on values that are otherwise valid so a - // shape/type error does not also surface as a misleading duplicate. + // shape/type or constraint error does not also surface as a misleading duplicate. if (diagnostics.length === diagnosticsBeforeValidation) { comparableItems.push({index, node: yamlNode.items[index]}); } @@ -1028,6 +1066,39 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) } } + if (!hasStructurallyInvalidArrayItems && schemaNode.contains) { + let matchingContainsCount = 0; + for (const {node} of containsCandidateItems) { + if (matchesSchemaNode(schemaNode.contains, node)) { + matchingContainsCount += 1; + } + } + + const requiredMinContains = typeof schemaNode.minContains === "number" + ? schemaNode.minContains + : 1; + if (matchingContainsCount < requiredMinContains) { + diagnostics.push({ + severity: "error", + message: localizeValidationMessage(ValidationMessageKeys.minContainsViolation, localizer, { + displayPath, + value: String(requiredMinContains) + }) + }); + } + + if (typeof schemaNode.maxContains === "number" && + matchingContainsCount > schemaNode.maxContains) { + diagnostics.push({ + severity: "error", + message: localizeValidationMessage(ValidationMessageKeys.maxContainsViolation, localizer, { + displayPath, + value: String(schemaNode.maxContains) + }) + }); + } + } + validateConstComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer); return; @@ -1251,6 +1322,249 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca validateConstComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer); } +/** + * 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 + * partial matchers: declared properties, required members, and constraints must + * match, but additional object members outside the sub-schema must not block a hit. + * + * @param {SchemaNode} schemaNode Schema node. + * @param {YamlNode} yamlNode YAML node. + * @returns {boolean} True when the YAML node matches the schema node. + */ +function matchesSchemaNode(schemaNode, yamlNode) { + return matchesSchemaNodeInternal(schemaNode, yamlNode); +} + +/** + * Match one YAML node against one schema node using JSON-Schema-style subset semantics. + * The helper mirrors validation rules closely, but it intentionally skips unknown-property + * rejection for objects so `contains` can test whether one item satisfies a sub-schema. + * + * @param {SchemaNode} schemaNode Schema node. + * @param {YamlNode} yamlNode YAML node. + * @returns {boolean} True when the YAML node satisfies the schema node. + */ +function matchesSchemaNodeInternal(schemaNode, yamlNode) { + if (schemaNode.type === "object") { + if (!yamlNode || yamlNode.kind !== "object") { + return false; + } + + const propertyCount = yamlNode.map instanceof Map + ? yamlNode.map.size + : Array.isArray(yamlNode.entries) + ? new Set(yamlNode.entries.map((entry) => entry.key)).size + : 0; + + for (const requiredProperty of schemaNode.required) { + if (!yamlNode.map.has(requiredProperty)) { + return false; + } + } + + for (const [key, childSchema] of Object.entries(schemaNode.properties)) { + if (yamlNode.map.has(key) && + !matchesSchemaNodeInternal(childSchema, yamlNode.map.get(key))) { + return false; + } + } + + if (typeof schemaNode.minProperties === "number" && + propertyCount < schemaNode.minProperties) { + return false; + } + + if (typeof schemaNode.maxProperties === "number" && + propertyCount > schemaNode.maxProperties) { + return false; + } + + return typeof schemaNode.constComparableValue !== "string" || + buildComparableNodeValue(schemaNode, yamlNode) === schemaNode.constComparableValue; + } + + if (schemaNode.type === "array") { + if (!yamlNode || yamlNode.kind !== "array") { + return false; + } + + if (typeof schemaNode.minItems === "number" && + yamlNode.items.length < schemaNode.minItems) { + return false; + } + + if (typeof schemaNode.maxItems === "number" && + yamlNode.items.length > schemaNode.maxItems) { + return false; + } + + for (const item of yamlNode.items) { + if (!matchesSchemaNodeInternal(schemaNode.items, item)) { + return false; + } + } + + if (schemaNode.uniqueItems === true) { + const seenItems = new Set(); + for (const item of yamlNode.items) { + const comparableValue = buildComparableNodeValue(schemaNode.items, item); + if (seenItems.has(comparableValue)) { + return false; + } + + seenItems.add(comparableValue); + } + } + + if (schemaNode.contains) { + let matchingContainsCount = 0; + for (const item of yamlNode.items) { + if (matchesSchemaNodeInternal(schemaNode.contains, item)) { + matchingContainsCount += 1; + } + } + + const requiredMinContains = typeof schemaNode.minContains === "number" + ? schemaNode.minContains + : 1; + if (matchingContainsCount < requiredMinContains) { + return false; + } + + if (typeof schemaNode.maxContains === "number" && + matchingContainsCount > schemaNode.maxContains) { + return false; + } + } + + return typeof schemaNode.constComparableValue !== "string" || + buildComparableNodeValue(schemaNode, yamlNode) === schemaNode.constComparableValue; + } + + if (!yamlNode || yamlNode.kind !== "scalar") { + return false; + } + + if (!isScalarCompatible(schemaNode.type, yamlNode.value)) { + return false; + } + + if (Array.isArray(schemaNode.enumValues) && + schemaNode.enumValues.length > 0 && + !schemaNode.enumValues.includes(unquoteScalar(yamlNode.value))) { + return false; + } + + const scalarValue = unquoteScalar(yamlNode.value); + const supportsNumericConstraints = schemaNode.type === "integer" || schemaNode.type === "number"; + const supportsLengthConstraints = schemaNode.type === "string"; + const supportsPatternConstraints = schemaNode.type === "string"; + + if (supportsNumericConstraints && + typeof schemaNode.minimum === "number" && + Number(scalarValue) < schemaNode.minimum) { + return false; + } + + if (supportsNumericConstraints && + typeof schemaNode.exclusiveMinimum === "number" && + Number(scalarValue) <= schemaNode.exclusiveMinimum) { + return false; + } + + if (supportsNumericConstraints && + typeof schemaNode.maximum === "number" && + Number(scalarValue) > schemaNode.maximum) { + return false; + } + + if (supportsNumericConstraints && + typeof schemaNode.exclusiveMaximum === "number" && + Number(scalarValue) >= schemaNode.exclusiveMaximum) { + return false; + } + + if (supportsNumericConstraints && + !matchesSchemaMultipleOf(scalarValue, schemaNode.multipleOf)) { + return false; + } + + if (supportsLengthConstraints && + typeof schemaNode.minLength === "number" && + scalarValue.length < schemaNode.minLength) { + return false; + } + + if (supportsLengthConstraints && + typeof schemaNode.maxLength === "number" && + scalarValue.length > schemaNode.maxLength) { + return false; + } + + if (supportsPatternConstraints && + !matchesSchemaPattern(scalarValue, schemaNode.patternRegex)) { + return false; + } + + return typeof schemaNode.constComparableValue !== "string" || + buildComparableNodeValue(schemaNode, yamlNode) === schemaNode.constComparableValue; +} + +/** + * Test whether one YAML node is structurally compatible with one schema node. + * This keeps array-level `contains` validation from producing noisy follow-on + * diagnostics when an item already has a shape or scalar-type mismatch, while + * still allowing value-level constraint failures to participate in contains counting. + * + * @param {SchemaNode} schemaNode Schema node. + * @param {YamlNode} yamlNode YAML node. + * @returns {boolean} True when the YAML node has the expected recursive shape. + */ +function isStructurallyCompatibleWithSchemaNode(schemaNode, yamlNode) { + if (schemaNode.type === "object") { + if (!yamlNode || yamlNode.kind !== "object") { + return false; + } + + for (const requiredProperty of schemaNode.required) { + if (!yamlNode.map.has(requiredProperty)) { + return false; + } + } + + for (const entry of yamlNode.entries) { + if (!Object.prototype.hasOwnProperty.call(schemaNode.properties, entry.key)) { + return false; + } + + if (!isStructurallyCompatibleWithSchemaNode(schemaNode.properties[entry.key], entry.node)) { + return false; + } + } + + return true; + } + + if (schemaNode.type === "array") { + if (!yamlNode || yamlNode.kind !== "array") { + return false; + } + + for (const item of yamlNode.items) { + if (!isStructurallyCompatibleWithSchemaNode(schemaNode.items, item)) { + return false; + } + } + + return true; + } + + return Boolean(yamlNode) && + yamlNode.kind === "scalar" && + isScalarCompatible(schemaNode.type, yamlNode.value); +} + /** * Validate one parsed YAML node against one normalized const comparable value. * The helper reuses the same comparable-key logic as uniqueItems so array order @@ -1390,6 +1704,8 @@ function localizeValidationMessage(key, localizer, params) { return `属性“${params.displayPath}”必须大于 ${params.value}。`; case ValidationMessageKeys.maximumViolation: return `属性“${params.displayPath}”必须小于或等于 ${params.value}。`; + case ValidationMessageKeys.maxContainsViolation: + return `属性“${params.displayPath}”最多只能包含 ${params.value} 个匹配 contains 条件的元素。`; case ValidationMessageKeys.maxItemsViolation: return `属性“${params.displayPath}”最多只能包含 ${params.value} 个元素。`; case ValidationMessageKeys.maxLengthViolation: @@ -1398,6 +1714,8 @@ function localizeValidationMessage(key, localizer, params) { return `属性“${params.displayPath}”必须大于或等于 ${params.value}。`; case ValidationMessageKeys.multipleOfViolation: return `属性“${params.displayPath}”必须是 ${params.value} 的整数倍。`; + case ValidationMessageKeys.minContainsViolation: + return `属性“${params.displayPath}”至少需要包含 ${params.value} 个匹配 contains 条件的元素。`; case ValidationMessageKeys.minItemsViolation: return `属性“${params.displayPath}”至少需要包含 ${params.value} 个元素。`; case ValidationMessageKeys.minLengthViolation: @@ -1432,6 +1750,8 @@ function localizeValidationMessage(key, localizer, params) { return `Property '${params.displayPath}' must be greater than ${params.value}.`; case ValidationMessageKeys.maximumViolation: return `Property '${params.displayPath}' must be less than or equal to ${params.value}.`; + case ValidationMessageKeys.maxContainsViolation: + return `Property '${params.displayPath}' must contain at most ${params.value} items matching the 'contains' schema.`; case ValidationMessageKeys.maxItemsViolation: return `Property '${params.displayPath}' must contain at most ${params.value} items.`; case ValidationMessageKeys.maxLengthViolation: @@ -1440,6 +1760,8 @@ function localizeValidationMessage(key, localizer, params) { return `Property '${params.displayPath}' must be greater than or equal to ${params.value}.`; case ValidationMessageKeys.multipleOfViolation: return `Property '${params.displayPath}' must be a multiple of ${params.value}.`; + case ValidationMessageKeys.minContainsViolation: + return `Property '${params.displayPath}' must contain at least ${params.value} items matching the 'contains' schema.`; case ValidationMessageKeys.minItemsViolation: return `Property '${params.displayPath}' must contain at least ${params.value} items.`; case ValidationMessageKeys.minLengthViolation: @@ -2167,8 +2489,11 @@ module.exports = { * constComparableValue?: string, * minItems?: number, * maxItems?: number, + * minContains?: number, + * maxContains?: number, * uniqueItems?: boolean, * refTable?: string, + * contains?: SchemaNode, * items: SchemaNode * } | { * type: "string" | "integer" | "number" | "boolean", diff --git a/tools/gframework-config-tool/src/containsSummary.js b/tools/gframework-config-tool/src/containsSummary.js new file mode 100644 index 00000000..a6fdbbe4 --- /dev/null +++ b/tools/gframework-config-tool/src/containsSummary.js @@ -0,0 +1,67 @@ +/** + * Build a compact contains-schema summary for array field hints. + * The summary reuses existing localized hint strings so Chinese UI surfaces + * do not fall back to mixed English tokens such as const/enum/pattern/ref. + * + * @param {{type?: string, enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, refTable?: string}} containsSchema Parsed contains schema metadata. + * @param {{t: (key: string, params?: Record) => string}} localizer Runtime localizer. + * @returns {string} Human-facing summary. + */ +function describeContainsSchema(containsSchema, localizer) { + const parts = []; + if (containsSchema.type) { + parts.push(containsSchema.type); + } + + if (containsSchema.constValue !== undefined) { + parts.push(localizer.t("webview.hint.const", { + value: containsSchema.constDisplayValue ?? containsSchema.constValue + })); + } else if (Array.isArray(containsSchema.enumValues) && containsSchema.enumValues.length > 0) { + parts.push(localizer.t("webview.hint.allowed", { + values: containsSchema.enumValues.join(", ") + })); + } else if (containsSchema.pattern) { + parts.push(localizer.t("webview.hint.pattern", { + value: containsSchema.pattern + })); + } + + if (containsSchema.refTable) { + parts.push(localizer.t("webview.hint.refTable", { + refTable: containsSchema.refTable + })); + } + + return parts.join(", ") || localizer.t("webview.objectArray.item"); +} + +/** + * Build localized contains-related hint lines for array fields. + * + * @param {{contains?: {type?: string, enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, refTable?: string}, minContains?: number}} propertySchema Array property schema metadata. + * @param {{t: (key: string, params?: Record) => string}} localizer Runtime localizer. + * @returns {string[]} Localized contains hint lines. + */ +function buildContainsHintLines(propertySchema, localizer) { + if (!propertySchema.contains) { + return []; + } + + const effectiveMinContains = typeof propertySchema.minContains === "number" + ? propertySchema.minContains + : 1; + return [ + localizer.t("webview.hint.contains", { + summary: describeContainsSchema(propertySchema.contains, localizer) + }), + localizer.t("webview.hint.minContains", { + value: effectiveMinContains + }) + ]; +} + +module.exports = { + describeContainsSchema, + buildContainsHintLines +}; diff --git a/tools/gframework-config-tool/src/extension.js b/tools/gframework-config-tool/src/extension.js index dbd71598..e263612d 100644 --- a/tools/gframework-config-tool/src/extension.js +++ b/tools/gframework-config-tool/src/extension.js @@ -18,6 +18,7 @@ const { joinArrayTemplatePath, joinPropertyPath } = require("./configPath"); +const {buildContainsHintLines} = require("./containsSummary"); const {createLocalizer} = require("./localization"); const localizer = createLocalizer(vscode.env.language); @@ -1577,7 +1578,7 @@ function getScalarArrayValue(yamlNode) { /** * 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, minItems?: number, maxItems?: number, minProperties?: number, maxProperties?: number, uniqueItems?: boolean, enumValues?: string[], items?: {enumValues?: string[], constValue?: string, constDisplayValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string}, refTable?: string}} propertySchema Property schema metadata. + * @param {{type?: string, description?: string, defaultValue?: string, constValue?: string, constDisplayValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string, minItems?: number, maxItems?: number, minContains?: number, maxContains?: number, minProperties?: number, maxProperties?: number, uniqueItems?: boolean, enumValues?: string[], contains?: {type?: string, enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: 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}, 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. @@ -1656,6 +1657,17 @@ function renderFieldHint(propertySchema, isArrayField, includeDescription = true hints.push(escapeHtml(localizer.t("webview.hint.maxItems", {value: propertySchema.maxItems}))); } + if (isArrayField && propertySchema.contains) { + const containsHints = buildContainsHintLines(propertySchema, localizer); + for (const containsHint of containsHints) { + hints.push(escapeHtml(containsHint)); + } + } + + if (isArrayField && typeof propertySchema.maxContains === "number") { + hints.push(escapeHtml(localizer.t("webview.hint.maxContains", {value: propertySchema.maxContains}))); + } + if (isArrayField && propertySchema.uniqueItems === true) { hints.push(escapeHtml(localizer.t("webview.hint.uniqueItems"))); } diff --git a/tools/gframework-config-tool/src/localization.js b/tools/gframework-config-tool/src/localization.js index 7078c680..5213e6db 100644 --- a/tools/gframework-config-tool/src/localization.js +++ b/tools/gframework-config-tool/src/localization.js @@ -116,6 +116,9 @@ const enMessages = { "webview.hint.pattern": "Pattern: {value}", "webview.hint.minItems": "Min items: {value}", "webview.hint.maxItems": "Max items: {value}", + "webview.hint.contains": "Contains: {summary}", + "webview.hint.minContains": "Min contains: {value}", + "webview.hint.maxContains": "Max contains: {value}", "webview.hint.uniqueItems": "Items must be unique", "webview.hint.itemMinimum": "Item minimum: {value}", "webview.hint.itemConst": "Item const: {value}", @@ -137,11 +140,13 @@ const enMessages = { [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}.", + [ValidationMessageKeys.maxContainsViolation]: "Property '{displayPath}' must contain at most {value} items matching the 'contains' schema.", [ValidationMessageKeys.maxItemsViolation]: "Property '{displayPath}' must contain at most {value} items.", [ValidationMessageKeys.maxLengthViolation]: "Property '{displayPath}' must be at most {value} characters long.", [ValidationMessageKeys.maxPropertiesViolation]: "Property '{displayPath}' must contain at most {value} properties.", [ValidationMessageKeys.minimumViolation]: "Property '{displayPath}' must be greater than or equal to {value}.", [ValidationMessageKeys.multipleOfViolation]: "Property '{displayPath}' must be a multiple of {value}.", + [ValidationMessageKeys.minContainsViolation]: "Property '{displayPath}' must contain at least {value} items matching the 'contains' schema.", [ValidationMessageKeys.minItemsViolation]: "Property '{displayPath}' must contain at least {value} items.", [ValidationMessageKeys.minLengthViolation]: "Property '{displayPath}' must be at least {value} characters long.", [ValidationMessageKeys.minPropertiesViolation]: "Property '{displayPath}' must contain at least {value} properties.", @@ -226,6 +231,9 @@ const zhCnMessages = { "webview.hint.pattern": "正则模式:{value}", "webview.hint.minItems": "最少元素数:{value}", "webview.hint.maxItems": "最多元素数:{value}", + "webview.hint.contains": "Contains 约束:{summary}", + "webview.hint.minContains": "最少 contains 匹配数:{value}", + "webview.hint.maxContains": "最多 contains 匹配数:{value}", "webview.hint.uniqueItems": "元素必须唯一", "webview.hint.itemMinimum": "元素最小值:{value}", "webview.hint.itemConst": "元素固定值:{value}", @@ -247,11 +255,13 @@ const zhCnMessages = { [ValidationMessageKeys.exclusiveMaximumViolation]: "属性“{displayPath}”必须小于 {value}。", [ValidationMessageKeys.exclusiveMinimumViolation]: "属性“{displayPath}”必须大于 {value}。", [ValidationMessageKeys.maximumViolation]: "属性“{displayPath}”必须小于或等于 {value}。", + [ValidationMessageKeys.maxContainsViolation]: "属性“{displayPath}”最多只能包含 {value} 个匹配 contains 条件的元素。", [ValidationMessageKeys.maxItemsViolation]: "属性“{displayPath}”最多只能包含 {value} 个元素。", [ValidationMessageKeys.maxLengthViolation]: "属性“{displayPath}”长度必须不超过 {value} 个字符。", [ValidationMessageKeys.maxPropertiesViolation]: "对象属性“{displayPath}”最多只能包含 {value} 个子属性。", [ValidationMessageKeys.minimumViolation]: "属性“{displayPath}”必须大于或等于 {value}。", [ValidationMessageKeys.multipleOfViolation]: "属性“{displayPath}”必须是 {value} 的整数倍。", + [ValidationMessageKeys.minContainsViolation]: "属性“{displayPath}”至少需要包含 {value} 个匹配 contains 条件的元素。", [ValidationMessageKeys.minItemsViolation]: "属性“{displayPath}”至少需要包含 {value} 个元素。", [ValidationMessageKeys.minLengthViolation]: "属性“{displayPath}”长度必须至少为 {value} 个字符。", [ValidationMessageKeys.minPropertiesViolation]: "对象属性“{displayPath}”至少需要包含 {value} 个子属性。", diff --git a/tools/gframework-config-tool/src/localizationKeys.js b/tools/gframework-config-tool/src/localizationKeys.js index 6de5f150..68df294c 100644 --- a/tools/gframework-config-tool/src/localizationKeys.js +++ b/tools/gframework-config-tool/src/localizationKeys.js @@ -8,11 +8,13 @@ const ValidationMessageKeys = Object.freeze({ expectedScalarShape: "validation.expectedScalarShape", expectedScalarValue: "validation.expectedScalarValue", maximumViolation: "validation.maximumViolation", + maxContainsViolation: "validation.maxContainsViolation", maxItemsViolation: "validation.maxItemsViolation", maxLengthViolation: "validation.maxLengthViolation", maxPropertiesViolation: "validation.maxPropertiesViolation", minimumViolation: "validation.minimumViolation", multipleOfViolation: "validation.multipleOfViolation", + minContainsViolation: "validation.minContainsViolation", minItemsViolation: "validation.minItemsViolation", minLengthViolation: "validation.minLengthViolation", minPropertiesViolation: "validation.minPropertiesViolation", diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index e21abf8a..92ff0b1e 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -799,6 +799,269 @@ phases: assert.match(diagnostics[1].message, /phases\[1\]|uniqueItems|元素唯一/u); }); +test("validateParsedConfig should report contains match-count violations", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "dropRates": { + "type": "array", + "minContains": 2, + "contains": { + "type": "integer", + "const": 5 + }, + "items": { + "type": "integer" + } + } + } + } + `); + const yaml = parseTopLevelYaml(` +dropRates: + - 5 + - 7 + - 9 +`); + + const diagnostics = validateParsedConfig(schema, yaml); + + assert.equal(diagnostics.length, 1); + assert.match(diagnostics[0].message, /at least 2 items matching the 'contains' schema|至少需要包含 2 个匹配 contains 条件的元素/u); +}); + +test("validateParsedConfig should skip contains match-count when items are structurally invalid", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "required": ["dropRates"], + "properties": { + "dropRates": { + "type": "array", + "minContains": 2, + "contains": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "const": "RARE" + } + } + }, + "items": { + "type": "object", + "required": ["type", "value"], + "properties": { + "type": { + "type": "string" + }, + "value": { + "type": "integer" + } + } + } + } + } + } + `); + const yaml = parseTopLevelYaml(` +dropRates: + - + type: RARE + value: "not-a-number" + - + type: RARE + value: 10 +`); + + const diagnostics = validateParsedConfig(schema, yaml); + + assert.ok(diagnostics.length > 0); + assert.match( + diagnostics[0].message, + /dropRates\[0\]\.value/u); + assert.match( + diagnostics[0].message, + /integer|整数/u); + assert.equal( + diagnostics.some((diagnostic) => /at least 2 items matching the 'contains' schema|至少需要包含 2 个匹配 contains 条件的元素/u.test(diagnostic.message)), + false); + assert.equal( + diagnostics.some((diagnostic) => /at most \d+ items matching the 'contains' schema|最多只能包含 \d+ 个匹配 contains 条件的元素/u.test(diagnostic.message)), + false); +}); + +test("validateParsedConfig should continue contains match-count when items only have value-level violations", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "dropRates": { + "type": "array", + "minContains": 1, + "contains": { + "type": "integer", + "const": 7 + }, + "items": { + "type": "integer", + "minimum": 10 + } + } + } + } + `); + const yaml = parseTopLevelYaml(` +dropRates: + - 5 +`); + + const diagnostics = validateParsedConfig(schema, yaml); + + assert.equal(diagnostics.length, 2); + assert.match(diagnostics[0].message, /greater than or equal to 10|大于或等于 10/u); + assert.match(diagnostics[1].message, /at least 1 items matching the 'contains' schema|至少需要包含 1 个匹配 contains 条件的元素/u); +}); + +test("validateParsedConfig should report maxContains violations", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "dropRates": { + "type": "array", + "maxContains": 1, + "contains": { + "type": "integer", + "const": 5 + }, + "items": { + "type": "integer" + } + } + } + } + `); + const yaml = parseTopLevelYaml(` +dropRates: + - 5 + - 5 + - 7 +`); + + const diagnostics = validateParsedConfig(schema, yaml); + + assert.equal(diagnostics.length, 1); + assert.match(diagnostics[0].message, /at most 1 items matching the 'contains' schema|最多只能包含 1 个匹配 contains 条件的元素/u); +}); + +test("validateParsedConfig should accept satisfied contains constraints", () => { + const schemaWithRange = parseSchemaContent(` + { + "type": "object", + "properties": { + "dropRates": { + "type": "array", + "minContains": 2, + "maxContains": 3, + "contains": { + "type": "integer", + "const": 5 + }, + "items": { + "type": "integer" + } + } + } + } + `); + const yamlWithinRange = parseTopLevelYaml(` +dropRates: + - 0 + - 5 + - 5 + - 10 +`); + + assert.deepEqual(validateParsedConfig(schemaWithRange, yamlWithinRange), []); + + const schemaWithDefaultMinContains = parseSchemaContent(` + { + "type": "object", + "properties": { + "dropRates": { + "type": "array", + "contains": { + "type": "integer", + "const": 5 + }, + "items": { + "type": "integer" + } + } + } + } + `); + const yamlSatisfyingDefaultMinContains = parseTopLevelYaml(` +dropRates: + - 1 + - 2 + - 5 + - 3 +`); + + assert.deepEqual(validateParsedConfig(schemaWithDefaultMinContains, yamlSatisfyingDefaultMinContains), []); +}); + +test("validateParsedConfig should allow object contains matches with additional declared item fields", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "entries": { + "type": "array", + "minContains": 1, + "contains": { + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "string", + "const": "boss" + } + } + }, + "items": { + "type": "object", + "required": ["id", "weight"], + "properties": { + "id": { + "type": "string" + }, + "weight": { + "type": "integer" + } + } + } + } + } + } + `); + const yaml = parseTopLevelYaml(` +entries: + - + id: boss + weight: 10 + - + id: slime + weight: 3 +`); + + assert.deepEqual(validateParsedConfig(schema, yaml), []); +}); + test("validateParsedConfig should accept large decimal multiples without floating-point drift", () => { const schema = parseSchemaContent(` { @@ -1065,6 +1328,140 @@ test("parseSchemaContent should capture multipleOf and uniqueItems metadata", () assert.equal(schema.properties.dropRates.items.multipleOf, 0.5); }); +test("parseSchemaContent should capture contains metadata", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "dropRates": { + "type": "array", + "minContains": 1, + "maxContains": 2, + "contains": { + "type": "integer", + "const": 5 + }, + "items": { + "type": "integer" + } + } + } + } + `); + + assert.equal(schema.properties.dropRates.minContains, 1); + assert.equal(schema.properties.dropRates.maxContains, 2); + assert.equal(schema.properties.dropRates.contains.type, "integer"); + assert.equal(schema.properties.dropRates.contains.constDisplayValue, "5"); +}); + +test("parseSchemaContent should reject nested-array contains schemas", () => { + assert.throws( + () => parseSchemaContent(` + { + "type": "object", + "properties": { + "dropRates": { + "type": "array", + "contains": { + "type": "array", + "items": { + "type": "integer" + } + }, + "items": { + "type": "integer" + } + } + } + } + `), + /unsupported nested array 'contains' schemas/u); +}); + +test("parseSchemaContent should reject minContains and maxContains without contains", () => { + assert.throws( + () => parseSchemaContent(` + { + "type": "object", + "properties": { + "dropRates": { + "type": "array", + "minContains": 1, + "items": { + "type": "integer" + } + } + } + } + `), + /'minContains' or 'maxContains' without 'contains'/u); + + assert.throws( + () => parseSchemaContent(` + { + "type": "object", + "properties": { + "dropRates": { + "type": "array", + "maxContains": 1, + "items": { + "type": "integer" + } + } + } + } + `), + /'minContains' or 'maxContains' without 'contains'/u); +}); + +test("parseSchemaContent should reject contains schemas where default minContains exceeds maxContains", () => { + assert.throws( + () => parseSchemaContent(` + { + "type": "object", + "properties": { + "dropRates": { + "type": "array", + "maxContains": 0, + "contains": { + "type": "integer", + "const": 5 + }, + "items": { + "type": "integer" + } + } + } + } + `), + /'minContains' greater than 'maxContains'/u); +}); + +test("parseSchemaContent should reject contains schemas where minContains is greater than maxContains", () => { + assert.throws( + () => parseSchemaContent(` + { + "type": "object", + "properties": { + "dropRates": { + "type": "array", + "minContains": 3, + "maxContains": 1, + "contains": { + "type": "integer", + "const": 5 + }, + "items": { + "type": "integer" + } + } + } + } + `), + /'minContains' greater than 'maxContains'/u); +}); + test("parseSchemaContent should capture object property-count metadata", () => { const schema = parseSchemaContent(` { diff --git a/tools/gframework-config-tool/test/containsSummary.test.js b/tools/gframework-config-tool/test/containsSummary.test.js new file mode 100644 index 00000000..8d29d7f0 --- /dev/null +++ b/tools/gframework-config-tool/test/containsSummary.test.js @@ -0,0 +1,95 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const {buildContainsHintLines, describeContainsSchema} = require("../src/containsSummary"); +const {createLocalizer} = require("../src/localization"); + +test("describeContainsSchema should reuse localized Chinese hint strings", () => { + const localizer = createLocalizer("zh-cn"); + + const summary = describeContainsSchema( + { + type: "string", + constValue: "\"potion\"", + constDisplayValue: "\"potion\"", + refTable: "item" + }, + localizer); + + assert.equal(summary, "string, 固定值:\"potion\", 引用表:item"); +}); + +test("describeContainsSchema should fall back to localized item label", () => { + const localizer = createLocalizer("en"); + + const summary = describeContainsSchema({}, localizer); + + assert.equal(summary, "Item"); +}); + +test("buildContainsHintLines should include default minContains when schema omits it", () => { + const localizer = createLocalizer("en"); + + const lines = buildContainsHintLines( + { + contains: { + type: "integer", + constValue: "5", + constDisplayValue: "5" + } + }, + localizer); + + assert.deepEqual(lines, [ + "Contains: integer, Const: 5", + "Min contains: 1" + ]); +}); + +test("buildContainsHintLines should use explicit minContains when provided", () => { + const localizer = createLocalizer("en"); + + const lines = buildContainsHintLines( + { + minContains: 2, + contains: { + type: "string", + constValue: "\"potion\"", + constDisplayValue: "\"potion\"", + refTable: "item" + } + }, + localizer); + + assert.deepEqual(lines, [ + "Contains: string, Const: \"potion\", Ref table: item", + "Min contains: 2" + ]); +}); + +test("describeContainsSchema should format enum-based contains schema in English", () => { + const localizer = createLocalizer("en"); + + const summary = describeContainsSchema( + { + type: "string", + enumValues: ["potion", "elixir"], + refTable: "item" + }, + localizer); + + assert.equal(summary, "string, Allowed: potion, elixir, Ref table: item"); +}); + +test("describeContainsSchema should format pattern-based contains schema in Chinese", () => { + const localizer = createLocalizer("zh-cn"); + + const summary = describeContainsSchema( + { + type: "string", + pattern: "^potion-", + refTable: "item" + }, + localizer); + + assert.equal(summary, "string, 正则模式:^potion-, 引用表:item"); +}); diff --git a/tools/gframework-config-tool/test/localization.test.js b/tools/gframework-config-tool/test/localization.test.js index 2fb40ceb..6a4f1f55 100644 --- a/tools/gframework-config-tool/test/localization.test.js +++ b/tools/gframework-config-tool/test/localization.test.js @@ -57,3 +57,15 @@ test("createLocalizer should expose object property-count validation keys in Sim localizer.t(ValidationMessageKeys.maxPropertiesViolation, {displayPath: "reward", value: 3}), "对象属性“reward”最多只能包含 3 个子属性。"); }); + +test("createLocalizer should expose contains-count validation keys", () => { + const englishLocalizer = createLocalizer("en"); + const chineseLocalizer = createLocalizer("zh-cn"); + + assert.equal( + englishLocalizer.t(ValidationMessageKeys.minContainsViolation, {displayPath: "dropRates", value: 2}), + "Property 'dropRates' must contain at least 2 items matching the 'contains' schema."); + assert.equal( + chineseLocalizer.t(ValidationMessageKeys.maxContainsViolation, {displayPath: "dropRates", value: 1}), + "属性“dropRates”最多只能包含 1 个匹配 contains 条件的元素。"); +});