mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-07 00:39:00 +08:00
Merge pull request #198 from GeWuYou/feat/config-schema-generator-and-validation
Feat/config schema generator and validation
This commit is contained in:
commit
5722b2a211
17
AGENTS.md
17
AGENTS.md
@ -10,6 +10,10 @@ All AI agents and contributors must follow these rules when writing, reviewing,
|
|||||||
- Use `@.ai/environment/tools.raw.yaml` only when you need the full collected facts behind the AI-facing hints.
|
- Use `@.ai/environment/tools.raw.yaml` only when you need the full collected facts behind the AI-facing hints.
|
||||||
- Prefer the project-relevant tools listed there instead of assuming every installed system tool is fair game.
|
- Prefer the project-relevant tools listed there instead of assuming every installed system tool is fair game.
|
||||||
- If the real environment differs from the inventory, use the project-relevant installed tool and report the mismatch.
|
- If the real environment differs from the inventory, use the project-relevant installed tool and report the mismatch.
|
||||||
|
- When working in WSL against this repository's Windows-backed worktree, prefer Windows Git from WSL (for example
|
||||||
|
`git.exe`) instead of the Linux `git` binary.
|
||||||
|
- If a Git command in WSL fails with a worktree-style “not a git repository” path translation error, rerun it with the
|
||||||
|
Windows Git executable and treat that as the repository-default Git path for the rest of the task.
|
||||||
|
|
||||||
## Commenting Rules (MUST)
|
## Commenting Rules (MUST)
|
||||||
|
|
||||||
@ -100,6 +104,10 @@ All generated or modified code MUST include clear and meaningful comments where
|
|||||||
- Keep `using` directives at the top of the file and sort them consistently.
|
- Keep `using` directives at the top of the file and sort them consistently.
|
||||||
- Separate logical blocks with blank lines when it improves readability.
|
- Separate logical blocks with blank lines when it improves readability.
|
||||||
- Prefer one primary type per file unless the surrounding project already uses a different local pattern.
|
- Prefer one primary type per file unless the surrounding project already uses a different local pattern.
|
||||||
|
- Unless there is a clear and documented reason to keep a file large, keep a single source file under roughly 800-1000
|
||||||
|
lines.
|
||||||
|
- If a file grows beyond that range, contributors MUST stop and check whether responsibilities should be split before
|
||||||
|
continuing; treating oversized files as the default is considered a design smell.
|
||||||
- Keep line length readable. Around 120 characters is the preferred upper bound.
|
- Keep line length readable. Around 120 characters is the preferred upper bound.
|
||||||
|
|
||||||
### C# Conventions
|
### C# Conventions
|
||||||
@ -114,6 +122,15 @@ All generated or modified code MUST include clear and meaningful comments where
|
|||||||
### Analyzer and Validation Expectations
|
### Analyzer and Validation Expectations
|
||||||
|
|
||||||
- The repository uses `Meziantou.Analyzer`; treat analyzer feedback as part of the coding standard.
|
- The repository uses `Meziantou.Analyzer`; treat analyzer feedback as part of the coding standard.
|
||||||
|
- Treat SonarQube maintainability rules as part of the coding standard as well, especially cognitive complexity and
|
||||||
|
oversized parameter list findings.
|
||||||
|
- When a method approaches analyzer complexity limits, prefer extracting named helper methods by semantic phase
|
||||||
|
(parsing, normalization, validation, diagnostics) instead of silencing the warning or doing cosmetic reshuffles.
|
||||||
|
- When a constructor or method exceeds parameter count limits, choose the refactor that matches the shape of the API:
|
||||||
|
use domain-specific value objects or parameter objects for naturally grouped data, and prefer named factory methods
|
||||||
|
when the call site is really selecting between different creation modes.
|
||||||
|
- Do not add suppressions for complexity or parameter-count findings unless the constraint is externally imposed and the
|
||||||
|
reason is documented in code comments.
|
||||||
- Naming must remain compatible with `scripts/validate-csharp-naming.sh`.
|
- Naming must remain compatible with `scripts/validate-csharp-naming.sh`.
|
||||||
|
|
||||||
## Testing Requirements
|
## Testing Requirements
|
||||||
|
|||||||
@ -510,6 +510,183 @@ public class YamlConfigLoaderTests
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证数值不满足 <c>multipleOf</c> 时会在运行时被拒绝。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void LoadAsync_Should_Throw_When_Number_Violates_MultipleOf()
|
||||||
|
{
|
||||||
|
CreateConfigFile(
|
||||||
|
"monster/slime.yaml",
|
||||||
|
"""
|
||||||
|
id: 1
|
||||||
|
name: Slime
|
||||||
|
hp: 12
|
||||||
|
""");
|
||||||
|
CreateSchemaFile(
|
||||||
|
"schemas/monster.schema.json",
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id", "name", "hp"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "integer" },
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"hp": {
|
||||||
|
"type": "integer",
|
||||||
|
"multipleOf": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
var loader = new YamlConfigLoader(_rootPath)
|
||||||
|
.RegisterTable<int, MonsterConfigStub>("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("hp"));
|
||||||
|
Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("12"));
|
||||||
|
Assert.That(exception.Message, Does.Contain("multiple of 5"));
|
||||||
|
Assert.That(registry.Count, Is.EqualTo(0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证大数值配合十进制步进时,会按十进制精确整倍数规则被运行时接受。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task LoadAsync_Should_Accept_Large_Decimal_Number_When_MultipleOf_Matches_Exact_Decimal_Step()
|
||||||
|
{
|
||||||
|
CreateConfigFile(
|
||||||
|
"monster/slime.yaml",
|
||||||
|
"""
|
||||||
|
id: 1
|
||||||
|
dropRate: 10000000.2
|
||||||
|
""");
|
||||||
|
CreateSchemaFile(
|
||||||
|
"schemas/monster.schema.json",
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id", "dropRate"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "integer" },
|
||||||
|
"dropRate": {
|
||||||
|
"type": "number",
|
||||||
|
"multipleOf": 0.1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
var loader = new YamlConfigLoader(_rootPath)
|
||||||
|
.RegisterTable<int, MonsterNumberConfigStub>("monster", "monster", "schemas/monster.schema.json",
|
||||||
|
static config => config.Id);
|
||||||
|
var registry = new ConfigRegistry();
|
||||||
|
|
||||||
|
await loader.LoadAsync(registry);
|
||||||
|
|
||||||
|
var table = registry.GetTable<int, MonsterNumberConfigStub>("monster");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(table.Count, Is.EqualTo(1));
|
||||||
|
Assert.That(table.Get(1).DropRate, Is.EqualTo(10000000.2d));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证大数量级但实际不满足 <c>multipleOf</c> 的数值会被运行时拒绝。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void LoadAsync_Should_Throw_When_Large_Number_Is_Not_Actually_MultipleOf()
|
||||||
|
{
|
||||||
|
CreateConfigFile(
|
||||||
|
"monster/slime.yaml",
|
||||||
|
"""
|
||||||
|
id: 1
|
||||||
|
dropRate: 1000000000000.4
|
||||||
|
""");
|
||||||
|
CreateSchemaFile(
|
||||||
|
"schemas/monster.schema.json",
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id", "dropRate"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "integer" },
|
||||||
|
"dropRate": {
|
||||||
|
"type": "number",
|
||||||
|
"multipleOf": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
var loader = new YamlConfigLoader(_rootPath)
|
||||||
|
.RegisterTable<int, MonsterNumberConfigStub>("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("dropRate"));
|
||||||
|
Assert.That(registry.Count, Is.EqualTo(0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证科学计数法数值会按 <c>number</c> 类型被运行时接受。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task LoadAsync_Should_Accept_Scientific_Notation_Number()
|
||||||
|
{
|
||||||
|
CreateConfigFile(
|
||||||
|
"monster/slime.yaml",
|
||||||
|
"""
|
||||||
|
id: 1
|
||||||
|
dropRate: 1.5e10
|
||||||
|
""");
|
||||||
|
CreateSchemaFile(
|
||||||
|
"schemas/monster.schema.json",
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id", "dropRate"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "integer" },
|
||||||
|
"dropRate": { "type": "number" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
var loader = new YamlConfigLoader(_rootPath)
|
||||||
|
.RegisterTable<int, MonsterNumberConfigStub>("monster", "monster", "schemas/monster.schema.json",
|
||||||
|
static config => config.Id);
|
||||||
|
var registry = new ConfigRegistry();
|
||||||
|
|
||||||
|
await loader.LoadAsync(registry);
|
||||||
|
|
||||||
|
var table = registry.GetTable<int, MonsterNumberConfigStub>("monster");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(table.Count, Is.EqualTo(1));
|
||||||
|
Assert.That(table.Get(1).DropRate, Is.EqualTo(1.5e10));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 验证字符串最小长度与最大长度约束会在运行时被统一拒绝。
|
/// 验证字符串最小长度与最大长度约束会在运行时被统一拒绝。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -762,6 +939,122 @@ public class YamlConfigLoaderTests
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证数组声明 <c>uniqueItems</c> 后,重复元素会在运行时被拒绝。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void LoadAsync_Should_Throw_When_Array_Violates_UniqueItems()
|
||||||
|
{
|
||||||
|
CreateConfigFile(
|
||||||
|
"monster/slime.yaml",
|
||||||
|
"""
|
||||||
|
id: 1
|
||||||
|
name: Slime
|
||||||
|
dropRates:
|
||||||
|
- 5
|
||||||
|
- 10
|
||||||
|
- 5
|
||||||
|
""");
|
||||||
|
CreateSchemaFile(
|
||||||
|
"schemas/monster.schema.json",
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id", "name", "dropRates"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "integer" },
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"dropRates": {
|
||||||
|
"type": "array",
|
||||||
|
"uniqueItems": true,
|
||||||
|
"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[2]"));
|
||||||
|
Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("5"));
|
||||||
|
Assert.That(exception.Message, Does.Contain("unique array items"));
|
||||||
|
Assert.That(registry.Count, Is.EqualTo(0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证 <c>uniqueItems</c> 的归一化键不会把带分隔符的不同对象值误判为重复项。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task LoadAsync_Should_Accept_Distinct_Object_Items_When_Comparable_Values_Contain_Separators()
|
||||||
|
{
|
||||||
|
CreateConfigFile(
|
||||||
|
"monster/slime.yaml",
|
||||||
|
"""
|
||||||
|
id: 1
|
||||||
|
entries:
|
||||||
|
-
|
||||||
|
a: "x|1:b=string:yz"
|
||||||
|
-
|
||||||
|
a: x
|
||||||
|
b: yz
|
||||||
|
""");
|
||||||
|
CreateSchemaFile(
|
||||||
|
"schemas/monster.schema.json",
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id", "entries"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "integer" },
|
||||||
|
"entries": {
|
||||||
|
"type": "array",
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"a": { "type": "string" },
|
||||||
|
"b": { "type": "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
var loader = new YamlConfigLoader(_rootPath)
|
||||||
|
.RegisterTable<int, MonsterComparableEntryArrayConfigStub>(
|
||||||
|
"monster",
|
||||||
|
"monster",
|
||||||
|
"schemas/monster.schema.json",
|
||||||
|
static config => config.Id);
|
||||||
|
var registry = new ConfigRegistry();
|
||||||
|
|
||||||
|
await loader.LoadAsync(registry);
|
||||||
|
|
||||||
|
var table = registry.GetTable<int, MonsterComparableEntryArrayConfigStub>("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].A, Is.EqualTo("x|1:b=string:yz"));
|
||||||
|
Assert.That(table.Get(1).Entries[1].A, Is.EqualTo("x"));
|
||||||
|
Assert.That(table.Get(1).Entries[1].B, Is.EqualTo("yz"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 验证启用 schema 校验后,未知字段不会再被静默忽略。
|
/// 验证启用 schema 校验后,未知字段不会再被静默忽略。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -1699,6 +1992,22 @@ public class YamlConfigLoaderTests
|
|||||||
public int Hp { get; set; }
|
public int Hp { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 用于浮点数 schema 校验测试的最小怪物配置类型。
|
||||||
|
/// </summary>
|
||||||
|
private sealed class MonsterNumberConfigStub
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置主键。
|
||||||
|
/// </summary>
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置浮点掉落率。
|
||||||
|
/// </summary>
|
||||||
|
public double DropRate { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 用于数组 schema 校验测试的最小怪物配置类型。
|
/// 用于数组 schema 校验测试的最小怪物配置类型。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -1778,6 +2087,22 @@ public class YamlConfigLoaderTests
|
|||||||
public IReadOnlyList<PhaseConfigStub> Phases { get; set; } = Array.Empty<PhaseConfigStub>();
|
public IReadOnlyList<PhaseConfigStub> Phases { get; set; } = Array.Empty<PhaseConfigStub>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 用于 <c>uniqueItems</c> 比较键碰撞回归测试的最小配置类型。
|
||||||
|
/// </summary>
|
||||||
|
private sealed class MonsterComparableEntryArrayConfigStub
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置主键。
|
||||||
|
/// </summary>
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置待比较对象数组。
|
||||||
|
/// </summary>
|
||||||
|
public List<ComparableEntryConfigStub> Entries { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 表示对象数组中的阶段元素。
|
/// 表示对象数组中的阶段元素。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -1794,6 +2119,22 @@ public class YamlConfigLoaderTests
|
|||||||
public string MonsterId { get; set; } = string.Empty;
|
public string MonsterId { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 表示用于比较键碰撞回归测试的对象数组元素。
|
||||||
|
/// </summary>
|
||||||
|
private sealed class ComparableEntryConfigStub
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置字段 A。
|
||||||
|
/// </summary>
|
||||||
|
public string A { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置字段 B。
|
||||||
|
/// </summary>
|
||||||
|
public string B { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 用于深层跨表引用测试的怪物配置类型。
|
/// 用于深层跨表引用测试的怪物配置类型。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -92,6 +92,7 @@ public class SchemaConfigGeneratorSnapshotTests
|
|||||||
"maximum": 999,
|
"maximum": 999,
|
||||||
"exclusiveMinimum": 0,
|
"exclusiveMinimum": 0,
|
||||||
"exclusiveMaximum": 1000,
|
"exclusiveMaximum": 1000,
|
||||||
|
"multipleOf": 5,
|
||||||
"default": 10
|
"default": 10
|
||||||
},
|
},
|
||||||
"dropItems": {
|
"dropItems": {
|
||||||
@ -99,6 +100,7 @@ public class SchemaConfigGeneratorSnapshotTests
|
|||||||
"type": "array",
|
"type": "array",
|
||||||
"minItems": 1,
|
"minItems": 1,
|
||||||
"maxItems": 3,
|
"maxItems": 3,
|
||||||
|
"uniqueItems": true,
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"minLength": 3,
|
"minLength": 3,
|
||||||
|
|||||||
@ -34,7 +34,7 @@ public sealed partial class MonsterConfig
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Schema property path: 'hp'.
|
/// Schema property path: 'hp'.
|
||||||
/// Constraints: minimum = 1, exclusiveMinimum = 0, maximum = 999, exclusiveMaximum = 1000.
|
/// Constraints: minimum = 1, exclusiveMinimum = 0, maximum = 999, exclusiveMaximum = 1000, multipleOf = 5.
|
||||||
/// Generated default initializer: = 10;
|
/// Generated default initializer: = 10;
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public int? Hp { get; set; } = 10;
|
public int? Hp { get; set; } = 10;
|
||||||
@ -45,7 +45,7 @@ public sealed partial class MonsterConfig
|
|||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Schema property path: 'dropItems'.
|
/// Schema property path: 'dropItems'.
|
||||||
/// Allowed values: potion, slime_gel.
|
/// Allowed values: potion, slime_gel.
|
||||||
/// Constraints: minItems = 1, maxItems = 3.
|
/// Constraints: minItems = 1, maxItems = 3, uniqueItems = true.
|
||||||
/// References config table: 'item'.
|
/// References config table: 'item'.
|
||||||
/// Item constraints: minLength = 3, maxLength = 12.
|
/// Item constraints: minLength = 3, maxLength = 12.
|
||||||
/// Generated default initializer: = new string[] { "potion" };
|
/// Generated default initializer: = new string[] { "potion" };
|
||||||
|
|||||||
@ -6,6 +6,8 @@ namespace GFramework.SourceGenerators.Config;
|
|||||||
/// 根据 AdditionalFiles 中的 JSON schema 生成配置类型和配置表包装。
|
/// 根据 AdditionalFiles 中的 JSON schema 生成配置类型和配置表包装。
|
||||||
/// 当前实现聚焦 AI-First 配置系统共享的最小 schema 子集,
|
/// 当前实现聚焦 AI-First 配置系统共享的最小 schema 子集,
|
||||||
/// 支持嵌套对象、对象数组、标量数组,以及可映射的 default / enum / ref-table 元数据。
|
/// 支持嵌套对象、对象数组、标量数组,以及可映射的 default / enum / ref-table 元数据。
|
||||||
|
/// 当前共享子集也会把 <c>multipleOf</c> 与 <c>uniqueItems</c> 写入生成代码文档,
|
||||||
|
/// 让消费者能直接在强类型 API 上看到运行时生效的约束。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Generator]
|
[Generator]
|
||||||
public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||||
@ -2430,7 +2432,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 将 shared schema 子集中的范围、长度、模式与数组数量约束整理成 XML 文档可读字符串。
|
/// 将 shared schema 子集中的范围、步进、长度、模式与数组数量 / 去重约束整理成 XML 文档可读字符串。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="element">Schema 节点。</param>
|
/// <param name="element">Schema 节点。</param>
|
||||||
/// <param name="schemaType">标量类型。</param>
|
/// <param name="schemaType">标量类型。</param>
|
||||||
@ -2463,6 +2465,13 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
|||||||
parts.Add($"exclusiveMaximum = {exclusiveMaximum.ToString(CultureInfo.InvariantCulture)}");
|
parts.Add($"exclusiveMaximum = {exclusiveMaximum.ToString(CultureInfo.InvariantCulture)}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((schemaType == "integer" || schemaType == "number") &&
|
||||||
|
TryGetFiniteNumber(element, "multipleOf", out var multipleOf) &&
|
||||||
|
multipleOf > 0d)
|
||||||
|
{
|
||||||
|
parts.Add($"multipleOf = {multipleOf.ToString(CultureInfo.InvariantCulture)}");
|
||||||
|
}
|
||||||
|
|
||||||
if (schemaType == "string" &&
|
if (schemaType == "string" &&
|
||||||
TryGetNonNegativeInt32(element, "minLength", out var minLength))
|
TryGetNonNegativeInt32(element, "minLength", out var minLength))
|
||||||
{
|
{
|
||||||
@ -2494,6 +2503,13 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
|||||||
parts.Add($"maxItems = {maxItems.ToString(CultureInfo.InvariantCulture)}");
|
parts.Add($"maxItems = {maxItems.ToString(CultureInfo.InvariantCulture)}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (schemaType == "array" &&
|
||||||
|
element.TryGetProperty("uniqueItems", out var uniqueItemsElement) &&
|
||||||
|
uniqueItemsElement.ValueKind == JsonValueKind.True)
|
||||||
|
{
|
||||||
|
parts.Add("uniqueItems = true");
|
||||||
|
}
|
||||||
|
|
||||||
return parts.Count > 0 ? string.Join(", ", parts) : null;
|
return parts.Count > 0 ? string.Join(", ", parts) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
- JSON Schema 作为结构描述
|
- JSON Schema 作为结构描述
|
||||||
- 一对象一文件的目录组织
|
- 一对象一文件的目录组织
|
||||||
- 运行时只读查询
|
- 运行时只读查询
|
||||||
- Runtime / Generator / Tooling 共享支持 `minimum`、`maximum`、`exclusiveMinimum`、`exclusiveMaximum`、`minLength`、`maxLength`、`pattern`、`minItems`、`maxItems`
|
- Runtime / Generator / Tooling 共享支持 `minimum`、`maximum`、`exclusiveMinimum`、`exclusiveMaximum`、`multipleOf`、`minLength`、`maxLength`、`pattern`、`minItems`、`maxItems`、`uniqueItems`
|
||||||
- Source Generator 生成配置类型、表包装、单表注册/访问辅助,以及项目级聚合注册目录
|
- Source Generator 生成配置类型、表包装、单表注册/访问辅助,以及项目级聚合注册目录
|
||||||
- VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口
|
- VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口
|
||||||
|
|
||||||
@ -53,7 +53,8 @@ GameProject/
|
|||||||
},
|
},
|
||||||
"hp": {
|
"hp": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"default": 10
|
"default": 10,
|
||||||
|
"multipleOf": 5
|
||||||
},
|
},
|
||||||
"rarity": {
|
"rarity": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@ -62,6 +63,7 @@ GameProject/
|
|||||||
"dropItems": {
|
"dropItems": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"description": "掉落物品表主键。",
|
"description": "掉落物品表主键。",
|
||||||
|
"uniqueItems": true,
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["potion", "slime_gel", "bomb"]
|
"enum": ["potion", "slime_gel", "bomb"]
|
||||||
@ -650,9 +652,11 @@ var loader = new YamlConfigLoader("config-root")
|
|||||||
- 对象数组元素结构不匹配
|
- 对象数组元素结构不匹配
|
||||||
- 数值字段违反 `minimum` / `maximum`
|
- 数值字段违反 `minimum` / `maximum`
|
||||||
- 数值字段违反 `exclusiveMinimum` / `exclusiveMaximum`
|
- 数值字段违反 `exclusiveMinimum` / `exclusiveMaximum`
|
||||||
|
- 数值字段违反 `multipleOf`
|
||||||
- 字符串字段违反 `minLength` / `maxLength`
|
- 字符串字段违反 `minLength` / `maxLength`
|
||||||
- 字符串字段违反 `pattern`
|
- 字符串字段违反 `pattern`
|
||||||
- 数组字段违反 `minItems` / `maxItems`
|
- 数组字段违反 `minItems` / `maxItems`
|
||||||
|
- 数组字段违反 `uniqueItems`
|
||||||
- 标量 `enum` 不匹配
|
- 标量 `enum` 不匹配
|
||||||
- 标量数组元素 `enum` 不匹配
|
- 标量数组元素 `enum` 不匹配
|
||||||
- 通过 `x-gframework-ref-table` 声明的跨表引用缺失目标行
|
- 通过 `x-gframework-ref-table` 声明的跨表引用缺失目标行
|
||||||
@ -702,9 +706,11 @@ if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var re
|
|||||||
- `enum`:供运行时校验、VS Code 校验和表单枚举选择复用
|
- `enum`:供运行时校验、VS Code 校验和表单枚举选择复用
|
||||||
- `minimum` / `maximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用
|
- `minimum` / `maximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用
|
||||||
- `exclusiveMinimum` / `exclusiveMaximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用
|
- `exclusiveMinimum` / `exclusiveMaximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用
|
||||||
|
- `multipleOf`:供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;当前优先按运行时与 JS 共用的十进制精确整倍数判定处理常见十进制步进,并在必要时退回浮点容差兜底
|
||||||
- `minLength` / `maxLength`:供运行时校验、VS Code 校验和生成代码 XML 文档复用
|
- `minLength` / `maxLength`:供运行时校验、VS Code 校验和生成代码 XML 文档复用
|
||||||
- `pattern`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用;当前按 C# `CultureInvariant` 与 JS 默认分组语义解释,非法模式会在 schema 解析阶段直接报错
|
- `pattern`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用;当前按 C# `CultureInvariant` 与 JS Unicode `u` 模式解释,非法模式会在 schema 解析阶段直接报错
|
||||||
- `minItems` / `maxItems`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用
|
- `minItems` / `maxItems`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用
|
||||||
|
- `uniqueItems`:供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;对象数组会按 schema 归一化后的结构比较重复项,而不是依赖 YAML 字段顺序
|
||||||
|
|
||||||
这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。
|
这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。
|
||||||
|
|
||||||
@ -801,7 +807,7 @@ var hotReload = loader.EnableHotReload(
|
|||||||
- 对带 `x-gframework-ref-table` 的字段提供引用 schema / 配置域 / 引用文件跳转入口
|
- 对带 `x-gframework-ref-table` 的字段提供引用 schema / 配置域 / 引用文件跳转入口
|
||||||
- 对空配置文件提供基于 schema 的示例 YAML 初始化入口
|
- 对空配置文件提供基于 schema 的示例 YAML 初始化入口
|
||||||
- 对同一配置域内的多份 YAML 文件执行批量字段更新
|
- 对同一配置域内的多份 YAML 文件执行批量字段更新
|
||||||
- 在表单和批量编辑入口中显示 `title / description / default / enum / ref-table` 元数据
|
- 在表单入口中显示 `title / description / default / enum / ref-table / multipleOf / uniqueItems` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息
|
||||||
|
|
||||||
当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。
|
当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,10 @@ const {
|
|||||||
} = require("./configPath");
|
} = require("./configPath");
|
||||||
const {ValidationMessageKeys} = require("./localizationKeys");
|
const {ValidationMessageKeys} = require("./localizationKeys");
|
||||||
|
|
||||||
|
const IntegerScalarPattern = /^[+-]?\d+$/u;
|
||||||
|
const NumberScalarPattern = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/u;
|
||||||
|
const BooleanScalarPattern = /^(true|false)$/iu;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse the repository's minimal config-schema subset into a recursive tree.
|
* Parse the repository's minimal config-schema subset into a recursive tree.
|
||||||
* The parser intentionally mirrors the same high-level contract used by the
|
* The parser intentionally mirrors the same high-level contract used by the
|
||||||
@ -262,11 +266,11 @@ function isScalarCompatible(expectedType, scalarValue) {
|
|||||||
const value = unquoteScalar(String(scalarValue));
|
const value = unquoteScalar(String(scalarValue));
|
||||||
switch (expectedType) {
|
switch (expectedType) {
|
||||||
case "integer":
|
case "integer":
|
||||||
return /^-?\d+$/u.test(value);
|
return IntegerScalarPattern.test(value);
|
||||||
case "number":
|
case "number":
|
||||||
return /^-?\d+(?:\.\d+)?$/u.test(value);
|
return NumberScalarPattern.test(value);
|
||||||
case "boolean":
|
case "boolean":
|
||||||
return /^(true|false)$/iu.test(value);
|
return BooleanScalarPattern.test(value);
|
||||||
case "string":
|
case "string":
|
||||||
return true;
|
return true;
|
||||||
default:
|
default:
|
||||||
@ -370,6 +374,16 @@ function normalizeSchemaNumber(value) {
|
|||||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize one strictly positive finite schema number.
|
||||||
|
*
|
||||||
|
* @param {unknown} value Raw schema value.
|
||||||
|
* @returns {number | undefined} Normalized positive number.
|
||||||
|
*/
|
||||||
|
function normalizeSchemaPositiveNumber(value) {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize one non-negative integer schema value for length constraints.
|
* Normalize one non-negative integer schema value for length constraints.
|
||||||
*
|
*
|
||||||
@ -380,6 +394,16 @@ function normalizeSchemaNonNegativeInteger(value) {
|
|||||||
return Number.isInteger(value) && value >= 0 ? value : undefined;
|
return Number.isInteger(value) && value >= 0 ? value : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize one boolean schema flag.
|
||||||
|
*
|
||||||
|
* @param {unknown} value Raw schema value.
|
||||||
|
* @returns {boolean | undefined} Normalized boolean.
|
||||||
|
*/
|
||||||
|
function normalizeSchemaBoolean(value) {
|
||||||
|
return typeof value === "boolean" ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize one schema pattern string when the regular expression can be
|
* Normalize one schema pattern string when the regular expression can be
|
||||||
* compiled by the local tooling runtime.
|
* compiled by the local tooling runtime.
|
||||||
@ -387,7 +411,7 @@ function normalizeSchemaNonNegativeInteger(value) {
|
|||||||
* @param {unknown} value Raw schema value.
|
* @param {unknown} value Raw schema value.
|
||||||
* @param {string} displayPath Logical property path used in diagnostics.
|
* @param {string} displayPath Logical property path used in diagnostics.
|
||||||
* @throws {Error} Thrown when the pattern string cannot be compiled.
|
* @throws {Error} Thrown when the pattern string cannot be compiled.
|
||||||
* @returns {string | undefined} Normalized pattern string.
|
* @returns {{source: string, regex: RegExp} | undefined} Normalized pattern metadata.
|
||||||
*/
|
*/
|
||||||
function normalizeSchemaPattern(value, displayPath) {
|
function normalizeSchemaPattern(value, displayPath) {
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
@ -395,8 +419,10 @@ function normalizeSchemaPattern(value, displayPath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
void new RegExp(value);
|
return {
|
||||||
return value;
|
source: value,
|
||||||
|
regex: new RegExp(value, "u")
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Schema property '${displayPath}' declares an invalid 'pattern' regular expression: ${error.message}`);
|
throw new Error(`Schema property '${displayPath}' declares an invalid 'pattern' regular expression: ${error.message}`);
|
||||||
}
|
}
|
||||||
@ -434,24 +460,124 @@ function formatSchemaDefaultValue(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test one scalar value against one schema pattern string.
|
* Test one scalar value against one compiled schema pattern.
|
||||||
*
|
*
|
||||||
* @param {string} scalarValue Scalar value from YAML.
|
* @param {string} scalarValue Scalar value from YAML.
|
||||||
* @param {string | undefined} pattern Schema pattern string.
|
* @param {RegExp | undefined} patternRegex Compiled schema pattern.
|
||||||
* @param {string} displayPath Logical property path used in diagnostics.
|
|
||||||
* @throws {Error} Thrown when the pattern string cannot be compiled.
|
|
||||||
* @returns {boolean} True when the value matches or no pattern is declared.
|
* @returns {boolean} True when the value matches or no pattern is declared.
|
||||||
*/
|
*/
|
||||||
function matchesSchemaPattern(scalarValue, pattern, displayPath) {
|
function matchesSchemaPattern(scalarValue, patternRegex) {
|
||||||
if (typeof pattern !== "string") {
|
if (!(patternRegex instanceof RegExp)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return patternRegex.test(scalarValue);
|
||||||
return new RegExp(pattern).test(scalarValue);
|
}
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Schema property '${displayPath}' declares an invalid 'pattern' regular expression: ${error.message}`);
|
/**
|
||||||
|
* Test whether one numeric scalar satisfies a multipleOf constraint.
|
||||||
|
*
|
||||||
|
* @param {string} scalarValue YAML scalar value.
|
||||||
|
* @param {number | undefined} multipleOf Schema multipleOf value.
|
||||||
|
* @returns {boolean} True when compatible or the constraint is absent.
|
||||||
|
*/
|
||||||
|
function matchesSchemaMultipleOf(scalarValue, multipleOf) {
|
||||||
|
if (typeof multipleOf !== "number") {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const exactDecimalResult = tryMatchesExactDecimalMultiple(scalarValue, String(multipleOf));
|
||||||
|
if (exactDecimalResult !== null) {
|
||||||
|
return exactDecimalResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericValue = Number(scalarValue);
|
||||||
|
const quotient = numericValue / multipleOf;
|
||||||
|
const nearestInteger = Math.round(quotient);
|
||||||
|
const tolerance = 1e-9 * Math.max(1, Math.abs(quotient));
|
||||||
|
return Math.abs(quotient - nearestInteger) <= tolerance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to evaluate one multipleOf constraint using exact decimal arithmetic.
|
||||||
|
* This keeps common YAML / JSON decimal literals aligned with the runtime and
|
||||||
|
* avoids large-number false positives that a pure floating-point quotient check can miss.
|
||||||
|
*
|
||||||
|
* @param {string} valueText YAML scalar text.
|
||||||
|
* @param {string} divisorText Schema multipleOf text.
|
||||||
|
* @returns {boolean | null} Exact result, or null when the inputs cannot be normalized exactly.
|
||||||
|
*/
|
||||||
|
function tryMatchesExactDecimalMultiple(valueText, divisorText) {
|
||||||
|
const valueParts = tryParseExactDecimal(valueText);
|
||||||
|
const divisorParts = tryParseExactDecimal(divisorText);
|
||||||
|
if (!valueParts || !divisorParts || divisorParts.significand === 0n) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const commonScale = Math.max(valueParts.scale, divisorParts.scale);
|
||||||
|
const scaledValue = scaleDecimalSignificand(valueParts.significand, valueParts.scale, commonScale);
|
||||||
|
const scaledDivisor = scaleDecimalSignificand(divisorParts.significand, divisorParts.scale, commonScale);
|
||||||
|
return scaledValue % scaledDivisor === 0n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a finite decimal literal into an integer significand plus decimal scale.
|
||||||
|
* The normalized form lets multipleOf checks run as integer modulo instead of floating-point math.
|
||||||
|
*
|
||||||
|
* @param {string} text Numeric text to normalize.
|
||||||
|
* @returns {{significand: bigint, scale: number} | null} Normalized parts, or null for unsupported input.
|
||||||
|
*/
|
||||||
|
function tryParseExactDecimal(text) {
|
||||||
|
const match = /^([+-]?)(?:(\d+)(?:\.(\d*))?|\.(\d+))(?:[eE]([+-]?\d+))?$/u.exec(String(text).trim());
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exponent = match[5] ? Number.parseInt(match[5], 10) : 0;
|
||||||
|
if (!Number.isSafeInteger(exponent)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const integerDigits = match[2] ?? "";
|
||||||
|
const fractionDigits = match[3] !== undefined ? match[3] : (match[4] ?? "");
|
||||||
|
let digits = `${integerDigits}${fractionDigits}`.replace(/^0+/u, "");
|
||||||
|
if (digits.length === 0) {
|
||||||
|
return {significand: 0n, scale: 0};
|
||||||
|
}
|
||||||
|
|
||||||
|
let scale = fractionDigits.length - exponent;
|
||||||
|
if (scale < 0) {
|
||||||
|
digits += "0".repeat(-scale);
|
||||||
|
scale = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (scale > 0 && digits.endsWith("0")) {
|
||||||
|
digits = digits.slice(0, -1);
|
||||||
|
scale -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let significand = BigInt(digits);
|
||||||
|
if (match[1] === "-") {
|
||||||
|
significand = -significand;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {significand, scale};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scale one normalized decimal significand to a larger decimal precision.
|
||||||
|
*
|
||||||
|
* @param {bigint} significand Integer significand.
|
||||||
|
* @param {number} currentScale Current decimal scale.
|
||||||
|
* @param {number} targetScale Target decimal scale.
|
||||||
|
* @returns {bigint} Scaled significand.
|
||||||
|
*/
|
||||||
|
function scaleDecimalSignificand(significand, currentScale, targetScale) {
|
||||||
|
if (currentScale === targetScale) {
|
||||||
|
return significand;
|
||||||
|
}
|
||||||
|
|
||||||
|
return significand * (10n ** BigInt(targetScale - currentScale));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -461,7 +587,7 @@ function matchesSchemaPattern(scalarValue, pattern, displayPath) {
|
|||||||
* @returns {string} YAML-ready scalar.
|
* @returns {string} YAML-ready scalar.
|
||||||
*/
|
*/
|
||||||
function formatYamlScalar(value) {
|
function formatYamlScalar(value) {
|
||||||
if (/^-?\d+(?:\.\d+)?$/u.test(value) || /^(true|false)$/iu.test(value)) {
|
if (NumberScalarPattern.test(value) || BooleanScalarPattern.test(value)) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -497,6 +623,7 @@ function unquoteScalar(value) {
|
|||||||
function parseSchemaNode(rawNode, displayPath) {
|
function parseSchemaNode(rawNode, displayPath) {
|
||||||
const value = rawNode && typeof rawNode === "object" ? rawNode : {};
|
const value = rawNode && typeof rawNode === "object" ? rawNode : {};
|
||||||
const type = typeof value.type === "string" ? value.type : "object";
|
const type = typeof value.type === "string" ? value.type : "object";
|
||||||
|
const patternMetadata = normalizeSchemaPattern(value.pattern, displayPath);
|
||||||
const metadata = {
|
const metadata = {
|
||||||
title: typeof value.title === "string" ? value.title : undefined,
|
title: typeof value.title === "string" ? value.title : undefined,
|
||||||
description: typeof value.description === "string" ? value.description : undefined,
|
description: typeof value.description === "string" ? value.description : undefined,
|
||||||
@ -505,11 +632,14 @@ function parseSchemaNode(rawNode, displayPath) {
|
|||||||
exclusiveMinimum: normalizeSchemaNumber(value.exclusiveMinimum),
|
exclusiveMinimum: normalizeSchemaNumber(value.exclusiveMinimum),
|
||||||
maximum: normalizeSchemaNumber(value.maximum),
|
maximum: normalizeSchemaNumber(value.maximum),
|
||||||
exclusiveMaximum: normalizeSchemaNumber(value.exclusiveMaximum),
|
exclusiveMaximum: normalizeSchemaNumber(value.exclusiveMaximum),
|
||||||
|
multipleOf: normalizeSchemaPositiveNumber(value.multipleOf),
|
||||||
minLength: normalizeSchemaNonNegativeInteger(value.minLength),
|
minLength: normalizeSchemaNonNegativeInteger(value.minLength),
|
||||||
maxLength: normalizeSchemaNonNegativeInteger(value.maxLength),
|
maxLength: normalizeSchemaNonNegativeInteger(value.maxLength),
|
||||||
pattern: normalizeSchemaPattern(value.pattern, displayPath),
|
pattern: patternMetadata ? patternMetadata.source : undefined,
|
||||||
|
patternRegex: patternMetadata ? patternMetadata.regex : undefined,
|
||||||
minItems: normalizeSchemaNonNegativeInteger(value.minItems),
|
minItems: normalizeSchemaNonNegativeInteger(value.minItems),
|
||||||
maxItems: normalizeSchemaNonNegativeInteger(value.maxItems),
|
maxItems: normalizeSchemaNonNegativeInteger(value.maxItems),
|
||||||
|
uniqueItems: normalizeSchemaBoolean(value.uniqueItems),
|
||||||
refTable: typeof value["x-gframework-ref-table"] === "string"
|
refTable: typeof value["x-gframework-ref-table"] === "string"
|
||||||
? value["x-gframework-ref-table"]
|
? value["x-gframework-ref-table"]
|
||||||
: undefined
|
: undefined
|
||||||
@ -545,6 +675,7 @@ function parseSchemaNode(rawNode, displayPath) {
|
|||||||
defaultValue: metadata.defaultValue,
|
defaultValue: metadata.defaultValue,
|
||||||
minItems: metadata.minItems,
|
minItems: metadata.minItems,
|
||||||
maxItems: metadata.maxItems,
|
maxItems: metadata.maxItems,
|
||||||
|
uniqueItems: metadata.uniqueItems === true,
|
||||||
refTable: metadata.refTable,
|
refTable: metadata.refTable,
|
||||||
items: itemNode
|
items: itemNode
|
||||||
};
|
};
|
||||||
@ -568,6 +699,9 @@ function parseSchemaNode(rawNode, displayPath) {
|
|||||||
exclusiveMaximum: type === "integer" || type === "number"
|
exclusiveMaximum: type === "integer" || type === "number"
|
||||||
? metadata.exclusiveMaximum
|
? metadata.exclusiveMaximum
|
||||||
: undefined,
|
: undefined,
|
||||||
|
multipleOf: type === "integer" || type === "number"
|
||||||
|
? metadata.multipleOf
|
||||||
|
: undefined,
|
||||||
minLength: type === "string"
|
minLength: type === "string"
|
||||||
? metadata.minLength
|
? metadata.minLength
|
||||||
: undefined,
|
: undefined,
|
||||||
@ -577,6 +711,9 @@ function parseSchemaNode(rawNode, displayPath) {
|
|||||||
pattern: type === "string"
|
pattern: type === "string"
|
||||||
? metadata.pattern
|
? metadata.pattern
|
||||||
: undefined,
|
: undefined,
|
||||||
|
patternRegex: type === "string"
|
||||||
|
? metadata.patternRegex
|
||||||
|
: undefined,
|
||||||
enumValues: normalizeSchemaEnumValues(value.enum),
|
enumValues: normalizeSchemaEnumValues(value.enum),
|
||||||
refTable: metadata.refTable
|
refTable: metadata.refTable
|
||||||
};
|
};
|
||||||
@ -630,14 +767,42 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const comparableItems = [];
|
||||||
for (let index = 0; index < yamlNode.items.length; index += 1) {
|
for (let index = 0; index < yamlNode.items.length; index += 1) {
|
||||||
|
const diagnosticsBeforeValidation = diagnostics.length;
|
||||||
validateNode(
|
validateNode(
|
||||||
schemaNode.items,
|
schemaNode.items,
|
||||||
yamlNode.items[index],
|
yamlNode.items[index],
|
||||||
joinArrayIndexPath(displayPath, index),
|
joinArrayIndexPath(displayPath, index),
|
||||||
diagnostics,
|
diagnostics,
|
||||||
localizer);
|
localizer);
|
||||||
|
|
||||||
|
// Keep uniqueItems focused on values that are otherwise valid so a
|
||||||
|
// shape/type error does not also surface as a misleading duplicate.
|
||||||
|
if (diagnostics.length === diagnosticsBeforeValidation) {
|
||||||
|
comparableItems.push({index, node: yamlNode.items[index]});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (schemaNode.uniqueItems === true) {
|
||||||
|
const seenItems = new Map();
|
||||||
|
for (const {index, node} of comparableItems) {
|
||||||
|
const comparableValue = buildComparableNodeValue(schemaNode.items, node);
|
||||||
|
if (seenItems.has(comparableValue)) {
|
||||||
|
diagnostics.push({
|
||||||
|
severity: "error",
|
||||||
|
message: localizeValidationMessage(ValidationMessageKeys.uniqueItemsViolation, localizer, {
|
||||||
|
displayPath: joinArrayIndexPath(displayPath, index),
|
||||||
|
duplicatePath: joinArrayIndexPath(displayPath, seenItems.get(comparableValue))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
seenItems.set(comparableValue, index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -729,6 +894,17 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (supportsNumericConstraints &&
|
||||||
|
!matchesSchemaMultipleOf(scalarValue, schemaNode.multipleOf)) {
|
||||||
|
diagnostics.push({
|
||||||
|
severity: "error",
|
||||||
|
message: localizeValidationMessage(ValidationMessageKeys.multipleOfViolation, localizer, {
|
||||||
|
displayPath,
|
||||||
|
value: String(schemaNode.multipleOf)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (supportsLengthConstraints &&
|
if (supportsLengthConstraints &&
|
||||||
typeof schemaNode.minLength === "number" &&
|
typeof schemaNode.minLength === "number" &&
|
||||||
scalarValue.length < schemaNode.minLength) {
|
scalarValue.length < schemaNode.minLength) {
|
||||||
@ -754,7 +930,7 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (supportsPatternConstraints &&
|
if (supportsPatternConstraints &&
|
||||||
!matchesSchemaPattern(scalarValue, schemaNode.pattern, schemaNode.displayPath)) {
|
!matchesSchemaPattern(scalarValue, schemaNode.patternRegex)) {
|
||||||
diagnostics.push({
|
diagnostics.push({
|
||||||
severity: "error",
|
severity: "error",
|
||||||
message: localizeValidationMessage(ValidationMessageKeys.patternViolation, localizer, {
|
message: localizeValidationMessage(ValidationMessageKeys.patternViolation, localizer, {
|
||||||
@ -824,6 +1000,57 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build one schema-aware comparable key for uniqueItems checks.
|
||||||
|
*
|
||||||
|
* @param {SchemaNode} schemaNode Schema node.
|
||||||
|
* @param {YamlNode | undefined} yamlNode YAML node.
|
||||||
|
* @returns {string} Comparable key.
|
||||||
|
*/
|
||||||
|
function buildComparableNodeValue(schemaNode, yamlNode) {
|
||||||
|
if (!yamlNode) {
|
||||||
|
return "missing";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schemaNode.type === "object") {
|
||||||
|
if (yamlNode.kind !== "object") {
|
||||||
|
return yamlNode.kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(schemaNode.properties)
|
||||||
|
.filter((key) => yamlNode.map.has(key))
|
||||||
|
.sort((left, right) => left.localeCompare(right))
|
||||||
|
.map((key) => {
|
||||||
|
const valueKey = buildComparableNodeValue(schemaNode.properties[key], yamlNode.map.get(key));
|
||||||
|
return `${key.length}:${key}=${valueKey.length}:${valueKey}`;
|
||||||
|
})
|
||||||
|
.join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schemaNode.type === "array") {
|
||||||
|
if (yamlNode.kind !== "array") {
|
||||||
|
return yamlNode.kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `[${yamlNode.items.map((item) => {
|
||||||
|
const valueKey = buildComparableNodeValue(schemaNode.items, item);
|
||||||
|
return `${valueKey.length}:${valueKey}`;
|
||||||
|
}).join(",")}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (yamlNode.kind !== "scalar") {
|
||||||
|
return yamlNode.kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scalarValue = unquoteScalar(yamlNode.value);
|
||||||
|
const normalizedScalar = schemaNode.type === "integer" || schemaNode.type === "number"
|
||||||
|
? String(Number(scalarValue))
|
||||||
|
: schemaNode.type === "boolean"
|
||||||
|
? String(/^true$/iu.test(scalarValue))
|
||||||
|
: scalarValue;
|
||||||
|
return `${schemaNode.type}:${normalizedScalar.length}:${normalizedScalar}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format one validation message in either English or Simplified Chinese.
|
* Format one validation message in either English or Simplified Chinese.
|
||||||
*
|
*
|
||||||
@ -859,12 +1086,16 @@ function localizeValidationMessage(key, localizer, params) {
|
|||||||
return `属性“${params.displayPath}”长度必须不超过 ${params.value} 个字符。`;
|
return `属性“${params.displayPath}”长度必须不超过 ${params.value} 个字符。`;
|
||||||
case ValidationMessageKeys.minimumViolation:
|
case ValidationMessageKeys.minimumViolation:
|
||||||
return `属性“${params.displayPath}”必须大于或等于 ${params.value}。`;
|
return `属性“${params.displayPath}”必须大于或等于 ${params.value}。`;
|
||||||
|
case ValidationMessageKeys.multipleOfViolation:
|
||||||
|
return `属性“${params.displayPath}”必须是 ${params.value} 的整数倍。`;
|
||||||
case ValidationMessageKeys.minItemsViolation:
|
case ValidationMessageKeys.minItemsViolation:
|
||||||
return `属性“${params.displayPath}”至少需要包含 ${params.value} 个元素。`;
|
return `属性“${params.displayPath}”至少需要包含 ${params.value} 个元素。`;
|
||||||
case ValidationMessageKeys.minLengthViolation:
|
case ValidationMessageKeys.minLengthViolation:
|
||||||
return `属性“${params.displayPath}”长度必须至少为 ${params.value} 个字符。`;
|
return `属性“${params.displayPath}”长度必须至少为 ${params.value} 个字符。`;
|
||||||
case ValidationMessageKeys.patternViolation:
|
case ValidationMessageKeys.patternViolation:
|
||||||
return `属性“${params.displayPath}”必须匹配正则模式“${params.value}”。`;
|
return `属性“${params.displayPath}”必须匹配正则模式“${params.value}”。`;
|
||||||
|
case ValidationMessageKeys.uniqueItemsViolation:
|
||||||
|
return `属性“${params.displayPath}”与更早的数组元素 ${params.duplicatePath} 重复;该数组要求元素唯一。`;
|
||||||
case ValidationMessageKeys.expectedObject:
|
case ValidationMessageKeys.expectedObject:
|
||||||
return params.subject;
|
return params.subject;
|
||||||
case ValidationMessageKeys.missingRequired:
|
case ValidationMessageKeys.missingRequired:
|
||||||
@ -897,12 +1128,16 @@ function localizeValidationMessage(key, localizer, params) {
|
|||||||
return `Property '${params.displayPath}' must be at most ${params.value} characters long.`;
|
return `Property '${params.displayPath}' must be at most ${params.value} characters long.`;
|
||||||
case ValidationMessageKeys.minimumViolation:
|
case ValidationMessageKeys.minimumViolation:
|
||||||
return `Property '${params.displayPath}' must be greater than or equal to ${params.value}.`;
|
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.minItemsViolation:
|
case ValidationMessageKeys.minItemsViolation:
|
||||||
return `Property '${params.displayPath}' must contain at least ${params.value} items.`;
|
return `Property '${params.displayPath}' must contain at least ${params.value} items.`;
|
||||||
case ValidationMessageKeys.minLengthViolation:
|
case ValidationMessageKeys.minLengthViolation:
|
||||||
return `Property '${params.displayPath}' must be at least ${params.value} characters long.`;
|
return `Property '${params.displayPath}' must be at least ${params.value} characters long.`;
|
||||||
case ValidationMessageKeys.patternViolation:
|
case ValidationMessageKeys.patternViolation:
|
||||||
return `Property '${params.displayPath}' must match pattern '${params.value}'.`;
|
return `Property '${params.displayPath}' must match pattern '${params.value}'.`;
|
||||||
|
case ValidationMessageKeys.uniqueItemsViolation:
|
||||||
|
return `Property '${params.displayPath}' duplicates earlier array item '${params.duplicatePath}', but uniqueItems is required.`;
|
||||||
case ValidationMessageKeys.expectedObject:
|
case ValidationMessageKeys.expectedObject:
|
||||||
return params.subject;
|
return params.subject;
|
||||||
case ValidationMessageKeys.missingRequired:
|
case ValidationMessageKeys.missingRequired:
|
||||||
@ -1558,6 +1793,7 @@ module.exports = {
|
|||||||
* defaultValue?: string,
|
* defaultValue?: string,
|
||||||
* minItems?: number,
|
* minItems?: number,
|
||||||
* maxItems?: number,
|
* maxItems?: number,
|
||||||
|
* uniqueItems?: boolean,
|
||||||
* refTable?: string,
|
* refTable?: string,
|
||||||
* items: SchemaNode
|
* items: SchemaNode
|
||||||
* } | {
|
* } | {
|
||||||
@ -1570,9 +1806,11 @@ module.exports = {
|
|||||||
* exclusiveMinimum?: number,
|
* exclusiveMinimum?: number,
|
||||||
* maximum?: number,
|
* maximum?: number,
|
||||||
* exclusiveMaximum?: number,
|
* exclusiveMaximum?: number,
|
||||||
|
* multipleOf?: number,
|
||||||
* minLength?: number,
|
* minLength?: number,
|
||||||
* maxLength?: number,
|
* maxLength?: number,
|
||||||
* pattern?: string,
|
* pattern?: string,
|
||||||
|
* patternRegex?: RegExp,
|
||||||
* enumValues?: string[],
|
* enumValues?: string[],
|
||||||
* refTable?: string
|
* refTable?: string
|
||||||
* }} SchemaNode
|
* }} SchemaNode
|
||||||
|
|||||||
@ -1574,7 +1574,7 @@ function getScalarArrayValue(yamlNode) {
|
|||||||
/**
|
/**
|
||||||
* Render human-facing metadata hints for one schema field.
|
* Render human-facing metadata hints for one schema field.
|
||||||
*
|
*
|
||||||
* @param {{description?: string, defaultValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, minLength?: number, maxLength?: number, pattern?: string, minItems?: number, maxItems?: number, enumValues?: string[], items?: {enumValues?: string[], minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, minLength?: number, maxLength?: number, pattern?: string}, refTable?: string}} propertySchema Property schema metadata.
|
* @param {{description?: string, defaultValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string, minItems?: number, maxItems?: number, uniqueItems?: boolean, enumValues?: string[], items?: {enumValues?: 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} isArrayField Whether the field is an array.
|
||||||
* @returns {string} HTML fragment.
|
* @returns {string} HTML fragment.
|
||||||
*/
|
*/
|
||||||
@ -1614,6 +1614,10 @@ function renderFieldHint(propertySchema, isArrayField) {
|
|||||||
hints.push(escapeHtml(localizer.t("webview.hint.exclusiveMaximum", {value: propertySchema.exclusiveMaximum})));
|
hints.push(escapeHtml(localizer.t("webview.hint.exclusiveMaximum", {value: propertySchema.exclusiveMaximum})));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isArrayField && typeof propertySchema.multipleOf === "number") {
|
||||||
|
hints.push(escapeHtml(localizer.t("webview.hint.multipleOf", {value: propertySchema.multipleOf})));
|
||||||
|
}
|
||||||
|
|
||||||
if (!isArrayField && typeof propertySchema.minLength === "number") {
|
if (!isArrayField && typeof propertySchema.minLength === "number") {
|
||||||
hints.push(escapeHtml(localizer.t("webview.hint.minLength", {value: propertySchema.minLength})));
|
hints.push(escapeHtml(localizer.t("webview.hint.minLength", {value: propertySchema.minLength})));
|
||||||
}
|
}
|
||||||
@ -1634,6 +1638,10 @@ function renderFieldHint(propertySchema, isArrayField) {
|
|||||||
hints.push(escapeHtml(localizer.t("webview.hint.maxItems", {value: propertySchema.maxItems})));
|
hints.push(escapeHtml(localizer.t("webview.hint.maxItems", {value: propertySchema.maxItems})));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isArrayField && propertySchema.uniqueItems === true) {
|
||||||
|
hints.push(escapeHtml(localizer.t("webview.hint.uniqueItems")));
|
||||||
|
}
|
||||||
|
|
||||||
if (isArrayField && propertySchema.items && typeof propertySchema.items.minimum === "number") {
|
if (isArrayField && propertySchema.items && typeof propertySchema.items.minimum === "number") {
|
||||||
hints.push(escapeHtml(localizer.t("webview.hint.itemMinimum", {value: propertySchema.items.minimum})));
|
hints.push(escapeHtml(localizer.t("webview.hint.itemMinimum", {value: propertySchema.items.minimum})));
|
||||||
}
|
}
|
||||||
@ -1650,6 +1658,10 @@ function renderFieldHint(propertySchema, isArrayField) {
|
|||||||
hints.push(escapeHtml(localizer.t("webview.hint.itemExclusiveMaximum", {value: propertySchema.items.exclusiveMaximum})));
|
hints.push(escapeHtml(localizer.t("webview.hint.itemExclusiveMaximum", {value: propertySchema.items.exclusiveMaximum})));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isArrayField && propertySchema.items && typeof propertySchema.items.multipleOf === "number") {
|
||||||
|
hints.push(escapeHtml(localizer.t("webview.hint.itemMultipleOf", {value: propertySchema.items.multipleOf})));
|
||||||
|
}
|
||||||
|
|
||||||
if (isArrayField && propertySchema.items && typeof propertySchema.items.minLength === "number") {
|
if (isArrayField && propertySchema.items && typeof propertySchema.items.minLength === "number") {
|
||||||
hints.push(escapeHtml(localizer.t("webview.hint.itemMinLength", {value: propertySchema.items.minLength})));
|
hints.push(escapeHtml(localizer.t("webview.hint.itemMinLength", {value: propertySchema.items.minLength})));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -109,15 +109,18 @@ const enMessages = {
|
|||||||
"webview.hint.exclusiveMinimum": "Exclusive minimum: {value}",
|
"webview.hint.exclusiveMinimum": "Exclusive minimum: {value}",
|
||||||
"webview.hint.maximum": "Maximum: {value}",
|
"webview.hint.maximum": "Maximum: {value}",
|
||||||
"webview.hint.exclusiveMaximum": "Exclusive maximum: {value}",
|
"webview.hint.exclusiveMaximum": "Exclusive maximum: {value}",
|
||||||
|
"webview.hint.multipleOf": "Multiple of: {value}",
|
||||||
"webview.hint.minLength": "Min length: {value}",
|
"webview.hint.minLength": "Min length: {value}",
|
||||||
"webview.hint.maxLength": "Max length: {value}",
|
"webview.hint.maxLength": "Max length: {value}",
|
||||||
"webview.hint.pattern": "Pattern: {value}",
|
"webview.hint.pattern": "Pattern: {value}",
|
||||||
"webview.hint.minItems": "Min items: {value}",
|
"webview.hint.minItems": "Min items: {value}",
|
||||||
"webview.hint.maxItems": "Max items: {value}",
|
"webview.hint.maxItems": "Max items: {value}",
|
||||||
|
"webview.hint.uniqueItems": "Items must be unique",
|
||||||
"webview.hint.itemMinimum": "Item minimum: {value}",
|
"webview.hint.itemMinimum": "Item minimum: {value}",
|
||||||
"webview.hint.itemExclusiveMinimum": "Item exclusive minimum: {value}",
|
"webview.hint.itemExclusiveMinimum": "Item exclusive minimum: {value}",
|
||||||
"webview.hint.itemMaximum": "Item maximum: {value}",
|
"webview.hint.itemMaximum": "Item maximum: {value}",
|
||||||
"webview.hint.itemExclusiveMaximum": "Item exclusive maximum: {value}",
|
"webview.hint.itemExclusiveMaximum": "Item exclusive maximum: {value}",
|
||||||
|
"webview.hint.itemMultipleOf": "Item multiple of: {value}",
|
||||||
"webview.hint.itemMinLength": "Item min length: {value}",
|
"webview.hint.itemMinLength": "Item min length: {value}",
|
||||||
"webview.hint.itemMaxLength": "Item max length: {value}",
|
"webview.hint.itemMaxLength": "Item max length: {value}",
|
||||||
"webview.hint.itemPattern": "Item pattern: {value}",
|
"webview.hint.itemPattern": "Item pattern: {value}",
|
||||||
@ -132,9 +135,11 @@ const enMessages = {
|
|||||||
[ValidationMessageKeys.maxItemsViolation]: "Property '{displayPath}' must contain at most {value} items.",
|
[ValidationMessageKeys.maxItemsViolation]: "Property '{displayPath}' must contain at most {value} items.",
|
||||||
[ValidationMessageKeys.maxLengthViolation]: "Property '{displayPath}' must be at most {value} characters long.",
|
[ValidationMessageKeys.maxLengthViolation]: "Property '{displayPath}' must be at most {value} characters long.",
|
||||||
[ValidationMessageKeys.minimumViolation]: "Property '{displayPath}' must be greater than or equal to {value}.",
|
[ValidationMessageKeys.minimumViolation]: "Property '{displayPath}' must be greater than or equal to {value}.",
|
||||||
|
[ValidationMessageKeys.multipleOfViolation]: "Property '{displayPath}' must be a multiple of {value}.",
|
||||||
[ValidationMessageKeys.minItemsViolation]: "Property '{displayPath}' must contain at least {value} items.",
|
[ValidationMessageKeys.minItemsViolation]: "Property '{displayPath}' must contain at least {value} items.",
|
||||||
[ValidationMessageKeys.minLengthViolation]: "Property '{displayPath}' must be at least {value} characters long.",
|
[ValidationMessageKeys.minLengthViolation]: "Property '{displayPath}' must be at least {value} characters long.",
|
||||||
[ValidationMessageKeys.patternViolation]: "Property '{displayPath}' must match pattern '{value}'.",
|
[ValidationMessageKeys.patternViolation]: "Property '{displayPath}' must match pattern '{value}'.",
|
||||||
|
[ValidationMessageKeys.uniqueItemsViolation]: "Property '{displayPath}' duplicates earlier array item '{duplicatePath}', but uniqueItems is required.",
|
||||||
[ValidationMessageKeys.enumMismatch]: "Property '{displayPath}' must be one of: {values}.",
|
[ValidationMessageKeys.enumMismatch]: "Property '{displayPath}' must be one of: {values}.",
|
||||||
[ValidationMessageKeys.expectedArray]: "Property '{displayPath}' is expected to be an array.",
|
[ValidationMessageKeys.expectedArray]: "Property '{displayPath}' is expected to be an array.",
|
||||||
[ValidationMessageKeys.expectedObject]: "{subject} is expected to be an object.",
|
[ValidationMessageKeys.expectedObject]: "{subject} is expected to be an object.",
|
||||||
@ -208,15 +213,18 @@ const zhCnMessages = {
|
|||||||
"webview.hint.exclusiveMinimum": "开区间最小值:{value}",
|
"webview.hint.exclusiveMinimum": "开区间最小值:{value}",
|
||||||
"webview.hint.maximum": "最大值:{value}",
|
"webview.hint.maximum": "最大值:{value}",
|
||||||
"webview.hint.exclusiveMaximum": "开区间最大值:{value}",
|
"webview.hint.exclusiveMaximum": "开区间最大值:{value}",
|
||||||
|
"webview.hint.multipleOf": "倍数约束:{value}",
|
||||||
"webview.hint.minLength": "最小长度:{value}",
|
"webview.hint.minLength": "最小长度:{value}",
|
||||||
"webview.hint.maxLength": "最大长度:{value}",
|
"webview.hint.maxLength": "最大长度:{value}",
|
||||||
"webview.hint.pattern": "正则模式:{value}",
|
"webview.hint.pattern": "正则模式:{value}",
|
||||||
"webview.hint.minItems": "最少元素数:{value}",
|
"webview.hint.minItems": "最少元素数:{value}",
|
||||||
"webview.hint.maxItems": "最多元素数:{value}",
|
"webview.hint.maxItems": "最多元素数:{value}",
|
||||||
|
"webview.hint.uniqueItems": "元素必须唯一",
|
||||||
"webview.hint.itemMinimum": "元素最小值:{value}",
|
"webview.hint.itemMinimum": "元素最小值:{value}",
|
||||||
"webview.hint.itemExclusiveMinimum": "元素开区间最小值:{value}",
|
"webview.hint.itemExclusiveMinimum": "元素开区间最小值:{value}",
|
||||||
"webview.hint.itemMaximum": "元素最大值:{value}",
|
"webview.hint.itemMaximum": "元素最大值:{value}",
|
||||||
"webview.hint.itemExclusiveMaximum": "元素开区间最大值:{value}",
|
"webview.hint.itemExclusiveMaximum": "元素开区间最大值:{value}",
|
||||||
|
"webview.hint.itemMultipleOf": "元素倍数约束:{value}",
|
||||||
"webview.hint.itemMinLength": "元素最小长度:{value}",
|
"webview.hint.itemMinLength": "元素最小长度:{value}",
|
||||||
"webview.hint.itemMaxLength": "元素最大长度:{value}",
|
"webview.hint.itemMaxLength": "元素最大长度:{value}",
|
||||||
"webview.hint.itemPattern": "元素正则模式:{value}",
|
"webview.hint.itemPattern": "元素正则模式:{value}",
|
||||||
@ -231,9 +239,11 @@ const zhCnMessages = {
|
|||||||
[ValidationMessageKeys.maxItemsViolation]: "属性“{displayPath}”最多只能包含 {value} 个元素。",
|
[ValidationMessageKeys.maxItemsViolation]: "属性“{displayPath}”最多只能包含 {value} 个元素。",
|
||||||
[ValidationMessageKeys.maxLengthViolation]: "属性“{displayPath}”长度必须不超过 {value} 个字符。",
|
[ValidationMessageKeys.maxLengthViolation]: "属性“{displayPath}”长度必须不超过 {value} 个字符。",
|
||||||
[ValidationMessageKeys.minimumViolation]: "属性“{displayPath}”必须大于或等于 {value}。",
|
[ValidationMessageKeys.minimumViolation]: "属性“{displayPath}”必须大于或等于 {value}。",
|
||||||
|
[ValidationMessageKeys.multipleOfViolation]: "属性“{displayPath}”必须是 {value} 的整数倍。",
|
||||||
[ValidationMessageKeys.minItemsViolation]: "属性“{displayPath}”至少需要包含 {value} 个元素。",
|
[ValidationMessageKeys.minItemsViolation]: "属性“{displayPath}”至少需要包含 {value} 个元素。",
|
||||||
[ValidationMessageKeys.minLengthViolation]: "属性“{displayPath}”长度必须至少为 {value} 个字符。",
|
[ValidationMessageKeys.minLengthViolation]: "属性“{displayPath}”长度必须至少为 {value} 个字符。",
|
||||||
[ValidationMessageKeys.patternViolation]: "属性“{displayPath}”必须匹配正则模式“{value}”。",
|
[ValidationMessageKeys.patternViolation]: "属性“{displayPath}”必须匹配正则模式“{value}”。",
|
||||||
|
[ValidationMessageKeys.uniqueItemsViolation]: "属性“{displayPath}”与更早的数组元素“{duplicatePath}”重复;该数组要求元素唯一。",
|
||||||
[ValidationMessageKeys.enumMismatch]: "属性“{displayPath}”必须是以下值之一:{values}。",
|
[ValidationMessageKeys.enumMismatch]: "属性“{displayPath}”必须是以下值之一:{values}。",
|
||||||
[ValidationMessageKeys.expectedArray]: "属性“{displayPath}”应为数组。",
|
[ValidationMessageKeys.expectedArray]: "属性“{displayPath}”应为数组。",
|
||||||
[ValidationMessageKeys.expectedObject]: "{subject}",
|
[ValidationMessageKeys.expectedObject]: "{subject}",
|
||||||
|
|||||||
@ -10,10 +10,12 @@ const ValidationMessageKeys = Object.freeze({
|
|||||||
maxItemsViolation: "validation.maxItemsViolation",
|
maxItemsViolation: "validation.maxItemsViolation",
|
||||||
maxLengthViolation: "validation.maxLengthViolation",
|
maxLengthViolation: "validation.maxLengthViolation",
|
||||||
minimumViolation: "validation.minimumViolation",
|
minimumViolation: "validation.minimumViolation",
|
||||||
|
multipleOfViolation: "validation.multipleOfViolation",
|
||||||
minItemsViolation: "validation.minItemsViolation",
|
minItemsViolation: "validation.minItemsViolation",
|
||||||
minLengthViolation: "validation.minLengthViolation",
|
minLengthViolation: "validation.minLengthViolation",
|
||||||
missingRequired: "validation.missingRequired",
|
missingRequired: "validation.missingRequired",
|
||||||
patternViolation: "validation.patternViolation",
|
patternViolation: "validation.patternViolation",
|
||||||
|
uniqueItemsViolation: "validation.uniqueItemsViolation",
|
||||||
unknownProperty: "validation.unknownProperty"
|
unknownProperty: "validation.unknownProperty"
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -308,6 +308,217 @@ tags:
|
|||||||
assert.match(diagnostics[1].message, /at most 3 items|最多只能包含 3 个元素/u);
|
assert.match(diagnostics[1].message, /at most 3 items|最多只能包含 3 个元素/u);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("validateParsedConfig should report multipleOf and uniqueItems violations", () => {
|
||||||
|
const schema = parseSchemaContent(`
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"hp": {
|
||||||
|
"type": "integer",
|
||||||
|
"multipleOf": 5
|
||||||
|
},
|
||||||
|
"phases": {
|
||||||
|
"type": "array",
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"wave": { "type": "integer" },
|
||||||
|
"monsterId": { "type": "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const yaml = parseTopLevelYaml(`
|
||||||
|
hp: 12
|
||||||
|
phases:
|
||||||
|
-
|
||||||
|
wave: 1
|
||||||
|
monsterId: slime
|
||||||
|
-
|
||||||
|
monsterId: slime
|
||||||
|
wave: 1
|
||||||
|
`);
|
||||||
|
|
||||||
|
const diagnostics = validateParsedConfig(schema, yaml);
|
||||||
|
|
||||||
|
assert.equal(diagnostics.length, 2);
|
||||||
|
assert.match(diagnostics[0].message, /multiple of 5|5 的整数倍/u);
|
||||||
|
assert.match(diagnostics[1].message, /phases\[1\]|uniqueItems|元素唯一/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateParsedConfig should accept large decimal multiples without floating-point drift", () => {
|
||||||
|
const schema = parseSchemaContent(`
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"dropRate": {
|
||||||
|
"type": "number",
|
||||||
|
"multipleOf": 0.1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const yaml = parseTopLevelYaml(`
|
||||||
|
dropRate: 10000000.2
|
||||||
|
`);
|
||||||
|
|
||||||
|
assert.deepEqual(validateParsedConfig(schema, yaml), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateParsedConfig should reject large numbers that are not actually multiples", () => {
|
||||||
|
const schema = parseSchemaContent(`
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"dropRate": {
|
||||||
|
"type": "number",
|
||||||
|
"multipleOf": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const yaml = parseTopLevelYaml(`
|
||||||
|
dropRate: 1000000000000.4
|
||||||
|
`);
|
||||||
|
|
||||||
|
const diagnostics = validateParsedConfig(schema, yaml);
|
||||||
|
|
||||||
|
assert.equal(diagnostics.length, 1);
|
||||||
|
assert.match(diagnostics[0].message, /multiple of 1|1 的整数倍/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateParsedConfig should accept scientific-notation numbers", () => {
|
||||||
|
const schema = parseSchemaContent(`
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"dropRate": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const yaml = parseTopLevelYaml(`
|
||||||
|
dropRate: 1.5e10
|
||||||
|
`);
|
||||||
|
|
||||||
|
assert.deepEqual(validateParsedConfig(schema, yaml), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateParsedConfig should apply schema patterns with Unicode semantics", () => {
|
||||||
|
const schema = parseSchemaContent(`
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^\\\\p{L}+$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const yaml = parseTopLevelYaml(`
|
||||||
|
name: 测试
|
||||||
|
`);
|
||||||
|
|
||||||
|
assert.deepEqual(validateParsedConfig(schema, yaml), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateParsedConfig should skip uniqueItems checks for invalid array items", () => {
|
||||||
|
const schema = parseSchemaContent(`
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"values": {
|
||||||
|
"type": "array",
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const yaml = parseTopLevelYaml(`
|
||||||
|
values:
|
||||||
|
-
|
||||||
|
id: 1
|
||||||
|
-
|
||||||
|
id: 2
|
||||||
|
`);
|
||||||
|
|
||||||
|
const diagnostics = validateParsedConfig(schema, yaml);
|
||||||
|
|
||||||
|
assert.equal(diagnostics.length, 2);
|
||||||
|
assert.match(diagnostics[0].message, /values\[0\]/u);
|
||||||
|
assert.match(diagnostics[1].message, /values\[1\]/u);
|
||||||
|
assert.ok(diagnostics.every((diagnostic) => !/uniqueItems|元素唯一/u.test(diagnostic.message)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateParsedConfig should report every uniqueItems duplicate in one pass", () => {
|
||||||
|
const schema = parseSchemaContent(`
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"tags": {
|
||||||
|
"type": "array",
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const yaml = parseTopLevelYaml(`
|
||||||
|
tags:
|
||||||
|
- alpha
|
||||||
|
- beta
|
||||||
|
- alpha
|
||||||
|
- beta
|
||||||
|
`);
|
||||||
|
|
||||||
|
const diagnostics = validateParsedConfig(schema, yaml);
|
||||||
|
|
||||||
|
assert.equal(diagnostics.length, 2);
|
||||||
|
assert.match(diagnostics[0].message, /tags\[2\]/u);
|
||||||
|
assert.match(diagnostics[1].message, /tags\[3\]/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateParsedConfig should avoid uniqueItems comparable-key collisions for distinct objects", () => {
|
||||||
|
const schema = parseSchemaContent(`
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"entries": {
|
||||||
|
"type": "array",
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"a": { "type": "string" },
|
||||||
|
"b": { "type": "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const yaml = parseTopLevelYaml(`
|
||||||
|
entries:
|
||||||
|
-
|
||||||
|
a: "x|1:b=string:yz"
|
||||||
|
-
|
||||||
|
a: x
|
||||||
|
b: yz
|
||||||
|
`);
|
||||||
|
|
||||||
|
assert.deepEqual(validateParsedConfig(schema, yaml), []);
|
||||||
|
});
|
||||||
|
|
||||||
test("parseSchemaContent should capture scalar range and length metadata", () => {
|
test("parseSchemaContent should capture scalar range and length metadata", () => {
|
||||||
const schema = parseSchemaContent(`
|
const schema = parseSchemaContent(`
|
||||||
{
|
{
|
||||||
@ -378,6 +589,32 @@ test("parseSchemaContent should capture exclusive bounds, pattern, and array ite
|
|||||||
assert.equal(schema.properties.tags.items.pattern, "^[a-z]+$");
|
assert.equal(schema.properties.tags.items.pattern, "^[a-z]+$");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("parseSchemaContent should capture multipleOf and uniqueItems metadata", () => {
|
||||||
|
const schema = parseSchemaContent(`
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"hp": {
|
||||||
|
"type": "integer",
|
||||||
|
"multipleOf": 5
|
||||||
|
},
|
||||||
|
"dropRates": {
|
||||||
|
"type": "array",
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": {
|
||||||
|
"type": "number",
|
||||||
|
"multipleOf": 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
assert.equal(schema.properties.hp.multipleOf, 5);
|
||||||
|
assert.equal(schema.properties.dropRates.uniqueItems, true);
|
||||||
|
assert.equal(schema.properties.dropRates.items.multipleOf, 0.5);
|
||||||
|
});
|
||||||
|
|
||||||
test("parseSchemaContent should reject invalid pattern declarations instead of dropping them", () => {
|
test("parseSchemaContent should reject invalid pattern declarations instead of dropping them", () => {
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => parseSchemaContent(`
|
() => parseSchemaContent(`
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user