mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-07 17:21:16 +08:00
Merge pull request #208 from GeWuYou/docs/config-system-complete
This commit is contained in:
commit
39e3ecfe46
@ -1039,6 +1039,523 @@ 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>minContains</c> / <c>maxContains</c> 时会被视为合法边界。
|
||||
/// </summary>
|
||||
[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<int, MonsterConfigIntegerArrayStub>("monster", "monster", "schemas/monster.schema.json",
|
||||
static config => config.Id);
|
||||
var registry = new ConfigRegistry();
|
||||
|
||||
await loader.LoadAsync(registry);
|
||||
|
||||
var table = registry.GetTable<int, MonsterConfigIntegerArrayStub>("monster");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(table.Count, Is.EqualTo(1));
|
||||
Assert.That(table.Get(1).DropRates, Is.EqualTo(new[] { 5, 7, 5 }));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证数组字段将 <c>contains</c> 声明为非对象 schema 时,会在 schema 解析阶段被拒绝。
|
||||
/// </summary>
|
||||
[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<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("'contains' as an object-valued schema"));
|
||||
Assert.That(registry.Count, Is.EqualTo(0));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证数组字段将 <c>contains</c> 声明为嵌套数组 schema 时,会在 schema 解析阶段被拒绝。
|
||||
/// </summary>
|
||||
[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<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("unsupported nested array 'contains' schemas"));
|
||||
Assert.That(registry.Count, Is.EqualTo(0));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证对象数组的 <c>contains</c> 试匹配会按声明属性子集工作,而不会因额外字段误判为不匹配。
|
||||
/// </summary>
|
||||
[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<int, MonsterWeightedEntryArrayConfigStub>("monster", "monster", "schemas/monster.schema.json",
|
||||
static config => config.Id);
|
||||
var registry = new ConfigRegistry();
|
||||
|
||||
await loader.LoadAsync(registry);
|
||||
|
||||
var table = registry.GetTable<int, MonsterWeightedEntryArrayConfigStub>("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));
|
||||
});
|
||||
}
|
||||
|
||||
/// <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>
|
||||
@ -2064,6 +2581,190 @@ public class YamlConfigLoaderTests
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证仅声明在 <c>contains</c> 子 schema 里的跨表引用也会参与整批加载校验。
|
||||
/// </summary>
|
||||
[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<string, ItemConfigStub>("item", "item", "schemas/item.schema.json",
|
||||
static config => config.Id)
|
||||
.RegisterTable<int, MonsterDropArrayConfigStub>("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.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));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证依赖关系仅来自 <c>contains</c> 子 schema 时,热重载仍会追踪该依赖并在目标表破坏引用后回滚。
|
||||
/// </summary>
|
||||
[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<string, ItemConfigStub>("item", "item", "schemas/item.schema.json",
|
||||
static config => config.Id)
|
||||
.RegisterTable<int, MonsterDropArrayConfigStub>("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<string, ItemConfigStub>("item").ContainsKey("potion"), Is.True);
|
||||
Assert.That(registry.GetTable<int, MonsterDropArrayConfigStub>("monster").Get(1).DropItemIds,
|
||||
Is.EqualTo(new[] { "potion" }));
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
hotReload.UnRegister();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证启用热重载后,配置文件内容变更会刷新已注册配置表。
|
||||
/// </summary>
|
||||
@ -2498,7 +3199,7 @@ public class YamlConfigLoaderTests
|
||||
/// <summary>
|
||||
/// 获取或设置掉落率列表。
|
||||
/// </summary>
|
||||
public IReadOnlyList<int> DropRates { get; set; } = Array.Empty<int>();
|
||||
public List<int> DropRates { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -2575,6 +3276,43 @@ public class YamlConfigLoaderTests
|
||||
public List<ComparableEntryConfigStub> Entries { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用于对象数组 <c>contains</c> 子集匹配回归测试的最小配置类型。
|
||||
/// </summary>
|
||||
private sealed class MonsterWeightedEntryArrayConfigStub
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置主键。
|
||||
/// </summary>
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置对象数组条目。
|
||||
/// </summary>
|
||||
public List<WeightedEntryConfigStub> Entries { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 表示对象数组 <c>contains</c> 子集匹配回归测试中的条目元素。
|
||||
/// </summary>
|
||||
private sealed class WeightedEntryConfigStub
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置条目标识。
|
||||
/// </summary>
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置权重。
|
||||
/// </summary>
|
||||
public int Weight { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 表示对象数组中的阶段元素。
|
||||
/// </summary>
|
||||
|
||||
@ -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
|
||||
{
|
||||
@ -435,22 +436,42 @@ internal static class YamlConfigSchemaValidator
|
||||
/// <param name="node">实际 YAML 节点。</param>
|
||||
/// <param name="schemaNode">对应的 schema 节点。</param>
|
||||
/// <param name="references">已收集的跨表引用。</param>
|
||||
/// <param name="allowUnknownObjectProperties">
|
||||
/// 是否允许对象节点出现当前 schema 子树未声明的额外字段。
|
||||
/// 该开关仅用于 <c>contains</c> 试匹配,让对象子 schema 可以按“声明属性子集匹配”工作;
|
||||
/// 正常加载主链路仍保持未知字段即失败的严格语义。
|
||||
/// </param>
|
||||
private static void ValidateNode(
|
||||
string tableName,
|
||||
string yamlPath,
|
||||
string displayPath,
|
||||
YamlNode node,
|
||||
YamlConfigSchemaNode schemaNode,
|
||||
ICollection<YamlConfigReferenceUsage>? references)
|
||||
ICollection<YamlConfigReferenceUsage>? references,
|
||||
bool allowUnknownObjectProperties = false)
|
||||
{
|
||||
switch (schemaNode.NodeType)
|
||||
{
|
||||
case YamlConfigSchemaPropertyType.Object:
|
||||
ValidateObjectNode(tableName, yamlPath, displayPath, node, schemaNode, references);
|
||||
ValidateObjectNode(
|
||||
tableName,
|
||||
yamlPath,
|
||||
displayPath,
|
||||
node,
|
||||
schemaNode,
|
||||
references,
|
||||
allowUnknownObjectProperties);
|
||||
return;
|
||||
|
||||
case YamlConfigSchemaPropertyType.Array:
|
||||
ValidateArrayNode(tableName, yamlPath, displayPath, node, schemaNode, references);
|
||||
ValidateArrayNode(
|
||||
tableName,
|
||||
yamlPath,
|
||||
displayPath,
|
||||
node,
|
||||
schemaNode,
|
||||
references,
|
||||
allowUnknownObjectProperties);
|
||||
return;
|
||||
|
||||
case YamlConfigSchemaPropertyType.Integer:
|
||||
@ -481,13 +502,17 @@ internal static class YamlConfigSchemaValidator
|
||||
/// <param name="node">实际 YAML 节点。</param>
|
||||
/// <param name="schemaNode">对象 schema 节点。</param>
|
||||
/// <param name="references">已收集的跨表引用。</param>
|
||||
/// <param name="allowUnknownObjectProperties">
|
||||
/// 是否允许当前对象包含 schema 子树未声明的额外字段。
|
||||
/// </param>
|
||||
private static void ValidateObjectNode(
|
||||
string tableName,
|
||||
string yamlPath,
|
||||
string displayPath,
|
||||
YamlNode node,
|
||||
YamlConfigSchemaNode schemaNode,
|
||||
ICollection<YamlConfigReferenceUsage>? references)
|
||||
ICollection<YamlConfigReferenceUsage>? references,
|
||||
bool allowUnknownObjectProperties)
|
||||
{
|
||||
if (node is not YamlMappingNode mappingNode)
|
||||
{
|
||||
@ -533,6 +558,11 @@ internal static class YamlConfigSchemaValidator
|
||||
if (schemaNode.Properties is null ||
|
||||
!schemaNode.Properties.TryGetValue(propertyName, out var propertySchema))
|
||||
{
|
||||
if (allowUnknownObjectProperties)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
throw ConfigLoadExceptionFactory.Create(
|
||||
ConfigLoadFailureKind.UnknownProperty,
|
||||
tableName,
|
||||
@ -542,7 +572,14 @@ internal static class YamlConfigSchemaValidator
|
||||
displayPath: propertyPath);
|
||||
}
|
||||
|
||||
ValidateNode(tableName, yamlPath, propertyPath, entry.Value, propertySchema, references);
|
||||
ValidateNode(
|
||||
tableName,
|
||||
yamlPath,
|
||||
propertyPath,
|
||||
entry.Value,
|
||||
propertySchema,
|
||||
references,
|
||||
allowUnknownObjectProperties);
|
||||
}
|
||||
|
||||
if (schemaNode.RequiredProperties is null)
|
||||
@ -639,13 +676,17 @@ internal static class YamlConfigSchemaValidator
|
||||
/// <param name="node">实际 YAML 节点。</param>
|
||||
/// <param name="schemaNode">数组 schema 节点。</param>
|
||||
/// <param name="references">已收集的跨表引用。</param>
|
||||
/// <param name="allowUnknownObjectProperties">
|
||||
/// 是否允许数组元素内的对象节点包含 schema 子树未声明的额外字段。
|
||||
/// </param>
|
||||
private static void ValidateArrayNode(
|
||||
string tableName,
|
||||
string yamlPath,
|
||||
string displayPath,
|
||||
YamlNode node,
|
||||
YamlConfigSchemaNode schemaNode,
|
||||
ICollection<YamlConfigReferenceUsage>? references)
|
||||
ICollection<YamlConfigReferenceUsage>? references,
|
||||
bool allowUnknownObjectProperties)
|
||||
{
|
||||
if (node is not YamlSequenceNode sequenceNode)
|
||||
{
|
||||
@ -682,10 +723,12 @@ internal static class YamlConfigSchemaValidator
|
||||
$"{displayPath}[{itemIndex}]",
|
||||
sequenceNode.Children[itemIndex],
|
||||
schemaNode.ItemNode,
|
||||
references);
|
||||
references,
|
||||
allowUnknownObjectProperties);
|
||||
}
|
||||
|
||||
ValidateArrayUniqueItemsConstraint(tableName, yamlPath, displayPath, sequenceNode, schemaNode);
|
||||
ValidateArrayContainsConstraints(tableName, yamlPath, displayPath, sequenceNode, schemaNode, references);
|
||||
ValidateConstantValue(tableName, yamlPath, displayPath, sequenceNode, schemaNode);
|
||||
}
|
||||
|
||||
@ -1152,7 +1195,7 @@ internal static class YamlConfigSchemaValidator
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析数组节点支持的元素数量约束。
|
||||
/// 解析数组节点支持的元素数量、去重与 <c>contains</c> 匹配数量约束。
|
||||
/// </summary>
|
||||
/// <param name="tableName">所属配置表名称。</param>
|
||||
/// <param name="schemaPath">Schema 文件路径。</param>
|
||||
@ -1168,6 +1211,7 @@ internal static class YamlConfigSchemaValidator
|
||||
var minItems = TryParseArrayLengthConstraint(tableName, schemaPath, propertyPath, element, "minItems");
|
||||
var maxItems = TryParseArrayLengthConstraint(tableName, schemaPath, propertyPath, element, "maxItems");
|
||||
var uniqueItems = TryParseUniqueItemsConstraint(tableName, schemaPath, propertyPath, element);
|
||||
var containsConstraints = ParseArrayContainsConstraints(tableName, schemaPath, propertyPath, element);
|
||||
|
||||
if (minItems.HasValue && maxItems.HasValue && minItems.Value > maxItems.Value)
|
||||
{
|
||||
@ -1179,9 +1223,77 @@ internal static class YamlConfigSchemaValidator
|
||||
displayPath: GetDiagnosticPath(propertyPath));
|
||||
}
|
||||
|
||||
return !minItems.HasValue && !maxItems.HasValue && !uniqueItems
|
||||
return !minItems.HasValue && !maxItems.HasValue && !uniqueItems && containsConstraints is null
|
||||
? null
|
||||
: new YamlConfigArrayConstraints(minItems, maxItems, uniqueItems);
|
||||
: new YamlConfigArrayConstraints(minItems, maxItems, uniqueItems, containsConstraints);
|
||||
}
|
||||
|
||||
/// <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 +2185,155 @@ 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>
|
||||
/// <param name="references">匹配成功的 <c>contains</c> 子树所声明的跨表引用收集器。</param>
|
||||
private static void ValidateArrayContainsConstraints(
|
||||
string tableName,
|
||||
string yamlPath,
|
||||
string displayPath,
|
||||
YamlSequenceNode sequenceNode,
|
||||
YamlConfigSchemaNode schemaNode,
|
||||
ICollection<YamlConfigReferenceUsage>? references)
|
||||
{
|
||||
var containsConstraints = schemaNode.ArrayConstraints?.ContainsConstraints;
|
||||
if (containsConstraints is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var matchingCount = CountMatchingContainsItems(
|
||||
tableName,
|
||||
yamlPath,
|
||||
displayPath,
|
||||
sequenceNode,
|
||||
containsConstraints.ContainsNode,
|
||||
references);
|
||||
var rawValue = matchingCount.ToString(CultureInfo.InvariantCulture);
|
||||
var requiredMinContains = containsConstraints.MinContains ?? 1;
|
||||
if (matchingCount < requiredMinContains)
|
||||
{
|
||||
throw ConfigLoadExceptionFactory.Create(
|
||||
ConfigLoadFailureKind.ConstraintViolation,
|
||||
tableName,
|
||||
$"Property '{displayPath}' in config file '{yamlPath}' must contain at least {requiredMinContains} items matching the 'contains' schema, but the current YAML sequence contains {matchingCount}.",
|
||||
yamlPath: yamlPath,
|
||||
schemaPath: schemaNode.SchemaPathHint,
|
||||
displayPath: GetDiagnosticPath(displayPath),
|
||||
rawValue: rawValue,
|
||||
detail: $"Minimum matching contains count: {requiredMinContains}.");
|
||||
}
|
||||
|
||||
if (containsConstraints.MaxContains.HasValue &&
|
||||
matchingCount > containsConstraints.MaxContains.Value)
|
||||
{
|
||||
throw ConfigLoadExceptionFactory.Create(
|
||||
ConfigLoadFailureKind.ConstraintViolation,
|
||||
tableName,
|
||||
$"Property '{displayPath}' in config file '{yamlPath}' must contain at most {containsConstraints.MaxContains.Value} items matching the 'contains' schema, but the current YAML sequence contains {matchingCount}.",
|
||||
yamlPath: yamlPath,
|
||||
schemaPath: schemaNode.SchemaPathHint,
|
||||
displayPath: GetDiagnosticPath(displayPath),
|
||||
rawValue: rawValue,
|
||||
detail: $"Maximum matching contains count: {containsConstraints.MaxContains.Value}.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <param name="references">匹配成功元素的可选跨表引用收集器。</param>
|
||||
/// <returns>匹配 <c>contains</c> 子 schema 的元素数量。</returns>
|
||||
private static int CountMatchingContainsItems(
|
||||
string tableName,
|
||||
string yamlPath,
|
||||
string displayPath,
|
||||
YamlSequenceNode sequenceNode,
|
||||
YamlConfigSchemaNode containsNode,
|
||||
ICollection<YamlConfigReferenceUsage>? references)
|
||||
{
|
||||
var matchingCount = 0;
|
||||
for (var itemIndex = 0; itemIndex < sequenceNode.Children.Count; itemIndex++)
|
||||
{
|
||||
if (IsArrayItemMatchingContains(
|
||||
tableName,
|
||||
yamlPath,
|
||||
$"{displayPath}[{itemIndex}]",
|
||||
sequenceNode.Children[itemIndex],
|
||||
containsNode,
|
||||
references))
|
||||
{
|
||||
matchingCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return matchingCount;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <param name="references">当前元素匹配成功后要写回的可选跨表引用收集器。</param>
|
||||
/// <returns>当前元素是否匹配 contains 子 schema。</returns>
|
||||
private static bool IsArrayItemMatchingContains(
|
||||
string tableName,
|
||||
string yamlPath,
|
||||
string displayPath,
|
||||
YamlNode itemNode,
|
||||
YamlConfigSchemaNode containsNode,
|
||||
ICollection<YamlConfigReferenceUsage>? references)
|
||||
{
|
||||
// contains 的“试匹配”不能把失败元素的引用泄漏给外层,但匹配成功的元素仍需要参与
|
||||
// 跨表引用收集,否则仅声明在 contains 子 schema 里的 ref-table 会被运行时遗漏。
|
||||
List<YamlConfigReferenceUsage>? matchedReferences = references is null ? null : new();
|
||||
|
||||
try
|
||||
{
|
||||
ValidateNode(
|
||||
tableName,
|
||||
yamlPath,
|
||||
displayPath,
|
||||
itemNode,
|
||||
containsNode,
|
||||
matchedReferences,
|
||||
allowUnknownObjectProperties: true);
|
||||
|
||||
if (references is not null &&
|
||||
matchedReferences is not null)
|
||||
{
|
||||
foreach (var referenceUsage in matchedReferences)
|
||||
{
|
||||
references.Add(referenceUsage);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (ConfigLoadException exception) when (exception.Diagnostic.FailureKind != ConfigLoadFailureKind.UnexpectedFailure)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将一个已通过结构校验的 YAML 节点归一化为可比较字符串。
|
||||
/// 该键同时服务于 <c>uniqueItems</c> 与 <c>const</c>,
|
||||
@ -2436,6 +2697,12 @@ internal static class YamlConfigSchemaValidator
|
||||
{
|
||||
CollectReferencedTableNames(node.ItemNode, referencedTableNames);
|
||||
}
|
||||
|
||||
var containsNode = node.ArrayConstraints?.ContainsConstraints?.ContainsNode;
|
||||
if (containsNode is not null)
|
||||
{
|
||||
CollectReferencedTableNames(containsNode, referencedTableNames);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -3100,7 +3367,7 @@ internal sealed class YamlConfigStringConstraints
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 表示一个数组节点上声明的元素数量或去重约束。
|
||||
/// 表示一个数组节点上声明的元素数量、去重与 contains 匹配计数约束。
|
||||
/// 该模型与标量约束拆分保存,避免数组节点继续共享不适用的标量字段。
|
||||
/// </summary>
|
||||
internal sealed class YamlConfigArrayConstraints
|
||||
@ -3111,11 +3378,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 +3405,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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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" };
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息
|
||||
|
||||
当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。
|
||||
|
||||
|
||||
@ -860,6 +860,8 @@ function parseSchemaNode(rawNode, displayPath) {
|
||||
patternRegex: patternMetadata ? patternMetadata.regex : undefined,
|
||||
minItems: normalizeSchemaNonNegativeInteger(value.minItems),
|
||||
maxItems: normalizeSchemaNonNegativeInteger(value.maxItems),
|
||||
minContains: normalizeSchemaNonNegativeInteger(value.minContains),
|
||||
maxContains: normalizeSchemaNonNegativeInteger(value.maxContains),
|
||||
minProperties: normalizeSchemaNonNegativeInteger(value.minProperties),
|
||||
maxProperties: normalizeSchemaNonNegativeInteger(value.maxProperties),
|
||||
uniqueItems: normalizeSchemaBoolean(value.uniqueItems),
|
||||
@ -892,6 +894,27 @@ function parseSchemaNode(rawNode, displayPath) {
|
||||
|
||||
if (type === "array") {
|
||||
const itemNode = parseSchemaNode(value.items || {}, joinArrayTemplatePath(displayPath));
|
||||
const containsNode = value.contains && typeof value.contains === "object"
|
||||
? parseSchemaNode(value.contains, joinArrayTemplatePath(displayPath))
|
||||
: undefined;
|
||||
if (!containsNode &&
|
||||
(typeof metadata.minContains === "number" || typeof metadata.maxContains === "number")) {
|
||||
throw new Error(`Schema property '${displayPath}' declares 'minContains' or 'maxContains' without 'contains'.`);
|
||||
}
|
||||
|
||||
if (containsNode && containsNode.type === "array") {
|
||||
throw new Error(`Schema property '${displayPath}' uses unsupported nested array 'contains' schemas.`);
|
||||
}
|
||||
|
||||
const effectiveMinContains = containsNode
|
||||
? (typeof metadata.minContains === "number" ? metadata.minContains : 1)
|
||||
: undefined;
|
||||
if (containsNode &&
|
||||
typeof metadata.maxContains === "number" &&
|
||||
effectiveMinContains > metadata.maxContains) {
|
||||
throw new Error(`Schema property '${displayPath}' declares 'minContains' greater than 'maxContains'.`);
|
||||
}
|
||||
|
||||
return applyConstMetadata({
|
||||
type: "array",
|
||||
displayPath,
|
||||
@ -900,8 +923,15 @@ function parseSchemaNode(rawNode, displayPath) {
|
||||
defaultValue: metadata.defaultValue,
|
||||
minItems: metadata.minItems,
|
||||
maxItems: metadata.maxItems,
|
||||
minContains: containsNode
|
||||
? metadata.minContains
|
||||
: undefined,
|
||||
maxContains: containsNode
|
||||
? metadata.maxContains
|
||||
: undefined,
|
||||
uniqueItems: metadata.uniqueItems === true,
|
||||
refTable: metadata.refTable,
|
||||
contains: containsNode,
|
||||
items: itemNode
|
||||
}, value.const, displayPath);
|
||||
}
|
||||
@ -993,6 +1023,8 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
|
||||
}
|
||||
|
||||
const comparableItems = [];
|
||||
const containsCandidateItems = [];
|
||||
let hasStructurallyInvalidArrayItems = false;
|
||||
for (let index = 0; index < yamlNode.items.length; index += 1) {
|
||||
const diagnosticsBeforeValidation = diagnostics.length;
|
||||
validateNode(
|
||||
@ -1002,8 +1034,14 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
|
||||
diagnostics,
|
||||
localizer);
|
||||
|
||||
if (isStructurallyCompatibleWithSchemaNode(schemaNode.items, yamlNode.items[index])) {
|
||||
containsCandidateItems.push({index, node: yamlNode.items[index]});
|
||||
} else {
|
||||
hasStructurallyInvalidArrayItems = true;
|
||||
}
|
||||
|
||||
// Keep uniqueItems focused on values that are otherwise valid so a
|
||||
// shape/type error does not also surface as a misleading duplicate.
|
||||
// shape/type or constraint error does not also surface as a misleading duplicate.
|
||||
if (diagnostics.length === diagnosticsBeforeValidation) {
|
||||
comparableItems.push({index, node: yamlNode.items[index]});
|
||||
}
|
||||
@ -1028,6 +1066,39 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasStructurallyInvalidArrayItems && schemaNode.contains) {
|
||||
let matchingContainsCount = 0;
|
||||
for (const {node} of containsCandidateItems) {
|
||||
if (matchesSchemaNode(schemaNode.contains, node)) {
|
||||
matchingContainsCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const requiredMinContains = typeof schemaNode.minContains === "number"
|
||||
? schemaNode.minContains
|
||||
: 1;
|
||||
if (matchingContainsCount < requiredMinContains) {
|
||||
diagnostics.push({
|
||||
severity: "error",
|
||||
message: localizeValidationMessage(ValidationMessageKeys.minContainsViolation, localizer, {
|
||||
displayPath,
|
||||
value: String(requiredMinContains)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof schemaNode.maxContains === "number" &&
|
||||
matchingContainsCount > schemaNode.maxContains) {
|
||||
diagnostics.push({
|
||||
severity: "error",
|
||||
message: localizeValidationMessage(ValidationMessageKeys.maxContainsViolation, localizer, {
|
||||
displayPath,
|
||||
value: String(schemaNode.maxContains)
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
validateConstComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer);
|
||||
|
||||
return;
|
||||
@ -1251,6 +1322,249 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca
|
||||
validateConstComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether one YAML node satisfies one schema node without emitting user-facing diagnostics.
|
||||
* This is used by array `contains`, where object sub-schemas must behave like
|
||||
* partial matchers: declared properties, required members, and constraints must
|
||||
* match, but additional object members outside the sub-schema must not block a hit.
|
||||
*
|
||||
* @param {SchemaNode} schemaNode Schema node.
|
||||
* @param {YamlNode} yamlNode YAML node.
|
||||
* @returns {boolean} True when the YAML node matches the schema node.
|
||||
*/
|
||||
function matchesSchemaNode(schemaNode, yamlNode) {
|
||||
return matchesSchemaNodeInternal(schemaNode, yamlNode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Match one YAML node against one schema node using JSON-Schema-style subset semantics.
|
||||
* The helper mirrors validation rules closely, but it intentionally skips unknown-property
|
||||
* rejection for objects so `contains` can test whether one item satisfies a sub-schema.
|
||||
*
|
||||
* @param {SchemaNode} schemaNode Schema node.
|
||||
* @param {YamlNode} yamlNode YAML node.
|
||||
* @returns {boolean} True when the YAML node satisfies the schema node.
|
||||
*/
|
||||
function matchesSchemaNodeInternal(schemaNode, yamlNode) {
|
||||
if (schemaNode.type === "object") {
|
||||
if (!yamlNode || yamlNode.kind !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const propertyCount = yamlNode.map instanceof Map
|
||||
? yamlNode.map.size
|
||||
: Array.isArray(yamlNode.entries)
|
||||
? new Set(yamlNode.entries.map((entry) => entry.key)).size
|
||||
: 0;
|
||||
|
||||
for (const requiredProperty of schemaNode.required) {
|
||||
if (!yamlNode.map.has(requiredProperty)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, childSchema] of Object.entries(schemaNode.properties)) {
|
||||
if (yamlNode.map.has(key) &&
|
||||
!matchesSchemaNodeInternal(childSchema, yamlNode.map.get(key))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof schemaNode.minProperties === "number" &&
|
||||
propertyCount < schemaNode.minProperties) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof schemaNode.maxProperties === "number" &&
|
||||
propertyCount > schemaNode.maxProperties) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return typeof schemaNode.constComparableValue !== "string" ||
|
||||
buildComparableNodeValue(schemaNode, yamlNode) === schemaNode.constComparableValue;
|
||||
}
|
||||
|
||||
if (schemaNode.type === "array") {
|
||||
if (!yamlNode || yamlNode.kind !== "array") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof schemaNode.minItems === "number" &&
|
||||
yamlNode.items.length < schemaNode.minItems) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof schemaNode.maxItems === "number" &&
|
||||
yamlNode.items.length > schemaNode.maxItems) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const item of yamlNode.items) {
|
||||
if (!matchesSchemaNodeInternal(schemaNode.items, item)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (schemaNode.uniqueItems === true) {
|
||||
const seenItems = new Set();
|
||||
for (const item of yamlNode.items) {
|
||||
const comparableValue = buildComparableNodeValue(schemaNode.items, item);
|
||||
if (seenItems.has(comparableValue)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seenItems.add(comparableValue);
|
||||
}
|
||||
}
|
||||
|
||||
if (schemaNode.contains) {
|
||||
let matchingContainsCount = 0;
|
||||
for (const item of yamlNode.items) {
|
||||
if (matchesSchemaNodeInternal(schemaNode.contains, item)) {
|
||||
matchingContainsCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const requiredMinContains = typeof schemaNode.minContains === "number"
|
||||
? schemaNode.minContains
|
||||
: 1;
|
||||
if (matchingContainsCount < requiredMinContains) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof schemaNode.maxContains === "number" &&
|
||||
matchingContainsCount > schemaNode.maxContains) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return typeof schemaNode.constComparableValue !== "string" ||
|
||||
buildComparableNodeValue(schemaNode, yamlNode) === schemaNode.constComparableValue;
|
||||
}
|
||||
|
||||
if (!yamlNode || yamlNode.kind !== "scalar") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isScalarCompatible(schemaNode.type, yamlNode.value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(schemaNode.enumValues) &&
|
||||
schemaNode.enumValues.length > 0 &&
|
||||
!schemaNode.enumValues.includes(unquoteScalar(yamlNode.value))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const scalarValue = unquoteScalar(yamlNode.value);
|
||||
const supportsNumericConstraints = schemaNode.type === "integer" || schemaNode.type === "number";
|
||||
const supportsLengthConstraints = schemaNode.type === "string";
|
||||
const supportsPatternConstraints = schemaNode.type === "string";
|
||||
|
||||
if (supportsNumericConstraints &&
|
||||
typeof schemaNode.minimum === "number" &&
|
||||
Number(scalarValue) < schemaNode.minimum) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (supportsNumericConstraints &&
|
||||
typeof schemaNode.exclusiveMinimum === "number" &&
|
||||
Number(scalarValue) <= schemaNode.exclusiveMinimum) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (supportsNumericConstraints &&
|
||||
typeof schemaNode.maximum === "number" &&
|
||||
Number(scalarValue) > schemaNode.maximum) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (supportsNumericConstraints &&
|
||||
typeof schemaNode.exclusiveMaximum === "number" &&
|
||||
Number(scalarValue) >= schemaNode.exclusiveMaximum) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (supportsNumericConstraints &&
|
||||
!matchesSchemaMultipleOf(scalarValue, schemaNode.multipleOf)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (supportsLengthConstraints &&
|
||||
typeof schemaNode.minLength === "number" &&
|
||||
scalarValue.length < schemaNode.minLength) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (supportsLengthConstraints &&
|
||||
typeof schemaNode.maxLength === "number" &&
|
||||
scalarValue.length > schemaNode.maxLength) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (supportsPatternConstraints &&
|
||||
!matchesSchemaPattern(scalarValue, schemaNode.patternRegex)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return typeof schemaNode.constComparableValue !== "string" ||
|
||||
buildComparableNodeValue(schemaNode, yamlNode) === schemaNode.constComparableValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether one YAML node is structurally compatible with one schema node.
|
||||
* This keeps array-level `contains` validation from producing noisy follow-on
|
||||
* diagnostics when an item already has a shape or scalar-type mismatch, while
|
||||
* still allowing value-level constraint failures to participate in contains counting.
|
||||
*
|
||||
* @param {SchemaNode} schemaNode Schema node.
|
||||
* @param {YamlNode} yamlNode YAML node.
|
||||
* @returns {boolean} True when the YAML node has the expected recursive shape.
|
||||
*/
|
||||
function isStructurallyCompatibleWithSchemaNode(schemaNode, yamlNode) {
|
||||
if (schemaNode.type === "object") {
|
||||
if (!yamlNode || yamlNode.kind !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const requiredProperty of schemaNode.required) {
|
||||
if (!yamlNode.map.has(requiredProperty)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of yamlNode.entries) {
|
||||
if (!Object.prototype.hasOwnProperty.call(schemaNode.properties, entry.key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isStructurallyCompatibleWithSchemaNode(schemaNode.properties[entry.key], entry.node)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (schemaNode.type === "array") {
|
||||
if (!yamlNode || yamlNode.kind !== "array") {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const item of yamlNode.items) {
|
||||
if (!isStructurallyCompatibleWithSchemaNode(schemaNode.items, item)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return Boolean(yamlNode) &&
|
||||
yamlNode.kind === "scalar" &&
|
||||
isScalarCompatible(schemaNode.type, yamlNode.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one parsed YAML node against one normalized const comparable value.
|
||||
* The helper reuses the same comparable-key logic as uniqueItems so array order
|
||||
@ -1390,6 +1704,8 @@ function localizeValidationMessage(key, localizer, params) {
|
||||
return `属性“${params.displayPath}”必须大于 ${params.value}。`;
|
||||
case ValidationMessageKeys.maximumViolation:
|
||||
return `属性“${params.displayPath}”必须小于或等于 ${params.value}。`;
|
||||
case ValidationMessageKeys.maxContainsViolation:
|
||||
return `属性“${params.displayPath}”最多只能包含 ${params.value} 个匹配 contains 条件的元素。`;
|
||||
case ValidationMessageKeys.maxItemsViolation:
|
||||
return `属性“${params.displayPath}”最多只能包含 ${params.value} 个元素。`;
|
||||
case ValidationMessageKeys.maxLengthViolation:
|
||||
@ -1398,6 +1714,8 @@ function localizeValidationMessage(key, localizer, params) {
|
||||
return `属性“${params.displayPath}”必须大于或等于 ${params.value}。`;
|
||||
case ValidationMessageKeys.multipleOfViolation:
|
||||
return `属性“${params.displayPath}”必须是 ${params.value} 的整数倍。`;
|
||||
case ValidationMessageKeys.minContainsViolation:
|
||||
return `属性“${params.displayPath}”至少需要包含 ${params.value} 个匹配 contains 条件的元素。`;
|
||||
case ValidationMessageKeys.minItemsViolation:
|
||||
return `属性“${params.displayPath}”至少需要包含 ${params.value} 个元素。`;
|
||||
case ValidationMessageKeys.minLengthViolation:
|
||||
@ -1432,6 +1750,8 @@ function localizeValidationMessage(key, localizer, params) {
|
||||
return `Property '${params.displayPath}' must be greater than ${params.value}.`;
|
||||
case ValidationMessageKeys.maximumViolation:
|
||||
return `Property '${params.displayPath}' must be less than or equal to ${params.value}.`;
|
||||
case ValidationMessageKeys.maxContainsViolation:
|
||||
return `Property '${params.displayPath}' must contain at most ${params.value} items matching the 'contains' schema.`;
|
||||
case ValidationMessageKeys.maxItemsViolation:
|
||||
return `Property '${params.displayPath}' must contain at most ${params.value} items.`;
|
||||
case ValidationMessageKeys.maxLengthViolation:
|
||||
@ -1440,6 +1760,8 @@ function localizeValidationMessage(key, localizer, params) {
|
||||
return `Property '${params.displayPath}' must be greater than or equal to ${params.value}.`;
|
||||
case ValidationMessageKeys.multipleOfViolation:
|
||||
return `Property '${params.displayPath}' must be a multiple of ${params.value}.`;
|
||||
case ValidationMessageKeys.minContainsViolation:
|
||||
return `Property '${params.displayPath}' must contain at least ${params.value} items matching the 'contains' schema.`;
|
||||
case ValidationMessageKeys.minItemsViolation:
|
||||
return `Property '${params.displayPath}' must contain at least ${params.value} items.`;
|
||||
case ValidationMessageKeys.minLengthViolation:
|
||||
@ -2167,8 +2489,11 @@ module.exports = {
|
||||
* constComparableValue?: string,
|
||||
* minItems?: number,
|
||||
* maxItems?: number,
|
||||
* minContains?: number,
|
||||
* maxContains?: number,
|
||||
* uniqueItems?: boolean,
|
||||
* refTable?: string,
|
||||
* contains?: SchemaNode,
|
||||
* items: SchemaNode
|
||||
* } | {
|
||||
* type: "string" | "integer" | "number" | "boolean",
|
||||
|
||||
67
tools/gframework-config-tool/src/containsSummary.js
Normal file
67
tools/gframework-config-tool/src/containsSummary.js
Normal file
@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Build a compact contains-schema summary for array field hints.
|
||||
* The summary reuses existing localized hint strings so Chinese UI surfaces
|
||||
* do not fall back to mixed English tokens such as const/enum/pattern/ref.
|
||||
*
|
||||
* @param {{type?: string, enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, refTable?: string}} containsSchema Parsed contains schema metadata.
|
||||
* @param {{t: (key: string, params?: Record<string, string | number>) => string}} localizer Runtime localizer.
|
||||
* @returns {string} Human-facing summary.
|
||||
*/
|
||||
function describeContainsSchema(containsSchema, localizer) {
|
||||
const parts = [];
|
||||
if (containsSchema.type) {
|
||||
parts.push(containsSchema.type);
|
||||
}
|
||||
|
||||
if (containsSchema.constValue !== undefined) {
|
||||
parts.push(localizer.t("webview.hint.const", {
|
||||
value: containsSchema.constDisplayValue ?? containsSchema.constValue
|
||||
}));
|
||||
} else if (Array.isArray(containsSchema.enumValues) && containsSchema.enumValues.length > 0) {
|
||||
parts.push(localizer.t("webview.hint.allowed", {
|
||||
values: containsSchema.enumValues.join(", ")
|
||||
}));
|
||||
} else if (containsSchema.pattern) {
|
||||
parts.push(localizer.t("webview.hint.pattern", {
|
||||
value: containsSchema.pattern
|
||||
}));
|
||||
}
|
||||
|
||||
if (containsSchema.refTable) {
|
||||
parts.push(localizer.t("webview.hint.refTable", {
|
||||
refTable: containsSchema.refTable
|
||||
}));
|
||||
}
|
||||
|
||||
return parts.join(", ") || localizer.t("webview.objectArray.item");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build localized contains-related hint lines for array fields.
|
||||
*
|
||||
* @param {{contains?: {type?: string, enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, refTable?: string}, minContains?: number}} propertySchema Array property schema metadata.
|
||||
* @param {{t: (key: string, params?: Record<string, string | number>) => string}} localizer Runtime localizer.
|
||||
* @returns {string[]} Localized contains hint lines.
|
||||
*/
|
||||
function buildContainsHintLines(propertySchema, localizer) {
|
||||
if (!propertySchema.contains) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const effectiveMinContains = typeof propertySchema.minContains === "number"
|
||||
? propertySchema.minContains
|
||||
: 1;
|
||||
return [
|
||||
localizer.t("webview.hint.contains", {
|
||||
summary: describeContainsSchema(propertySchema.contains, localizer)
|
||||
}),
|
||||
localizer.t("webview.hint.minContains", {
|
||||
value: effectiveMinContains
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
describeContainsSchema,
|
||||
buildContainsHintLines
|
||||
};
|
||||
@ -18,6 +18,7 @@ const {
|
||||
joinArrayTemplatePath,
|
||||
joinPropertyPath
|
||||
} = require("./configPath");
|
||||
const {buildContainsHintLines} = require("./containsSummary");
|
||||
const {createLocalizer} = require("./localization");
|
||||
|
||||
const localizer = createLocalizer(vscode.env.language);
|
||||
@ -1577,7 +1578,7 @@ function getScalarArrayValue(yamlNode) {
|
||||
/**
|
||||
* Render human-facing metadata hints for one schema field.
|
||||
*
|
||||
* @param {{type?: string, description?: string, defaultValue?: string, constValue?: string, constDisplayValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string, minItems?: number, maxItems?: number, minProperties?: number, maxProperties?: number, uniqueItems?: boolean, enumValues?: string[], items?: {enumValues?: string[], constValue?: string, constDisplayValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string}, refTable?: string}} propertySchema Property schema metadata.
|
||||
* @param {{type?: string, description?: string, defaultValue?: string, constValue?: string, constDisplayValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string, minItems?: number, maxItems?: number, minContains?: number, maxContains?: number, minProperties?: number, maxProperties?: number, uniqueItems?: boolean, enumValues?: string[], contains?: {type?: string, enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, refTable?: string}, items?: {enumValues?: string[], constValue?: string, constDisplayValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string}, refTable?: string}} propertySchema Property schema metadata.
|
||||
* @param {boolean} isArrayField Whether the field is an array.
|
||||
* @param {boolean} includeDescription Whether description text should be included in the hint output.
|
||||
* @returns {string} HTML fragment.
|
||||
@ -1656,6 +1657,17 @@ function renderFieldHint(propertySchema, isArrayField, includeDescription = true
|
||||
hints.push(escapeHtml(localizer.t("webview.hint.maxItems", {value: propertySchema.maxItems})));
|
||||
}
|
||||
|
||||
if (isArrayField && propertySchema.contains) {
|
||||
const containsHints = buildContainsHintLines(propertySchema, localizer);
|
||||
for (const containsHint of containsHints) {
|
||||
hints.push(escapeHtml(containsHint));
|
||||
}
|
||||
}
|
||||
|
||||
if (isArrayField && typeof propertySchema.maxContains === "number") {
|
||||
hints.push(escapeHtml(localizer.t("webview.hint.maxContains", {value: propertySchema.maxContains})));
|
||||
}
|
||||
|
||||
if (isArrayField && propertySchema.uniqueItems === true) {
|
||||
hints.push(escapeHtml(localizer.t("webview.hint.uniqueItems")));
|
||||
}
|
||||
|
||||
@ -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} 个子属性。",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -799,6 +799,269 @@ phases:
|
||||
assert.match(diagnostics[1].message, /phases\[1\]|uniqueItems|元素唯一/u);
|
||||
});
|
||||
|
||||
test("validateParsedConfig should report contains match-count violations", () => {
|
||||
const schema = parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dropRates": {
|
||||
"type": "array",
|
||||
"minContains": 2,
|
||||
"contains": {
|
||||
"type": "integer",
|
||||
"const": 5
|
||||
},
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const yaml = parseTopLevelYaml(`
|
||||
dropRates:
|
||||
- 5
|
||||
- 7
|
||||
- 9
|
||||
`);
|
||||
|
||||
const diagnostics = validateParsedConfig(schema, yaml);
|
||||
|
||||
assert.equal(diagnostics.length, 1);
|
||||
assert.match(diagnostics[0].message, /at least 2 items matching the 'contains' schema|至少需要包含 2 个匹配 contains 条件的元素/u);
|
||||
});
|
||||
|
||||
test("validateParsedConfig should skip contains match-count when items are structurally invalid", () => {
|
||||
const schema = parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["dropRates"],
|
||||
"properties": {
|
||||
"dropRates": {
|
||||
"type": "array",
|
||||
"minContains": 2,
|
||||
"contains": {
|
||||
"type": "object",
|
||||
"required": ["type"],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "RARE"
|
||||
}
|
||||
}
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["type", "value"],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const yaml = parseTopLevelYaml(`
|
||||
dropRates:
|
||||
-
|
||||
type: RARE
|
||||
value: "not-a-number"
|
||||
-
|
||||
type: RARE
|
||||
value: 10
|
||||
`);
|
||||
|
||||
const diagnostics = validateParsedConfig(schema, yaml);
|
||||
|
||||
assert.ok(diagnostics.length > 0);
|
||||
assert.match(
|
||||
diagnostics[0].message,
|
||||
/dropRates\[0\]\.value/u);
|
||||
assert.match(
|
||||
diagnostics[0].message,
|
||||
/integer|整数/u);
|
||||
assert.equal(
|
||||
diagnostics.some((diagnostic) => /at least 2 items matching the 'contains' schema|至少需要包含 2 个匹配 contains 条件的元素/u.test(diagnostic.message)),
|
||||
false);
|
||||
assert.equal(
|
||||
diagnostics.some((diagnostic) => /at most \d+ items matching the 'contains' schema|最多只能包含 \d+ 个匹配 contains 条件的元素/u.test(diagnostic.message)),
|
||||
false);
|
||||
});
|
||||
|
||||
test("validateParsedConfig should continue contains match-count when items only have value-level violations", () => {
|
||||
const schema = parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dropRates": {
|
||||
"type": "array",
|
||||
"minContains": 1,
|
||||
"contains": {
|
||||
"type": "integer",
|
||||
"const": 7
|
||||
},
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"minimum": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const yaml = parseTopLevelYaml(`
|
||||
dropRates:
|
||||
- 5
|
||||
`);
|
||||
|
||||
const diagnostics = validateParsedConfig(schema, yaml);
|
||||
|
||||
assert.equal(diagnostics.length, 2);
|
||||
assert.match(diagnostics[0].message, /greater than or equal to 10|大于或等于 10/u);
|
||||
assert.match(diagnostics[1].message, /at least 1 items matching the 'contains' schema|至少需要包含 1 个匹配 contains 条件的元素/u);
|
||||
});
|
||||
|
||||
test("validateParsedConfig should report maxContains violations", () => {
|
||||
const schema = parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dropRates": {
|
||||
"type": "array",
|
||||
"maxContains": 1,
|
||||
"contains": {
|
||||
"type": "integer",
|
||||
"const": 5
|
||||
},
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const yaml = parseTopLevelYaml(`
|
||||
dropRates:
|
||||
- 5
|
||||
- 5
|
||||
- 7
|
||||
`);
|
||||
|
||||
const diagnostics = validateParsedConfig(schema, yaml);
|
||||
|
||||
assert.equal(diagnostics.length, 1);
|
||||
assert.match(diagnostics[0].message, /at most 1 items matching the 'contains' schema|最多只能包含 1 个匹配 contains 条件的元素/u);
|
||||
});
|
||||
|
||||
test("validateParsedConfig should accept satisfied contains constraints", () => {
|
||||
const schemaWithRange = parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dropRates": {
|
||||
"type": "array",
|
||||
"minContains": 2,
|
||||
"maxContains": 3,
|
||||
"contains": {
|
||||
"type": "integer",
|
||||
"const": 5
|
||||
},
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const yamlWithinRange = parseTopLevelYaml(`
|
||||
dropRates:
|
||||
- 0
|
||||
- 5
|
||||
- 5
|
||||
- 10
|
||||
`);
|
||||
|
||||
assert.deepEqual(validateParsedConfig(schemaWithRange, yamlWithinRange), []);
|
||||
|
||||
const schemaWithDefaultMinContains = parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dropRates": {
|
||||
"type": "array",
|
||||
"contains": {
|
||||
"type": "integer",
|
||||
"const": 5
|
||||
},
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const yamlSatisfyingDefaultMinContains = parseTopLevelYaml(`
|
||||
dropRates:
|
||||
- 1
|
||||
- 2
|
||||
- 5
|
||||
- 3
|
||||
`);
|
||||
|
||||
assert.deepEqual(validateParsedConfig(schemaWithDefaultMinContains, yamlSatisfyingDefaultMinContains), []);
|
||||
});
|
||||
|
||||
test("validateParsedConfig should allow object contains matches with additional declared item fields", () => {
|
||||
const schema = parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"minContains": 1,
|
||||
"contains": {
|
||||
"type": "object",
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"const": "boss"
|
||||
}
|
||||
}
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["id", "weight"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const yaml = parseTopLevelYaml(`
|
||||
entries:
|
||||
-
|
||||
id: boss
|
||||
weight: 10
|
||||
-
|
||||
id: slime
|
||||
weight: 3
|
||||
`);
|
||||
|
||||
assert.deepEqual(validateParsedConfig(schema, yaml), []);
|
||||
});
|
||||
|
||||
test("validateParsedConfig should accept large decimal multiples without floating-point drift", () => {
|
||||
const schema = parseSchemaContent(`
|
||||
{
|
||||
@ -1065,6 +1328,140 @@ test("parseSchemaContent should capture multipleOf and uniqueItems metadata", ()
|
||||
assert.equal(schema.properties.dropRates.items.multipleOf, 0.5);
|
||||
});
|
||||
|
||||
test("parseSchemaContent should capture contains metadata", () => {
|
||||
const schema = parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dropRates": {
|
||||
"type": "array",
|
||||
"minContains": 1,
|
||||
"maxContains": 2,
|
||||
"contains": {
|
||||
"type": "integer",
|
||||
"const": 5
|
||||
},
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
assert.equal(schema.properties.dropRates.minContains, 1);
|
||||
assert.equal(schema.properties.dropRates.maxContains, 2);
|
||||
assert.equal(schema.properties.dropRates.contains.type, "integer");
|
||||
assert.equal(schema.properties.dropRates.contains.constDisplayValue, "5");
|
||||
});
|
||||
|
||||
test("parseSchemaContent should reject nested-array contains schemas", () => {
|
||||
assert.throws(
|
||||
() => parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dropRates": {
|
||||
"type": "array",
|
||||
"contains": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`),
|
||||
/unsupported nested array 'contains' schemas/u);
|
||||
});
|
||||
|
||||
test("parseSchemaContent should reject minContains and maxContains without contains", () => {
|
||||
assert.throws(
|
||||
() => parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dropRates": {
|
||||
"type": "array",
|
||||
"minContains": 1,
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`),
|
||||
/'minContains' or 'maxContains' without 'contains'/u);
|
||||
|
||||
assert.throws(
|
||||
() => parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dropRates": {
|
||||
"type": "array",
|
||||
"maxContains": 1,
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`),
|
||||
/'minContains' or 'maxContains' without 'contains'/u);
|
||||
});
|
||||
|
||||
test("parseSchemaContent should reject contains schemas where default minContains exceeds maxContains", () => {
|
||||
assert.throws(
|
||||
() => parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dropRates": {
|
||||
"type": "array",
|
||||
"maxContains": 0,
|
||||
"contains": {
|
||||
"type": "integer",
|
||||
"const": 5
|
||||
},
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`),
|
||||
/'minContains' greater than 'maxContains'/u);
|
||||
});
|
||||
|
||||
test("parseSchemaContent should reject contains schemas where minContains is greater than maxContains", () => {
|
||||
assert.throws(
|
||||
() => parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dropRates": {
|
||||
"type": "array",
|
||||
"minContains": 3,
|
||||
"maxContains": 1,
|
||||
"contains": {
|
||||
"type": "integer",
|
||||
"const": 5
|
||||
},
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`),
|
||||
/'minContains' greater than 'maxContains'/u);
|
||||
});
|
||||
|
||||
test("parseSchemaContent should capture object property-count metadata", () => {
|
||||
const schema = parseSchemaContent(`
|
||||
{
|
||||
|
||||
95
tools/gframework-config-tool/test/containsSummary.test.js
Normal file
95
tools/gframework-config-tool/test/containsSummary.test.js
Normal file
@ -0,0 +1,95 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {buildContainsHintLines, describeContainsSchema} = require("../src/containsSummary");
|
||||
const {createLocalizer} = require("../src/localization");
|
||||
|
||||
test("describeContainsSchema should reuse localized Chinese hint strings", () => {
|
||||
const localizer = createLocalizer("zh-cn");
|
||||
|
||||
const summary = describeContainsSchema(
|
||||
{
|
||||
type: "string",
|
||||
constValue: "\"potion\"",
|
||||
constDisplayValue: "\"potion\"",
|
||||
refTable: "item"
|
||||
},
|
||||
localizer);
|
||||
|
||||
assert.equal(summary, "string, 固定值:\"potion\", 引用表:item");
|
||||
});
|
||||
|
||||
test("describeContainsSchema should fall back to localized item label", () => {
|
||||
const localizer = createLocalizer("en");
|
||||
|
||||
const summary = describeContainsSchema({}, localizer);
|
||||
|
||||
assert.equal(summary, "Item");
|
||||
});
|
||||
|
||||
test("buildContainsHintLines should include default minContains when schema omits it", () => {
|
||||
const localizer = createLocalizer("en");
|
||||
|
||||
const lines = buildContainsHintLines(
|
||||
{
|
||||
contains: {
|
||||
type: "integer",
|
||||
constValue: "5",
|
||||
constDisplayValue: "5"
|
||||
}
|
||||
},
|
||||
localizer);
|
||||
|
||||
assert.deepEqual(lines, [
|
||||
"Contains: integer, Const: 5",
|
||||
"Min contains: 1"
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildContainsHintLines should use explicit minContains when provided", () => {
|
||||
const localizer = createLocalizer("en");
|
||||
|
||||
const lines = buildContainsHintLines(
|
||||
{
|
||||
minContains: 2,
|
||||
contains: {
|
||||
type: "string",
|
||||
constValue: "\"potion\"",
|
||||
constDisplayValue: "\"potion\"",
|
||||
refTable: "item"
|
||||
}
|
||||
},
|
||||
localizer);
|
||||
|
||||
assert.deepEqual(lines, [
|
||||
"Contains: string, Const: \"potion\", Ref table: item",
|
||||
"Min contains: 2"
|
||||
]);
|
||||
});
|
||||
|
||||
test("describeContainsSchema should format enum-based contains schema in English", () => {
|
||||
const localizer = createLocalizer("en");
|
||||
|
||||
const summary = describeContainsSchema(
|
||||
{
|
||||
type: "string",
|
||||
enumValues: ["potion", "elixir"],
|
||||
refTable: "item"
|
||||
},
|
||||
localizer);
|
||||
|
||||
assert.equal(summary, "string, Allowed: potion, elixir, Ref table: item");
|
||||
});
|
||||
|
||||
test("describeContainsSchema should format pattern-based contains schema in Chinese", () => {
|
||||
const localizer = createLocalizer("zh-cn");
|
||||
|
||||
const summary = describeContainsSchema(
|
||||
{
|
||||
type: "string",
|
||||
pattern: "^potion-",
|
||||
refTable: "item"
|
||||
},
|
||||
localizer);
|
||||
|
||||
assert.equal(summary, "string, 正则模式:^potion-, 引用表:item");
|
||||
});
|
||||
@ -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 条件的元素。");
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user