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 / 引用约束。
/// 当前共享子集额外支持 multipleOf、uniqueItems、
+/// contains / minContains / maxContains、
/// minProperties 与 maxProperties,
-/// 让数值步进、数组去重和对象属性数量规则在运行时与生成器 / 工具侧保持一致。
+/// 让数值步进、数组去重、数组匹配计数和对象属性数量规则在运行时与生成器 / 工具侧保持一致。
///
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 节点归一化为可比较字符串。
/// 该键同时服务于 uniqueItems 与 const,
@@ -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 元数据。
/// 当前共享子集也会把 multipleOf、uniqueItems、
+/// contains / minContains / maxContains、
/// minProperties 与 maxProperties 写入生成代码文档,
/// 让消费者能直接在强类型 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");
+});