docs(config): 添加游戏内容配置系统完整文档与验证工具

- 新增游戏内容配置系统详细文档,涵盖 YAML 配置、JSON Schema 结构、目录组织等
- 添加 Schema 示例和 YAML 示例,展示怪物和物品配置的具体用法
- 提供推荐接入模板,包括目录结构、csproj 配置和启动代码模板
- 添加运行时读取模板和 Architecture 接入模板,简化集成流程
- 实现配置系统运行时校验行为说明,支持多种约束验证
- 添加开发期热重载功能说明和使用方法
- 提供 VS Code 工具支持,包括配置浏览、表单编辑等功能
- 新增配置验证工具实现,支持 JSON Schema 解析和 YAML 验证
- 添加批编辑功能,支持安全更新顶层标量字段和数组
- 提供完整的 API 参考和最佳实践指南
This commit is contained in:
GeWuYou 2026-04-10 18:22:40 +08:00
parent dd004738b3
commit 4ff5189da4
12 changed files with 857 additions and 11 deletions

View File

@ -1039,6 +1039,287 @@ public class YamlConfigLoaderTests
});
}
/// <summary>
/// 验证数组声明 <c>contains</c> 后,默认至少要有一个匹配元素。
/// </summary>
[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<int, MonsterConfigIntegerArrayStub>("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<ConfigLoadException>(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));
});
}
/// <summary>
/// 验证数组声明 <c>minContains</c> 后,会按匹配数量而不是总元素数做约束判断。
/// </summary>
[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<int, MonsterConfigIntegerArrayStub>("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<ConfigLoadException>(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));
});
}
/// <summary>
/// 验证数组声明 <c>maxContains</c> 后,会拒绝匹配元素过多的序列。
/// </summary>
[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<int, MonsterConfigIntegerArrayStub>("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<ConfigLoadException>(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));
});
}
/// <summary>
/// 验证数组在未声明 <c>contains</c> 时不能单独使用 <c>minContains</c>。
/// </summary>
[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<int, MonsterConfigIntegerArrayStub>("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<ConfigLoadException>(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));
});
}
/// <summary>
/// 验证数组字段将 <c>minContains</c> 声明为大于 <c>maxContains</c> 时,会在 schema 解析阶段被拒绝。
/// </summary>
[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<int, MonsterConfigIntegerArrayStub>("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<ConfigLoadException>(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));
});
}
/// <summary>
/// 验证 <c>uniqueItems</c> 的归一化键不会把带分隔符的不同对象值误判为重复项。
/// </summary>

View File

@ -9,8 +9,9 @@ namespace GFramework.Game.Config;
/// 该校验器与当前配置生成器、VS Code 工具支持的 schema 子集保持一致,
/// 并通过递归遍历方式覆盖嵌套对象、对象数组、标量数组与深层 enum / 引用约束。
/// 当前共享子集额外支持 <c>multipleOf</c>、<c>uniqueItems</c>、
/// <c>contains</c> / <c>minContains</c> / <c>maxContains</c>、
/// <c>minProperties</c> 与 <c>maxProperties</c>
/// 让数值步进、数组去重和对象属性数量规则在运行时与生成器 / 工具侧保持一致。
/// 让数值步进、数组去重、数组匹配计数和对象属性数量规则在运行时与生成器 / 工具侧保持一致。
/// </summary>
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
}
/// <summary>
/// 解析数组节点支持的元素数量约束。
/// 解析数组节点支持的元素数量、去重与 <c>contains</c> 匹配数量约束。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
@ -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);
}
/// <summary>
/// 解析数组节点声明的 <c>contains</c> 约束及其匹配数量边界。
/// 运行时会把 <c>contains</c> 解析成独立的 schema 子树,后续逐项复用同一套递归校验逻辑判断“是否匹配”。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">数组字段路径。</param>
/// <param name="element">Schema 节点。</param>
/// <returns>数组 contains 约束模型;未声明时返回空。</returns>
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);
}
/// <summary>
@ -2073,6 +2144,126 @@ internal static class YamlConfigSchemaValidator
}
}
/// <summary>
/// 校验数组是否满足 <c>contains</c> 声明的匹配数量边界。
/// 该实现会对每个数组项复用同一套递归校验逻辑做“非抛出式匹配”,避免 contains 与主校验链各自维护不同的 schema 解释规则。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="yamlPath">YAML 文件路径。</param>
/// <param name="displayPath">字段路径。</param>
/// <param name="sequenceNode">实际数组节点。</param>
/// <param name="schemaNode">数组 schema 节点。</param>
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}.");
}
}
/// <summary>
/// 统计当前数组中有多少元素满足 <c>contains</c> 子 schema。
/// 非预期内部错误会继续抛出,只有正常的 schema 不匹配才会被当成“当前元素不计数”。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="yamlPath">YAML 文件路径。</param>
/// <param name="displayPath">数组字段路径。</param>
/// <param name="sequenceNode">实际数组节点。</param>
/// <param name="containsNode">contains 子 schema。</param>
/// <returns>匹配 <c>contains</c> 子 schema 的元素数量。</returns>
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;
}
/// <summary>
/// 判断单个数组元素是否满足 <c>contains</c> 子 schema。
/// contains 的语义是“尝试匹配”,因此普通约束失败会返回 <see langword="false" />,但内部意外状态仍会继续抛出。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="yamlPath">YAML 文件路径。</param>
/// <param name="displayPath">当前数组元素路径。</param>
/// <param name="itemNode">实际 YAML 元素。</param>
/// <param name="containsNode">contains 子 schema。</param>
/// <returns>当前元素是否匹配 contains 子 schema。</returns>
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;
}
}
/// <summary>
/// 将一个已通过结构校验的 YAML 节点归一化为可比较字符串。
/// 该键同时服务于 <c>uniqueItems</c> 与 <c>const</c>
@ -3100,7 +3291,7 @@ internal sealed class YamlConfigStringConstraints
}
/// <summary>
/// 表示一个数组节点上声明的元素数量或去重约束。
/// 表示一个数组节点上声明的元素数量、去重与 contains 匹配计数约束。
/// 该模型与标量约束拆分保存,避免数组节点继续共享不适用的标量字段。
/// </summary>
internal sealed class YamlConfigArrayConstraints
@ -3111,11 +3302,17 @@ internal sealed class YamlConfigArrayConstraints
/// <param name="minItems">最小元素数量约束。</param>
/// <param name="maxItems">最大元素数量约束。</param>
/// <param name="uniqueItems">是否要求数组元素唯一。</param>
public YamlConfigArrayConstraints(int? minItems, int? maxItems, bool uniqueItems)
/// <param name="containsConstraints">数组 contains 约束;未声明时为空。</param>
public YamlConfigArrayConstraints(
int? minItems,
int? maxItems,
bool uniqueItems,
YamlConfigArrayContainsConstraints? containsConstraints)
{
MinItems = minItems;
MaxItems = maxItems;
UniqueItems = uniqueItems;
ContainsConstraints = containsConstraints;
}
/// <summary>
@ -3132,6 +3329,51 @@ internal sealed class YamlConfigArrayConstraints
/// 获取是否要求数组元素唯一。
/// </summary>
public bool UniqueItems { get; }
/// <summary>
/// 获取数组 contains 约束;未声明时返回空。
/// </summary>
public YamlConfigArrayContainsConstraints? ContainsConstraints { get; }
}
/// <summary>
/// 表示数组节点声明的 <c>contains</c> 匹配约束。
/// 该模型把 contains 子 schema 与匹配数量边界聚合在一起,避免数组节点再额外散落多组相关成员。
/// </summary>
internal sealed class YamlConfigArrayContainsConstraints
{
/// <summary>
/// 初始化数组 contains 约束模型。
/// </summary>
/// <param name="containsNode">contains 子 schema。</param>
/// <param name="minContains">最小匹配数量;为 <see langword="null" /> 时按 JSON Schema 语义默认 1。</param>
/// <param name="maxContains">最大匹配数量。</param>
public YamlConfigArrayContainsConstraints(
YamlConfigSchemaNode containsNode,
int? minContains,
int? maxContains)
{
ArgumentNullException.ThrowIfNull(containsNode);
ContainsNode = containsNode;
MinContains = minContains;
MaxContains = maxContains;
}
/// <summary>
/// 获取 contains 子 schema。
/// </summary>
public YamlConfigSchemaNode ContainsNode { get; }
/// <summary>
/// 获取最小匹配数量;未显式声明时返回空,由调用方按默认值 1 解释。
/// </summary>
public int? MinContains { get; }
/// <summary>
/// 获取最大匹配数量。
/// </summary>
public int? MaxContains { get; }
}
/// <summary>

