From 4ff5189da41ffef774992406b7ab46355e7bbb97 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:22:40 +0800 Subject: [PATCH] =?UTF-8?q?docs(config):=20=E6=B7=BB=E5=8A=A0=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=E5=86=85=E5=AE=B9=E9=85=8D=E7=BD=AE=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E6=96=87=E6=A1=A3=E4=B8=8E=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增游戏内容配置系统详细文档,涵盖 YAML 配置、JSON Schema 结构、目录组织等 - 添加 Schema 示例和 YAML 示例,展示怪物和物品配置的具体用法 - 提供推荐接入模板,包括目录结构、csproj 配置和启动代码模板 - 添加运行时读取模板和 Architecture 接入模板,简化集成流程 - 实现配置系统运行时校验行为说明,支持多种约束验证 - 添加开发期热重载功能说明和使用方法 - 提供 VS Code 工具支持,包括配置浏览、表单编辑等功能 - 新增配置验证工具实现,支持 JSON Schema 解析和 YAML 验证 - 添加批编辑功能,支持安全更新顶层标量字段和数组 - 提供完整的 API 参考和最佳实践指南 --- .../Config/YamlConfigLoaderTests.cs | 281 ++++++++++++++++++ .../Config/YamlConfigSchemaValidator.cs | 254 +++++++++++++++- .../SchemaConfigGeneratorSnapshotTests.cs | 6 + .../SchemaConfigGenerator/MonsterConfig.g.txt | 2 +- .../Config/SchemaConfigGenerator.cs | 85 +++++- docs/zh-CN/game/config-system.md | 6 +- .../src/configValidation.js | 74 +++++ tools/gframework-config-tool/src/extension.js | 45 ++- .../src/localization.js | 10 + .../src/localizationKeys.js | 2 + .../test/configValidation.test.js | 91 ++++++ .../test/localization.test.js | 12 + 12 files changed, 857 insertions(+), 11 deletions(-) diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs index 7f115f61..e4a8a1df 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -1039,6 +1039,287 @@ 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)); + }); + } + + /// + /// 验证数组在未声明 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 的归一化键不会把带分隔符的不同对象值误判为重复项。 /// diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index 613fe827..8b60101f 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 { @@ -686,6 +687,7 @@ internal static class YamlConfigSchemaValidator } ValidateArrayUniqueItemsConstraint(tableName, yamlPath, displayPath, sequenceNode, schemaNode); + ValidateArrayContainsConstraints(tableName, yamlPath, displayPath, sequenceNode, schemaNode); ValidateConstantValue(tableName, yamlPath, displayPath, sequenceNode, schemaNode); } @@ -1152,7 +1154,7 @@ internal static class YamlConfigSchemaValidator } /// - /// 解析数组节点支持的元素数量约束。 + /// 解析数组节点支持的元素数量、去重与 contains 匹配数量约束。 /// /// 所属配置表名称。 /// Schema 文件路径。 @@ -1168,6 +1170,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 +1182,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 +2144,126 @@ internal static class YamlConfigSchemaValidator } } + /// + /// 校验数组是否满足 contains 声明的匹配数量边界。 + /// 该实现会对每个数组项复用同一套递归校验逻辑做“非抛出式匹配”,避免 contains 与主校验链各自维护不同的 schema 解释规则。 + /// + /// 所属配置表名称。 + /// YAML 文件路径。 + /// 字段路径。 + /// 实际数组节点。 + /// 数组 schema 节点。 + private static void ValidateArrayContainsConstraints( + string tableName, + string yamlPath, + string displayPath, + YamlSequenceNode sequenceNode, + YamlConfigSchemaNode schemaNode) + { + var containsConstraints = schemaNode.ArrayConstraints?.ContainsConstraints; + if (containsConstraints is null) + { + return; + } + + var matchingCount = CountMatchingContainsItems( + tableName, + yamlPath, + displayPath, + sequenceNode, + containsConstraints.ContainsNode); + 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) + { + var matchingCount = 0; + for (var itemIndex = 0; itemIndex < sequenceNode.Children.Count; itemIndex++) + { + if (IsArrayItemMatchingContains( + tableName, + yamlPath, + $"{displayPath}[{itemIndex}]", + sequenceNode.Children[itemIndex], + containsNode)) + { + 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) + { + try + { + ValidateNode(tableName, yamlPath, displayPath, itemNode, containsNode, references: null); + return true; + } + catch (ConfigLoadException exception) when (exception.Diagnostic.FailureKind != ConfigLoadFailureKind.UnexpectedFailure) + { + return false; + } + } + /// /// 将一个已通过结构校验的 YAML 节点归一化为可比较字符串。 /// 该键同时服务于 uniqueItemsconst, @@ -3100,7 +3291,7 @@ internal sealed class YamlConfigStringConstraints } /// -/// 表示一个数组节点上声明的元素数量或去重约束。 +/// 表示一个数组节点上声明的元素数量、去重与 contains 匹配计数约束。 /// 该模型与标量约束拆分保存,避免数组节点继续共享不适用的标量字段。 /// internal sealed class YamlConfigArrayConstraints @@ -3111,11 +3302,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 +3329,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..e034ddb8 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,9 @@ 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; return applyConstMetadata({ type: "array", displayPath, @@ -900,8 +905,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 +1005,7 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) } const comparableItems = []; + let hasInvalidArrayItems = false; for (let index = 0; index < yamlNode.items.length; index += 1) { const diagnosticsBeforeValidation = diagnostics.length; validateNode( @@ -1006,6 +1019,8 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) // shape/type error does not also surface as a misleading duplicate. if (diagnostics.length === diagnosticsBeforeValidation) { comparableItems.push({index, node: yamlNode.items[index]}); + } else { + hasInvalidArrayItems = true; } } @@ -1028,6 +1043,39 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) } } + if (!hasInvalidArrayItems && schemaNode.contains) { + let matchingContainsCount = 0; + for (const {node} of comparableItems) { + 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 +1299,21 @@ 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` so the tooling can reuse the same recursive validator + * while treating regular validation failures as a simple "does not match" result. + * + * @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) { + const diagnostics = []; + validateNode(schemaNode, yamlNode, schemaNode.displayPath, diagnostics, undefined); + return diagnostics.length === 0; +} + /** * 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 +1453,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 +1463,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 +1499,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 +1509,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 +2238,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/extension.js b/tools/gframework-config-tool/src/extension.js index dbd71598..75c758ef 100644 --- a/tools/gframework-config-tool/src/extension.js +++ b/tools/gframework-config-tool/src/extension.js @@ -1577,7 +1577,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 +1656,20 @@ function renderFieldHint(propertySchema, isArrayField, includeDescription = true hints.push(escapeHtml(localizer.t("webview.hint.maxItems", {value: propertySchema.maxItems}))); } + if (isArrayField && propertySchema.contains) { + hints.push(escapeHtml(localizer.t("webview.hint.contains", { + summary: describeContainsSchema(propertySchema.contains) + }))); + } + + if (isArrayField && typeof propertySchema.minContains === "number") { + hints.push(escapeHtml(localizer.t("webview.hint.minContains", {value: propertySchema.minContains}))); + } + + 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"))); } @@ -1709,6 +1723,35 @@ function renderFieldHint(propertySchema, isArrayField, includeDescription = true return `${hints.join(" · ")}`; } +/** + * Build a compact contains-schema summary for array field hints. + * The hint intentionally stays short so the form preview can expose the rule + * without inlining a second full schema tree beside the field controls. + * + * @param {{type?: string, enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, refTable?: string}} containsSchema Parsed contains schema metadata. + * @returns {string} Human-facing summary. + */ +function describeContainsSchema(containsSchema) { + const parts = []; + if (containsSchema.type) { + parts.push(containsSchema.type); + } + + if (containsSchema.constValue !== undefined) { + parts.push(`const = ${containsSchema.constDisplayValue ?? containsSchema.constValue}`); + } else if (Array.isArray(containsSchema.enumValues) && containsSchema.enumValues.length > 0) { + parts.push(`enum = ${containsSchema.enumValues.join(", ")}`); + } else if (containsSchema.pattern) { + parts.push(`pattern = ${containsSchema.pattern}`); + } + + if (containsSchema.refTable) { + parts.push(`ref = ${containsSchema.refTable}`); + } + + return parts.join(", ") || "item"; +} + /** * Prompt for one batch-edit field value. * 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..812b45f4 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -799,6 +799,70 @@ 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 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 large decimal multiples without floating-point drift", () => { const schema = parseSchemaContent(` { @@ -1065,6 +1129,33 @@ 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 capture object property-count metadata", () => { const schema = parseSchemaContent(` { 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 条件的元素。"); +});