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 1/6] =?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 条件的元素。"); +}); From 039ef9817ac85fab94135031f36c12182908be3a Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:52:03 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat(extension):=20=E6=B7=BB=E5=8A=A0GFrame?= =?UTF-8?q?work=E9=85=8D=E7=BD=AE=E5=B7=A5=E5=85=B7=E6=89=A9=E5=B1=95?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现配置文件浏览器树视图,支持工作区配置目录导航 - 集成轻量级验证系统,支持YAML配置文件语法检查 - 添加模式感知表单预览功能,支持结构化配置编辑 - 实现批量编辑功能,支持跨多个配置文件统一修改字段值 - 集成国际化支持,提供中英文本地化界面 - 添加实时配置文件保存验证,在文件保存时自动校验 - 实现引用导航功能,支持跳转到关联配置表和文件 - 添加工作区变更响应,支持动态刷新配置树视图 --- .../Config/YamlConfigLoaderTests.cs | 350 +++++++++++++++++- .../Config/YamlConfigSchemaValidator.cs | 42 ++- .../src/containsSummary.js | 41 ++ tools/gframework-config-tool/src/extension.js | 32 +- .../test/containsSummary.test.js | 27 ++ 5 files changed, 454 insertions(+), 38 deletions(-) create mode 100644 tools/gframework-config-tool/src/containsSummary.js create mode 100644 tools/gframework-config-tool/test/containsSummary.test.js diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs index e4a8a1df..af964a0b 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -1211,6 +1211,170 @@ public class YamlConfigLoaderTests }); } + /// + /// 验证匹配数量刚好等于 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 时不能单独使用 minContains。 /// @@ -2345,6 +2509,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(); + } + } + /// /// 验证启用热重载后,配置文件内容变更会刷新已注册配置表。 /// @@ -2779,7 +3127,7 @@ public class YamlConfigLoaderTests /// /// 获取或设置掉落率列表。 /// - public IReadOnlyList DropRates { get; set; } = Array.Empty(); + public List DropRates { get; set; } = new(); } /// diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index 8b60101f..a2b76c6d 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -687,7 +687,7 @@ internal static class YamlConfigSchemaValidator } ValidateArrayUniqueItemsConstraint(tableName, yamlPath, displayPath, sequenceNode, schemaNode); - ValidateArrayContainsConstraints(tableName, yamlPath, displayPath, sequenceNode, schemaNode); + ValidateArrayContainsConstraints(tableName, yamlPath, displayPath, sequenceNode, schemaNode, references); ValidateConstantValue(tableName, yamlPath, displayPath, sequenceNode, schemaNode); } @@ -2153,12 +2153,14 @@ internal static class YamlConfigSchemaValidator /// 字段路径。 /// 实际数组节点。 /// 数组 schema 节点。 + /// 匹配成功的 contains 子树所声明的跨表引用收集器。 private static void ValidateArrayContainsConstraints( string tableName, string yamlPath, string displayPath, YamlSequenceNode sequenceNode, - YamlConfigSchemaNode schemaNode) + YamlConfigSchemaNode schemaNode, + ICollection? references) { var containsConstraints = schemaNode.ArrayConstraints?.ContainsConstraints; if (containsConstraints is null) @@ -2171,7 +2173,8 @@ internal static class YamlConfigSchemaValidator yamlPath, displayPath, sequenceNode, - containsConstraints.ContainsNode); + containsConstraints.ContainsNode, + references); var rawValue = matchingCount.ToString(CultureInfo.InvariantCulture); var requiredMinContains = containsConstraints.MinContains ?? 1; if (matchingCount < requiredMinContains) @@ -2211,13 +2214,15 @@ internal static class YamlConfigSchemaValidator /// 数组字段路径。 /// 实际数组节点。 /// contains 子 schema。 + /// 匹配成功元素的可选跨表引用收集器。 /// 匹配 contains 子 schema 的元素数量。 private static int CountMatchingContainsItems( string tableName, string yamlPath, string displayPath, YamlSequenceNode sequenceNode, - YamlConfigSchemaNode containsNode) + YamlConfigSchemaNode containsNode, + ICollection? references) { var matchingCount = 0; for (var itemIndex = 0; itemIndex < sequenceNode.Children.Count; itemIndex++) @@ -2227,7 +2232,8 @@ internal static class YamlConfigSchemaValidator yamlPath, $"{displayPath}[{itemIndex}]", sequenceNode.Children[itemIndex], - containsNode)) + containsNode, + references)) { matchingCount++; } @@ -2245,17 +2251,33 @@ internal static class YamlConfigSchemaValidator /// 当前数组元素路径。 /// 实际 YAML 元素。 /// contains 子 schema。 + /// 当前元素匹配成功后要写回的可选跨表引用收集器。 /// 当前元素是否匹配 contains 子 schema。 private static bool IsArrayItemMatchingContains( string tableName, string yamlPath, string displayPath, YamlNode itemNode, - YamlConfigSchemaNode containsNode) + YamlConfigSchemaNode containsNode, + ICollection? references) { + // contains 的“试匹配”不能把失败元素的引用泄漏给外层,但匹配成功的元素仍需要参与 + // 跨表引用收集,否则仅声明在 contains 子 schema 里的 ref-table 会被运行时遗漏。 + List? matchedReferences = references is null ? null : new(); + try { - ValidateNode(tableName, yamlPath, displayPath, itemNode, containsNode, references: null); + ValidateNode(tableName, yamlPath, displayPath, itemNode, containsNode, matchedReferences); + + 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) @@ -2627,6 +2649,12 @@ internal static class YamlConfigSchemaValidator { CollectReferencedTableNames(node.ItemNode, referencedTableNames); } + + var containsNode = node.ArrayConstraints?.ContainsConstraints?.ContainsNode; + if (containsNode is not null) + { + CollectReferencedTableNames(containsNode, referencedTableNames); + } } /// diff --git a/tools/gframework-config-tool/src/containsSummary.js b/tools/gframework-config-tool/src/containsSummary.js new file mode 100644 index 00000000..7b28c193 --- /dev/null +++ b/tools/gframework-config-tool/src/containsSummary.js @@ -0,0 +1,41 @@ +/** + * 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"); +} + +module.exports = { + describeContainsSchema +}; diff --git a/tools/gframework-config-tool/src/extension.js b/tools/gframework-config-tool/src/extension.js index 75c758ef..f07b6c3f 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 {describeContainsSchema} = require("./containsSummary"); const {createLocalizer} = require("./localization"); const localizer = createLocalizer(vscode.env.language); @@ -1658,7 +1659,7 @@ function renderFieldHint(propertySchema, isArrayField, includeDescription = true if (isArrayField && propertySchema.contains) { hints.push(escapeHtml(localizer.t("webview.hint.contains", { - summary: describeContainsSchema(propertySchema.contains) + summary: describeContainsSchema(propertySchema.contains, localizer) }))); } @@ -1723,35 +1724,6 @@ 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/test/containsSummary.test.js b/tools/gframework-config-tool/test/containsSummary.test.js new file mode 100644 index 00000000..4ad19c24 --- /dev/null +++ b/tools/gframework-config-tool/test/containsSummary.test.js @@ -0,0 +1,27 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const {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"); +}); From 925af56b1cff9fa0ce2e49c9a709252513bcff42 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Fri, 10 Apr 2026 19:58:42 +0800 Subject: [PATCH 3/6] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E9=AA=8C=E8=AF=81=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现配置模式解析器,支持对象、数组和标量类型的递归验证 - 添加 YAML 解析和注释提取功能,支持嵌套对象和数组结构 - 实现配置验证诊断,提供详细的错误和警告信息 - 添加表单更新应用功能,支持标量值和数组的批量编辑 - 实现配置示例生成功能,包含描述信息作为 YAML 注释 - 添加数值约束验证,包括最小值、最大值、倍数和长度限制 - 实现枚举值和模式匹配验证,确保数据符合预定义规则 - 添加常量值比较功能,支持对象和数组类型的深度比较 --- .../Config/YamlConfigLoaderTests.cs | 109 ++++++++++++++++++ .../Config/YamlConfigSchemaValidator.cs | 64 ++++++++-- .../src/configValidation.js | 13 +++ .../src/containsSummary.js | 28 ++++- tools/gframework-config-tool/src/extension.js | 13 +-- .../test/configValidation.test.js | 71 ++++++++++++ .../test/containsSummary.test.js | 21 +++- 7 files changed, 301 insertions(+), 18 deletions(-) diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs index af964a0b..1d0c4a10 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -1375,6 +1375,78 @@ public class YamlConfigLoaderTests }); } + /// + /// 验证对象数组的 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。 /// @@ -3204,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 a2b76c6d..8c588e56 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -436,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: @@ -482,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) { @@ -534,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, @@ -543,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) @@ -640,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) { @@ -683,7 +723,8 @@ internal static class YamlConfigSchemaValidator $"{displayPath}[{itemIndex}]", sequenceNode.Children[itemIndex], schemaNode.ItemNode, - references); + references, + allowUnknownObjectProperties); } ValidateArrayUniqueItemsConstraint(tableName, yamlPath, displayPath, sequenceNode, schemaNode); @@ -2267,7 +2308,14 @@ internal static class YamlConfigSchemaValidator try { - ValidateNode(tableName, yamlPath, displayPath, itemNode, containsNode, matchedReferences); + ValidateNode( + tableName, + yamlPath, + displayPath, + itemNode, + containsNode, + matchedReferences, + allowUnknownObjectProperties: true); if (references is not null && matchedReferences is not null) diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js index e034ddb8..8dcb5de6 100644 --- a/tools/gframework-config-tool/src/configValidation.js +++ b/tools/gframework-config-tool/src/configValidation.js @@ -897,6 +897,19 @@ function parseSchemaNode(rawNode, displayPath) { const containsNode = value.contains && typeof value.contains === "object" ? parseSchemaNode(value.contains, joinArrayTemplatePath(displayPath)) : undefined; + 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, diff --git a/tools/gframework-config-tool/src/containsSummary.js b/tools/gframework-config-tool/src/containsSummary.js index 7b28c193..a6fdbbe4 100644 --- a/tools/gframework-config-tool/src/containsSummary.js +++ b/tools/gframework-config-tool/src/containsSummary.js @@ -36,6 +36,32 @@ function describeContainsSchema(containsSchema, localizer) { 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 + describeContainsSchema, + buildContainsHintLines }; diff --git a/tools/gframework-config-tool/src/extension.js b/tools/gframework-config-tool/src/extension.js index f07b6c3f..e263612d 100644 --- a/tools/gframework-config-tool/src/extension.js +++ b/tools/gframework-config-tool/src/extension.js @@ -18,7 +18,7 @@ const { joinArrayTemplatePath, joinPropertyPath } = require("./configPath"); -const {describeContainsSchema} = require("./containsSummary"); +const {buildContainsHintLines} = require("./containsSummary"); const {createLocalizer} = require("./localization"); const localizer = createLocalizer(vscode.env.language); @@ -1658,13 +1658,10 @@ function renderFieldHint(propertySchema, isArrayField, includeDescription = true } if (isArrayField && propertySchema.contains) { - hints.push(escapeHtml(localizer.t("webview.hint.contains", { - summary: describeContainsSchema(propertySchema.contains, localizer) - }))); - } - - if (isArrayField && typeof propertySchema.minContains === "number") { - hints.push(escapeHtml(localizer.t("webview.hint.minContains", {value: propertySchema.minContains}))); + const containsHints = buildContainsHintLines(propertySchema, localizer); + for (const containsHint of containsHints) { + hints.push(escapeHtml(containsHint)); + } } if (isArrayField && typeof propertySchema.maxContains === "number") { diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index 812b45f4..3d3ba6e4 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -1156,6 +1156,77 @@ test("parseSchemaContent should capture contains metadata", () => { 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 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 index 4ad19c24..5d10e12e 100644 --- a/tools/gframework-config-tool/test/containsSummary.test.js +++ b/tools/gframework-config-tool/test/containsSummary.test.js @@ -1,6 +1,6 @@ const test = require("node:test"); const assert = require("node:assert/strict"); -const {describeContainsSchema} = require("../src/containsSummary"); +const {buildContainsHintLines, describeContainsSchema} = require("../src/containsSummary"); const {createLocalizer} = require("../src/localization"); test("describeContainsSchema should reuse localized Chinese hint strings", () => { @@ -25,3 +25,22 @@ test("describeContainsSchema should fall back to localized item label", () => { 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" + ]); +}); From dca304afeb46039f5126682cd665fe55aa5df8ea Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Fri, 10 Apr 2026 20:09:12 +0800 Subject: [PATCH 4/6] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E9=AA=8C=E8=AF=81=E5=8A=9F=E8=83=BD=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现配置架构解析器,支持对象、数组和标量类型的递归验证 - 添加YAML文档解析功能,包括注释提取和路径映射 - 集成配置验证诊断系统,支持多种数据类型约束检查 - 实现批量编辑器的可编辑字段收集功能 - 添加表单更新应用逻辑,支持标量和数组值的安全更新 - 集成数值约束验证,包括最小值、最大值和倍数检查 - 实现字符串长度和正则表达式模式验证 - 添加枚举值匹配和唯一性约束检查 - 实现配置架构注释提取和样本YAML生成功能 - 支持配置架构默认值和常量值处理 --- .../src/configValidation.js | 5 + .../test/configValidation.test.js | 94 +++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js index 8dcb5de6..59c1f3cb 100644 --- a/tools/gframework-config-tool/src/configValidation.js +++ b/tools/gframework-config-tool/src/configValidation.js @@ -897,6 +897,11 @@ function parseSchemaNode(rawNode, 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.`); } diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index 3d3ba6e4..881da545 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -863,6 +863,64 @@ dropRates: 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 accept large decimal multiples without floating-point drift", () => { const schema = parseSchemaContent(` { @@ -1180,6 +1238,42 @@ test("parseSchemaContent should reject nested-array contains schemas", () => { /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(` From b0e8b6ecc5a80ac8cfce1936b93fdbc10863710a Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Fri, 10 Apr 2026 20:21:47 +0800 Subject: [PATCH 5/6] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E9=AA=8C=E8=AF=81=E5=8A=9F=E8=83=BD=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现配置模式解析器,支持对象、数组和标量类型的递归验证 - 添加 YAML 配置文件解析和注释提取功能 - 实现配置值的类型兼容性检查和约束验证 - 添加批量编辑器字段收集和表单更新应用功能 - 实现配置样本生成和多语言本地化支持 - 添加精确十进制算术用于数值约束验证 - 实现配置枚举值和默认值的标准化处理 - 添加配置常量值的可比较键构建功能 --- .../src/configValidation.js | 184 +++++++++++++++++- .../test/configValidation.test.js | 47 +++++ 2 files changed, 226 insertions(+), 5 deletions(-) diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js index 59c1f3cb..94e96d72 100644 --- a/tools/gframework-config-tool/src/configValidation.js +++ b/tools/gframework-config-tool/src/configValidation.js @@ -1319,17 +1319,191 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca /** * 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. + * 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) { - const diagnostics = []; - validateNode(schemaNode, yamlNode, schemaNode.displayPath, diagnostics, undefined); - return diagnostics.length === 0; + 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; } /** diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index 881da545..38fe05af 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -921,6 +921,53 @@ dropRates: 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(` { From 19088fed03e75e6f685c4e0dd518b1c6a37abe02 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Fri, 10 Apr 2026 20:30:04 +0800 Subject: [PATCH 6/6] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E9=AA=8C=E8=AF=81=E5=8A=9F=E8=83=BD=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现配置架构解析器,支持JSON架构到递归树的转换 - 添加YAML解析器,支持根映射、嵌套对象和数组结构 - 集成配置验证诊断系统,提供架构和YAML内容校验 - 实现批量编辑器字段提取,支持标量类型安全更新 - 添加YAML注释提取功能,映射到逻辑字段路径 - 创建示例配置YAML生成功能,包含架构描述作为注释 - 实现表单更新应用到YAML功能,重写YAML树结构 - 添加标量兼容性检查,支持整数、数字、布尔值和字符串类型 - 实现精确十进制算术运算,用于multipleOf约束验证 - 添加模式匹配验证,支持正则表达式编译和测试 - 实现常量值比较功能,保持与运行时一致的比较格式 - 集成多语言本地化支持,提供中英文验证消息 --- .../src/configValidation.js | 71 ++++++++++++-- .../test/configValidation.test.js | 94 +++++++++++++++++++ .../test/containsSummary.test.js | 49 ++++++++++ 3 files changed, 208 insertions(+), 6 deletions(-) diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js index 94e96d72..77b39183 100644 --- a/tools/gframework-config-tool/src/configValidation.js +++ b/tools/gframework-config-tool/src/configValidation.js @@ -1023,7 +1023,8 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) } const comparableItems = []; - let hasInvalidArrayItems = false; + const containsCandidateItems = []; + let hasStructurallyInvalidArrayItems = false; for (let index = 0; index < yamlNode.items.length; index += 1) { const diagnosticsBeforeValidation = diagnostics.length; validateNode( @@ -1033,12 +1034,16 @@ 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]}); - } else { - hasInvalidArrayItems = true; } } @@ -1061,9 +1066,9 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) } } - if (!hasInvalidArrayItems && schemaNode.contains) { + if (!hasStructurallyInvalidArrayItems && schemaNode.contains) { let matchingContainsCount = 0; - for (const {node} of comparableItems) { + for (const {node} of containsCandidateItems) { if (matchesSchemaNode(schemaNode.contains, node)) { matchingContainsCount += 1; } @@ -1506,6 +1511,60 @@ function matchesSchemaNodeInternal(schemaNode, yamlNode) { 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 diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index 38fe05af..92ff0b1e 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -831,6 +831,100 @@ dropRates: 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(` { diff --git a/tools/gframework-config-tool/test/containsSummary.test.js b/tools/gframework-config-tool/test/containsSummary.test.js index 5d10e12e..8d29d7f0 100644 --- a/tools/gframework-config-tool/test/containsSummary.test.js +++ b/tools/gframework-config-tool/test/containsSummary.test.js @@ -44,3 +44,52 @@ test("buildContainsHintLines should include default minContains when schema omit "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"); +});