View File

@ -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,

View File

@ -48,7 +48,7 @@ public sealed partial class MonsterConfig
/// <remarks>
/// 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" };

View File

@ -7,6 +7,7 @@ namespace GFramework.SourceGenerators.Config;
/// 当前实现聚焦 AI-First 配置系统共享的最小 schema 子集,
/// 支持嵌套对象、对象数组、标量数组,以及可映射的 default / enum / const / ref-table 元数据。
/// 当前共享子集也会把 <c>multipleOf</c>、<c>uniqueItems</c>、
/// <c>contains</c> / <c>minContains</c> / <c>maxContains</c>、
/// <c>minProperties</c> 与 <c>maxProperties</c> 写入生成代码文档,
/// 让消费者能直接在强类型 API 上看到运行时生效的约束。
/// </summary>
@ -2442,7 +2443,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
}
/// <summary>
/// 将 shared schema 子集中的范围、步进、长度、数组数量 / 去重与对象属性数量约束整理成 XML 文档可读字符串。
/// 将 shared schema 子集中的范围、步进、长度、数组数量 / 去重 / contains 与对象属性数量约束整理成 XML 文档可读字符串。
/// </summary>
/// <param name="element">Schema 节点。</param>
/// <param name="schemaType">标量类型。</param>
@ -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;
}
/// <summary>
/// 将数组 <c>contains</c> 子 schema 整理成 XML 文档可读字符串。
/// 输出优先保持紧凑,只展示消费者在强类型 API 上最需要看到的匹配摘要。
/// </summary>
/// <param name="element">数组 schema 节点。</param>
/// <returns>格式化后的 contains 说明。</returns>
private static string? TryBuildContainsDocumentation(JsonElement element)
{
if (!element.TryGetProperty("contains", out var containsElement) ||
containsElement.ValueKind != JsonValueKind.Object)
{
return null;
}
return TryBuildContainsSchemaSummary(containsElement);
}
/// <summary>
/// 为 <c>contains</c> 子 schema 生成紧凑摘要。
/// 该摘要复用现有 enum / const / 约束文档构造器,避免 contains 与主属性文档逐渐漂移。
/// </summary>
/// <param name="containsElement">contains 子 schema。</param>
/// <returns>格式化后的摘要字符串。</returns>
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<string>();
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)})";
}
/// <summary>
/// 将 const 值整理成 XML 文档可读字符串。
/// </summary>

View File

@ -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` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息
当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。

View File

@ -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",

View File

@ -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 `<span class="hint">${hints.join(" · ")}</span>`;
}
/**
* 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.
*

View File

@ -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} 个子属性。",

View File

@ -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",

View File

@ -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(`
{

View File

@ -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 条件的元素。");
});