mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-12 22:03:30 +08:00
Compare commits
16 Commits
5722b2a211
...
dd004738b3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd004738b3 | ||
|
|
58f362f7eb | ||
|
|
137a9427f9 | ||
|
|
41194d5d45 | ||
|
|
2f782d52f5 | ||
|
|
396397356c | ||
|
|
733fd4c7d2 | ||
|
|
9f73421532 | ||
|
|
8287bf37fc | ||
|
|
e28a1e4ecd | ||
|
|
0320404514 | ||
|
|
50161a2f28 | ||
|
|
a49c99c528 | ||
|
|
1a50d7af39 | ||
|
|
7931b41589 | ||
|
|
06d048f38a |
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@ -64,7 +64,7 @@ jobs:
|
||||
# 使用 TruffleHog 工具扫描代码库中的敏感信息泄露,如API密钥、密码等
|
||||
# 该步骤会比较基础分支和当前提交之间的差异,检测新增内容中是否包含敏感数据
|
||||
- name: TruffleHog OSS
|
||||
uses: trufflesecurity/trufflehog@v3.94.2
|
||||
uses: trufflesecurity/trufflehog@v3.94.3
|
||||
with:
|
||||
# 扫描路径,. 表示扫描整个仓库
|
||||
path: .
|
||||
@ -125,7 +125,7 @@ jobs:
|
||||
run: dotnet tool restore
|
||||
|
||||
- name: Setup Node.js 20
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
|
||||
@ -35,7 +35,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js 20
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
|
||||
6
.github/workflows/publish.yml
vendored
6
.github/workflows/publish.yml
vendored
@ -100,7 +100,7 @@ jobs:
|
||||
dotnet-version: 10.0.x
|
||||
|
||||
- name: Download package artifacts
|
||||
uses: actions/download-artifact@v5
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: packages
|
||||
path: ./packages
|
||||
@ -152,7 +152,7 @@ jobs:
|
||||
dotnet-version: 10.0.x
|
||||
|
||||
- name: Download package artifacts
|
||||
uses: actions/download-artifact@v5
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: packages
|
||||
path: ./packages
|
||||
@ -201,7 +201,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download package artifacts
|
||||
uses: actions/download-artifact@v5
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: packages
|
||||
path: ./packages
|
||||
|
||||
@ -14,6 +14,10 @@ All AI agents and contributors must follow these rules when writing, reviewing,
|
||||
`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.
|
||||
- If the shell does not currently resolve `git.exe` to the host Windows Git installation, prepend that installation's
|
||||
command directory to `PATH` and reset shell command hashing for the current session before continuing.
|
||||
- After resolving the host Windows Git path, prefer an explicit session-local binding for subsequent commands so the
|
||||
shell does not fall back to Linux `/usr/bin/git` later in the same WSL session.
|
||||
|
||||
## Commenting Rules (MUST)
|
||||
|
||||
|
||||
@ -18,11 +18,11 @@
|
||||
<Using Include="GFramework.Core.Abstractions"/>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Meziantou.Analyzer" Version="3.0.43">
|
||||
<PackageReference Update="Meziantou.Analyzer" Version="3.0.46">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Update="Meziantou.Polyfill" Version="1.0.106">
|
||||
<PackageReference Update="Meziantou.Polyfill" Version="1.0.109">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@ -15,14 +15,14 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0"/>
|
||||
<PackageReference Include="Moq" Version="4.20.72"/>
|
||||
<PackageReference Include="NUnit" Version="4.5.1"/>
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="6.2.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Scriban" Version="7.0.6" />
|
||||
<PackageReference Include="Scriban" Version="7.1.0" />
|
||||
<ProjectReference Include="..\GFramework.Core.Abstractions\GFramework.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\GFramework.Core\GFramework.Core.csproj"/>
|
||||
<ProjectReference Include="..\GFramework.SourceGenerators.Abstractions\GFramework.SourceGenerators.Abstractions.csproj"/>
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Meziantou.Polyfill" Version="1.0.106">
|
||||
<PackageReference Include="Meziantou.Polyfill" Version="1.0.109">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0"/>
|
||||
<PackageReference Include="Moq" Version="4.20.72"/>
|
||||
<PackageReference Include="NUnit" Version="4.5.1"/>
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="6.2.0"/>
|
||||
|
||||
@ -20,7 +20,7 @@
|
||||
<Using Include="GFramework.Game.Abstractions"/>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Meziantou.Analyzer" Version="3.0.43">
|
||||
<PackageReference Update="Meziantou.Analyzer" Version="3.0.46">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@ -363,6 +363,52 @@ public class YamlConfigLoaderTests
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证标量 <c>const</c> 限制会在运行时被拒绝。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void LoadAsync_Should_Throw_When_Scalar_Value_Does_Not_Match_Schema_Const()
|
||||
{
|
||||
CreateConfigFile(
|
||||
"monster/slime.yaml",
|
||||
"""
|
||||
id: 1
|
||||
name: Slime
|
||||
rarity: rare
|
||||
""");
|
||||
CreateSchemaFile(
|
||||
"schemas/monster.schema.json",
|
||||
"""
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["id", "name", "rarity"],
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"name": { "type": "string" },
|
||||
"rarity": {
|
||||
"type": "string",
|
||||
"const": "common"
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
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!.Message, Does.Contain("constant value"));
|
||||
Assert.That(exception.Message, Does.Contain("\"common\""));
|
||||
Assert.That(registry.Count, Is.EqualTo(0));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证数值最小值与最大值约束会在运行时被统一拒绝。
|
||||
/// </summary>
|
||||
@ -1198,6 +1244,58 @@ public class YamlConfigLoaderTests
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证数组 <c>const</c> 限制会保留元素顺序并按完整序列比较。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void LoadAsync_Should_Throw_When_Array_Value_Does_Not_Match_Schema_Const()
|
||||
{
|
||||
CreateConfigFile(
|
||||
"monster/slime.yaml",
|
||||
"""
|
||||
id: 1
|
||||
name: Slime
|
||||
dropItemIds:
|
||||
- gem
|
||||
- potion
|
||||
""");
|
||||
CreateSchemaFile(
|
||||
"schemas/monster.schema.json",
|
||||
"""
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["id", "name"],
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"name": { "type": "string" },
|
||||
"dropItemIds": {
|
||||
"type": "array",
|
||||
"const": ["potion", "gem"],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var loader = new YamlConfigLoader(_rootPath)
|
||||
.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!.Message, Does.Contain("dropItemIds"));
|
||||
Assert.That(exception.Message, Does.Contain("potion"));
|
||||
Assert.That(exception.Message, Does.Contain("gem"));
|
||||
Assert.That(registry.Count, Is.EqualTo(0));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证嵌套对象中的必填字段同样会按 schema 在运行时生效。
|
||||
/// </summary>
|
||||
@ -1248,6 +1346,380 @@ public class YamlConfigLoaderTests
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证嵌套对象 <c>const</c> 限制会按完整对象内容比较。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void LoadAsync_Should_Throw_When_Nested_Object_Does_Not_Match_Schema_Const()
|
||||
{
|
||||
CreateConfigFile(
|
||||
"monster/slime.yaml",
|
||||
"""
|
||||
id: 1
|
||||
name: Slime
|
||||
reward:
|
||||
gold: 10
|
||||
currency: gem
|
||||
""");
|
||||
CreateSchemaFile(
|
||||
"schemas/monster.schema.json",
|
||||
"""
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["id", "name", "reward"],
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"name": { "type": "string" },
|
||||
"reward": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"gold": { "type": "integer" },
|
||||
"currency": { "type": "string" }
|
||||
},
|
||||
"const": {
|
||||
"gold": 10,
|
||||
"currency": "coin"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var loader = new YamlConfigLoader(_rootPath)
|
||||
.RegisterTable<int, MonsterNestedConfigStub>("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!.Message, Does.Contain("reward"));
|
||||
Assert.That(exception.Message, Does.Contain("\"gold\""));
|
||||
Assert.That(exception.Message, Does.Contain("\"currency\""));
|
||||
Assert.That(exception.Message, Does.Contain("\"coin\""));
|
||||
Assert.That(registry.Count, Is.EqualTo(0));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证空对象 <c>const</c> 约束会被视为合法 schema,并与空 YAML 映射正确匹配。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task LoadAsync_Should_Accept_Empty_Object_Schema_Const()
|
||||
{
|
||||
CreateConfigFile(
|
||||
"monster/slime.yaml",
|
||||
"""
|
||||
id: 1
|
||||
name: Slime
|
||||
reward: {}
|
||||
""");
|
||||
CreateSchemaFile(
|
||||
"schemas/monster.schema.json",
|
||||
"""
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["id", "name", "reward"],
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"name": { "type": "string" },
|
||||
"reward": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"const": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var loader = new YamlConfigLoader(_rootPath)
|
||||
.RegisterTable<int, MonsterNestedConfigStub>("monster", "monster", "schemas/monster.schema.json",
|
||||
static config => config.Id);
|
||||
var registry = new ConfigRegistry();
|
||||
|
||||
await loader.LoadAsync(registry);
|
||||
|
||||
var table = registry.GetTable<int, MonsterNestedConfigStub>("monster");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(table.Count, Is.EqualTo(1));
|
||||
Assert.That(table.Get(1).Name, Is.EqualTo("Slime"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证对象字段不满足 <c>minProperties</c> 时会在运行时被拒绝。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void LoadAsync_Should_Throw_When_Object_Violates_MinProperties()
|
||||
{
|
||||
CreateConfigFile(
|
||||
"monster/slime.yaml",
|
||||
"""
|
||||
id: 1
|
||||
name: Slime
|
||||
reward:
|
||||
gold: 10
|
||||
""");
|
||||
CreateSchemaFile(
|
||||
"schemas/monster.schema.json",
|
||||
"""
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["id", "name", "reward"],
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"name": { "type": "string" },
|
||||
"reward": {
|
||||
"type": "object",
|
||||
"minProperties": 2,
|
||||
"properties": {
|
||||
"gold": { "type": "integer" },
|
||||
"currency": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var loader = new YamlConfigLoader(_rootPath)
|
||||
.RegisterTable<int, MonsterNestedConfigStub>("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("reward"));
|
||||
Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("1"));
|
||||
Assert.That(exception.Message, Does.Contain("at least 2 properties"));
|
||||
Assert.That(registry.Count, Is.EqualTo(0));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证对象字段不满足 <c>maxProperties</c> 时会在运行时被拒绝。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void LoadAsync_Should_Throw_When_Object_Violates_MaxProperties()
|
||||
{
|
||||
CreateConfigFile(
|
||||
"monster/slime.yaml",
|
||||
"""
|
||||
id: 1
|
||||
name: Slime
|
||||
reward:
|
||||
gold: 10
|
||||
currency: coin
|
||||
tier: epic
|
||||
""");
|
||||
CreateSchemaFile(
|
||||
"schemas/monster.schema.json",
|
||||
"""
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["id", "name", "reward"],
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"name": { "type": "string" },
|
||||
"reward": {
|
||||
"type": "object",
|
||||
"maxProperties": 2,
|
||||
"properties": {
|
||||
"gold": { "type": "integer" },
|
||||
"currency": { "type": "string" },
|
||||
"tier": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var loader = new YamlConfigLoader(_rootPath)
|
||||
.RegisterTable<int, MonsterNestedConfigStub>("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("reward"));
|
||||
Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("3"));
|
||||
Assert.That(exception.Message, Does.Contain("at most 2 properties"));
|
||||
Assert.That(registry.Count, Is.EqualTo(0));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证对象字段将 <c>minProperties</c> 声明为非法值时,会在 schema 解析阶段被拒绝。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void LoadAsync_Should_Throw_When_Object_Property_Count_Constraint_Is_Not_NonNegative_Integer()
|
||||
{
|
||||
CreateConfigFile(
|
||||
"monster/slime.yaml",
|
||||
"""
|
||||
id: 1
|
||||
name: Slime
|
||||
reward:
|
||||
gold: 10
|
||||
""");
|
||||
CreateSchemaFile(
|
||||
"schemas/monster.schema.json",
|
||||
"""
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["id", "name", "reward"],
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"name": { "type": "string" },
|
||||
"reward": {
|
||||
"type": "object",
|
||||
"minProperties": -1,
|
||||
"properties": {
|
||||
"gold": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var loader = new YamlConfigLoader(_rootPath)
|
||||
.RegisterTable<int, MonsterNestedConfigStub>("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("reward"));
|
||||
Assert.That(exception.Message, Does.Contain("minProperties"));
|
||||
Assert.That(exception.Message, Does.Contain("non-negative integer"));
|
||||
Assert.That(registry.Count, Is.EqualTo(0));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证对象字段将 <c>maxProperties</c> 声明为非整数数值时,会在 schema 解析阶段被拒绝。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void LoadAsync_Should_Throw_When_Object_MaxProperties_Constraint_Is_Not_Integer()
|
||||
{
|
||||
CreateConfigFile(
|
||||
"monster/slime.yaml",
|
||||
"""
|
||||
id: 1
|
||||
name: Slime
|
||||
reward:
|
||||
gold: 10
|
||||
currency: coin
|
||||
""");
|
||||
CreateSchemaFile(
|
||||
"schemas/monster.schema.json",
|
||||
"""
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["id", "name", "reward"],
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"name": { "type": "string" },
|
||||
"reward": {
|
||||
"type": "object",
|
||||
"maxProperties": 1.5,
|
||||
"properties": {
|
||||
"gold": { "type": "integer" },
|
||||
"currency": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var loader = new YamlConfigLoader(_rootPath)
|
||||
.RegisterTable<int, MonsterNestedConfigStub>("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("reward"));
|
||||
Assert.That(exception.Message, Does.Contain("maxProperties"));
|
||||
Assert.That(exception.Message, Does.Contain("non-negative integer"));
|
||||
Assert.That(registry.Count, Is.EqualTo(0));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证对象字段将 <c>minProperties</c> 声明为大于 <c>maxProperties</c> 时,会在 schema 解析阶段被拒绝。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void LoadAsync_Should_Throw_When_Object_Property_Count_Constraints_Are_Inverted()
|
||||
{
|
||||
CreateConfigFile(
|
||||
"monster/slime.yaml",
|
||||
"""
|
||||
id: 1
|
||||
name: Slime
|
||||
reward:
|
||||
gold: 10
|
||||
""");
|
||||
CreateSchemaFile(
|
||||
"schemas/monster.schema.json",
|
||||
"""
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["id", "name", "reward"],
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"name": { "type": "string" },
|
||||
"reward": {
|
||||
"type": "object",
|
||||
"minProperties": 3,
|
||||
"maxProperties": 2,
|
||||
"properties": {
|
||||
"gold": { "type": "integer" },
|
||||
"currency": { "type": "string" },
|
||||
"tier": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var loader = new YamlConfigLoader(_rootPath)
|
||||
.RegisterTable<int, MonsterNestedConfigStub>("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("reward"));
|
||||
Assert.That(exception.Message, Does.Contain("minProperties"));
|
||||
Assert.That(exception.Message, Does.Contain("greater than 'maxProperties'"));
|
||||
Assert.That(registry.Count, Is.EqualTo(0));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证对象数组中的嵌套字段也会按 schema 递归校验。
|
||||
/// </summary>
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0"/>
|
||||
<PackageReference Include="Moq" Version="4.20.72"/>
|
||||
<PackageReference Include="NUnit" Version="4.5.1"/>
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="6.2.0"/>
|
||||
|
||||
@ -8,8 +8,9 @@ namespace GFramework.Game.Config;
|
||||
/// 提供 YAML 配置文件与 JSON Schema 之间的最小运行时校验能力。
|
||||
/// 该校验器与当前配置生成器、VS Code 工具支持的 schema 子集保持一致,
|
||||
/// 并通过递归遍历方式覆盖嵌套对象、对象数组、标量数组与深层 enum / 引用约束。
|
||||
/// 当前共享子集额外支持 <c>multipleOf</c> 与 <c>uniqueItems</c>,
|
||||
/// 让数值步进和数组去重规则在运行时与生成器 / 工具侧保持一致。
|
||||
/// 当前共享子集额外支持 <c>multipleOf</c>、<c>uniqueItems</c>、
|
||||
/// <c>minProperties</c> 与 <c>maxProperties</c>,
|
||||
/// 让数值步进、数组去重和对象属性数量规则在运行时与生成器 / 工具侧保持一致。
|
||||
/// </summary>
|
||||
internal static class YamlConfigSchemaValidator
|
||||
{
|
||||
@ -321,7 +322,13 @@ internal static class YamlConfigSchemaValidator
|
||||
property.Value);
|
||||
}
|
||||
|
||||
return YamlConfigSchemaNode.CreateObject(properties, requiredProperties, schemaPath);
|
||||
var objectNode = YamlConfigSchemaNode.CreateObject(
|
||||
properties,
|
||||
requiredProperties,
|
||||
ParseObjectConstraints(tableName, schemaPath, propertyPath, element),
|
||||
schemaPath);
|
||||
return objectNode.WithConstantValue(
|
||||
ParseConstantValue(tableName, schemaPath, propertyPath, element, objectNode));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -381,10 +388,12 @@ internal static class YamlConfigSchemaValidator
|
||||
displayPath: GetDiagnosticPath(propertyPath));
|
||||
}
|
||||
|
||||
return YamlConfigSchemaNode.CreateArray(
|
||||
var arrayNode = YamlConfigSchemaNode.CreateArray(
|
||||
itemNode,
|
||||
ParseArrayConstraints(tableName, schemaPath, propertyPath, element),
|
||||
schemaPath);
|
||||
return arrayNode.WithConstantValue(
|
||||
ParseConstantValue(tableName, schemaPath, propertyPath, element, arrayNode));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -406,12 +415,14 @@ internal static class YamlConfigSchemaValidator
|
||||
string? referenceTableName)
|
||||
{
|
||||
EnsureReferenceKeywordIsSupported(tableName, schemaPath, propertyPath, nodeType, referenceTableName);
|
||||
return YamlConfigSchemaNode.CreateScalar(
|
||||
var scalarNode = YamlConfigSchemaNode.CreateScalar(
|
||||
nodeType,
|
||||
referenceTableName,
|
||||
ParseEnumValues(tableName, schemaPath, propertyPath, element, nodeType, "enum"),
|
||||
ParseScalarConstraints(tableName, schemaPath, propertyPath, element, nodeType),
|
||||
schemaPath);
|
||||
return scalarNode.WithConstantValue(
|
||||
ParseConstantValue(tableName, schemaPath, propertyPath, element, scalarNode));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -555,6 +566,68 @@ internal static class YamlConfigSchemaValidator
|
||||
schemaPath: schemaNode.SchemaPathHint,
|
||||
displayPath: requiredPath);
|
||||
}
|
||||
|
||||
if (schemaNode.ObjectConstraints is not null)
|
||||
{
|
||||
ValidateObjectConstraints(tableName, yamlPath, displayPath, seenProperties.Count, schemaNode);
|
||||
}
|
||||
|
||||
ValidateConstantValue(tableName, yamlPath, displayPath, mappingNode, schemaNode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验对象节点声明的属性数量约束。
|
||||
/// </summary>
|
||||
/// <param name="tableName">所属配置表名称。</param>
|
||||
/// <param name="yamlPath">YAML 文件路径。</param>
|
||||
/// <param name="displayPath">对象字段路径;根对象时为空。</param>
|
||||
/// <param name="propertyCount">当前对象实际属性数量。</param>
|
||||
/// <param name="schemaNode">对象 schema 节点。</param>
|
||||
private static void ValidateObjectConstraints(
|
||||
string tableName,
|
||||
string yamlPath,
|
||||
string displayPath,
|
||||
int propertyCount,
|
||||
YamlConfigSchemaNode schemaNode)
|
||||
{
|
||||
var constraints = schemaNode.ObjectConstraints;
|
||||
if (constraints is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var subject = string.IsNullOrWhiteSpace(displayPath)
|
||||
? "Root object"
|
||||
: $"Property '{displayPath}'";
|
||||
var rawValue = propertyCount.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
if (constraints.MinProperties.HasValue &&
|
||||
propertyCount < constraints.MinProperties.Value)
|
||||
{
|
||||
throw ConfigLoadExceptionFactory.Create(
|
||||
ConfigLoadFailureKind.ConstraintViolation,
|
||||
tableName,
|
||||
$"{subject} in config file '{yamlPath}' must contain at least {constraints.MinProperties.Value.ToString(CultureInfo.InvariantCulture)} properties.",
|
||||
yamlPath: yamlPath,
|
||||
schemaPath: schemaNode.SchemaPathHint,
|
||||
displayPath: GetDiagnosticPath(displayPath),
|
||||
rawValue: rawValue,
|
||||
detail: $"Minimum property count: {constraints.MinProperties.Value.ToString(CultureInfo.InvariantCulture)}.");
|
||||
}
|
||||
|
||||
if (constraints.MaxProperties.HasValue &&
|
||||
propertyCount > constraints.MaxProperties.Value)
|
||||
{
|
||||
throw ConfigLoadExceptionFactory.Create(
|
||||
ConfigLoadFailureKind.ConstraintViolation,
|
||||
tableName,
|
||||
$"{subject} in config file '{yamlPath}' must contain at most {constraints.MaxProperties.Value.ToString(CultureInfo.InvariantCulture)} properties.",
|
||||
yamlPath: yamlPath,
|
||||
schemaPath: schemaNode.SchemaPathHint,
|
||||
displayPath: GetDiagnosticPath(displayPath),
|
||||
rawValue: rawValue,
|
||||
detail: $"Maximum property count: {constraints.MaxProperties.Value.ToString(CultureInfo.InvariantCulture)}.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -613,6 +686,7 @@ internal static class YamlConfigSchemaValidator
|
||||
}
|
||||
|
||||
ValidateArrayUniqueItemsConstraint(tableName, yamlPath, displayPath, sequenceNode, schemaNode);
|
||||
ValidateConstantValue(tableName, yamlPath, displayPath, sequenceNode, schemaNode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -705,6 +779,8 @@ internal static class YamlConfigSchemaValidator
|
||||
ValidateScalarConstraints(tableName, yamlPath, displayPath, value, normalizedValue, schemaNode);
|
||||
}
|
||||
|
||||
ValidateConstantValue(tableName, yamlPath, displayPath, scalarNode, schemaNode);
|
||||
|
||||
if (schemaNode.ReferenceTableName != null &&
|
||||
references is not null)
|
||||
{
|
||||
@ -756,12 +832,250 @@ internal static class YamlConfigSchemaValidator
|
||||
foreach (var item in enumElement.EnumerateArray())
|
||||
{
|
||||
allowedValues.Add(
|
||||
NormalizeEnumValue(tableName, schemaPath, propertyPath, keywordName, expectedType, item));
|
||||
NormalizeKeywordScalarValue(tableName, schemaPath, propertyPath, keywordName, expectedType, item));
|
||||
}
|
||||
|
||||
return allowedValues;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析 <c>const</c>,并把 schema 常量预归一化成与运行时 YAML 相同的稳定比较键。
|
||||
/// 这样运行时只需要复用现有递归比较逻辑,而不必在每次加载时重新解释 JSON 常量。
|
||||
/// </summary>
|
||||
/// <param name="tableName">所属配置表名称。</param>
|
||||
/// <param name="schemaPath">Schema 文件路径。</param>
|
||||
/// <param name="propertyPath">字段路径。</param>
|
||||
/// <param name="element">Schema 节点。</param>
|
||||
/// <param name="schemaNode">已解析的 schema 节点。</param>
|
||||
/// <returns>常量约束模型;未声明时返回空。</returns>
|
||||
private static YamlConfigConstantValue? ParseConstantValue(
|
||||
string tableName,
|
||||
string schemaPath,
|
||||
string propertyPath,
|
||||
JsonElement element,
|
||||
YamlConfigSchemaNode schemaNode)
|
||||
{
|
||||
if (!element.TryGetProperty("const", out var constantElement))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new YamlConfigConstantValue(
|
||||
BuildComparableConstantValue(tableName, schemaPath, propertyPath, "const", constantElement, schemaNode),
|
||||
constantElement.GetRawText());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 把 schema 中的 <c>const</c> JSON 值转换成与 YAML 运行时一致的比较键。
|
||||
/// </summary>
|
||||
/// <param name="tableName">所属配置表名称。</param>
|
||||
/// <param name="schemaPath">Schema 文件路径。</param>
|
||||
/// <param name="propertyPath">字段路径。</param>
|
||||
/// <param name="keywordName">关键字名称。</param>
|
||||
/// <param name="element">常量 JSON 值。</param>
|
||||
/// <param name="schemaNode">目标 schema 节点。</param>
|
||||
/// <returns>可稳定比较的归一化键。</returns>
|
||||
private static string BuildComparableConstantValue(
|
||||
string tableName,
|
||||
string schemaPath,
|
||||
string propertyPath,
|
||||
string keywordName,
|
||||
JsonElement element,
|
||||
YamlConfigSchemaNode schemaNode)
|
||||
{
|
||||
return schemaNode.NodeType switch
|
||||
{
|
||||
YamlConfigSchemaPropertyType.Object => BuildComparableConstantObjectValue(
|
||||
tableName,
|
||||
schemaPath,
|
||||
propertyPath,
|
||||
keywordName,
|
||||
element,
|
||||
schemaNode),
|
||||
YamlConfigSchemaPropertyType.Array => BuildComparableConstantArrayValue(
|
||||
tableName,
|
||||
schemaPath,
|
||||
propertyPath,
|
||||
keywordName,
|
||||
element,
|
||||
schemaNode),
|
||||
YamlConfigSchemaPropertyType.Integer => BuildComparableConstantScalarValue(
|
||||
tableName,
|
||||
schemaPath,
|
||||
propertyPath,
|
||||
keywordName,
|
||||
element,
|
||||
schemaNode),
|
||||
YamlConfigSchemaPropertyType.Number => BuildComparableConstantScalarValue(
|
||||
tableName,
|
||||
schemaPath,
|
||||
propertyPath,
|
||||
keywordName,
|
||||
element,
|
||||
schemaNode),
|
||||
YamlConfigSchemaPropertyType.Boolean => BuildComparableConstantScalarValue(
|
||||
tableName,
|
||||
schemaPath,
|
||||
propertyPath,
|
||||
keywordName,
|
||||
element,
|
||||
schemaNode),
|
||||
YamlConfigSchemaPropertyType.String => BuildComparableConstantScalarValue(
|
||||
tableName,
|
||||
schemaPath,
|
||||
propertyPath,
|
||||
keywordName,
|
||||
element,
|
||||
schemaNode),
|
||||
_ => throw new InvalidOperationException($"Unsupported schema node type '{schemaNode.NodeType}'.")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建对象常量的稳定比较键。
|
||||
/// 这里同样忽略 JSON 对象字段顺序,避免 schema 文本格式影响常量比较结果。
|
||||
/// </summary>
|
||||
/// <param name="tableName">所属配置表名称。</param>
|
||||
/// <param name="schemaPath">Schema 文件路径。</param>
|
||||
/// <param name="propertyPath">字段路径。</param>
|
||||
/// <param name="keywordName">关键字名称。</param>
|
||||
/// <param name="element">常量 JSON 值。</param>
|
||||
/// <param name="schemaNode">对象 schema 节点。</param>
|
||||
/// <returns>对象常量的可比较键。</returns>
|
||||
private static string BuildComparableConstantObjectValue(
|
||||
string tableName,
|
||||
string schemaPath,
|
||||
string propertyPath,
|
||||
string keywordName,
|
||||
JsonElement element,
|
||||
YamlConfigSchemaNode schemaNode)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw ConfigLoadExceptionFactory.Create(
|
||||
ConfigLoadFailureKind.SchemaUnsupported,
|
||||
tableName,
|
||||
$"Property '{propertyPath}' in schema file '{schemaPath}' uses '{keywordName}', but only object values are compatible with schema type '{GetTypeName(schemaNode.NodeType)}'.",
|
||||
schemaPath: schemaPath,
|
||||
displayPath: GetDiagnosticPath(propertyPath));
|
||||
}
|
||||
|
||||
var properties = schemaNode.Properties
|
||||
?? throw new InvalidOperationException("Object schema nodes must expose declared properties.");
|
||||
var objectEntries = new List<KeyValuePair<string, string>>();
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (!properties.TryGetValue(property.Name, out var propertySchema))
|
||||
{
|
||||
var childPath = CombineSchemaPath(propertyPath, property.Name);
|
||||
throw ConfigLoadExceptionFactory.Create(
|
||||
ConfigLoadFailureKind.SchemaUnsupported,
|
||||
tableName,
|
||||
$"Property '{propertyPath}' in schema file '{schemaPath}' uses '{keywordName}', but nested property '{childPath}' is not declared in the object schema.",
|
||||
schemaPath: schemaPath,
|
||||
displayPath: GetDiagnosticPath(childPath));
|
||||
}
|
||||
|
||||
objectEntries.Add(
|
||||
new KeyValuePair<string, string>(
|
||||
property.Name,
|
||||
BuildComparableConstantValue(
|
||||
tableName,
|
||||
schemaPath,
|
||||
CombineSchemaPath(propertyPath, property.Name),
|
||||
keywordName,
|
||||
property.Value,
|
||||
propertySchema)));
|
||||
}
|
||||
|
||||
objectEntries.Sort(static (left, right) => string.CompareOrdinal(left.Key, right.Key));
|
||||
return string.Join(
|
||||
"|",
|
||||
objectEntries.Select(static entry =>
|
||||
$"{entry.Key.Length.ToString(CultureInfo.InvariantCulture)}:{entry.Key}={entry.Value.Length.ToString(CultureInfo.InvariantCulture)}:{entry.Value}"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建数组常量的稳定比较键。
|
||||
/// </summary>
|
||||
/// <param name="tableName">所属配置表名称。</param>
|
||||
/// <param name="schemaPath">Schema 文件路径。</param>
|
||||
/// <param name="propertyPath">字段路径。</param>
|
||||
/// <param name="keywordName">关键字名称。</param>
|
||||
/// <param name="element">常量 JSON 值。</param>
|
||||
/// <param name="schemaNode">数组 schema 节点。</param>
|
||||
/// <returns>数组常量的可比较键。</returns>
|
||||
private static string BuildComparableConstantArrayValue(
|
||||
string tableName,
|
||||
string schemaPath,
|
||||
string propertyPath,
|
||||
string keywordName,
|
||||
JsonElement element,
|
||||
YamlConfigSchemaNode schemaNode)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
throw ConfigLoadExceptionFactory.Create(
|
||||
ConfigLoadFailureKind.SchemaUnsupported,
|
||||
tableName,
|
||||
$"Property '{propertyPath}' in schema file '{schemaPath}' uses '{keywordName}', but only array values are compatible with schema type '{GetTypeName(schemaNode.NodeType)}'.",
|
||||
schemaPath: schemaPath,
|
||||
displayPath: GetDiagnosticPath(propertyPath));
|
||||
}
|
||||
|
||||
if (schemaNode.ItemNode is null)
|
||||
{
|
||||
throw new InvalidOperationException("Array schema nodes must expose their item schema.");
|
||||
}
|
||||
|
||||
return "[" +
|
||||
string.Join(
|
||||
",",
|
||||
element.EnumerateArray().Select(
|
||||
(item, index) =>
|
||||
{
|
||||
var comparableValue = BuildComparableConstantValue(
|
||||
tableName,
|
||||
schemaPath,
|
||||
$"{propertyPath}[{index}]",
|
||||
keywordName,
|
||||
item,
|
||||
schemaNode.ItemNode);
|
||||
return
|
||||
$"{comparableValue.Length.ToString(CultureInfo.InvariantCulture)}:{comparableValue}";
|
||||
})) +
|
||||
"]";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建标量常量的稳定比较键。
|
||||
/// </summary>
|
||||
/// <param name="tableName">所属配置表名称。</param>
|
||||
/// <param name="schemaPath">Schema 文件路径。</param>
|
||||
/// <param name="propertyPath">字段路径。</param>
|
||||
/// <param name="keywordName">关键字名称。</param>
|
||||
/// <param name="element">常量 JSON 值。</param>
|
||||
/// <param name="schemaNode">标量 schema 节点。</param>
|
||||
/// <returns>标量常量的可比较键。</returns>
|
||||
private static string BuildComparableConstantScalarValue(
|
||||
string tableName,
|
||||
string schemaPath,
|
||||
string propertyPath,
|
||||
string keywordName,
|
||||
JsonElement element,
|
||||
YamlConfigSchemaNode schemaNode)
|
||||
{
|
||||
var normalizedValue = NormalizeKeywordScalarValue(
|
||||
tableName,
|
||||
schemaPath,
|
||||
propertyPath,
|
||||
keywordName,
|
||||
schemaNode.NodeType,
|
||||
element);
|
||||
return
|
||||
$"{schemaNode.NodeType}:{normalizedValue.Length.ToString(CultureInfo.InvariantCulture)}:{normalizedValue}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析标量字段支持的范围、长度与模式约束。
|
||||
/// 当前共享子集支持:
|
||||
@ -870,6 +1184,49 @@ internal static class YamlConfigSchemaValidator
|
||||
: new YamlConfigArrayConstraints(minItems, maxItems, uniqueItems);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析对象节点支持的属性数量约束。
|
||||
/// </summary>
|
||||
/// <param name="tableName">所属配置表名称。</param>
|
||||
/// <param name="schemaPath">Schema 文件路径。</param>
|
||||
/// <param name="propertyPath">对象字段路径。</param>
|
||||
/// <param name="element">Schema 节点。</param>
|
||||
/// <returns>对象约束模型;未声明时返回空。</returns>
|
||||
private static YamlConfigObjectConstraints? ParseObjectConstraints(
|
||||
string tableName,
|
||||
string schemaPath,
|
||||
string propertyPath,
|
||||
JsonElement element)
|
||||
{
|
||||
var minProperties = TryParseObjectPropertyCountConstraint(
|
||||
tableName,
|
||||
schemaPath,
|
||||
propertyPath,
|
||||
element,
|
||||
"minProperties");
|
||||
var maxProperties = TryParseObjectPropertyCountConstraint(
|
||||
tableName,
|
||||
schemaPath,
|
||||
propertyPath,
|
||||
element,
|
||||
"maxProperties");
|
||||
|
||||
if (minProperties.HasValue && maxProperties.HasValue && minProperties.Value > maxProperties.Value)
|
||||
{
|
||||
var targetDescription = DescribeObjectSchemaTarget(propertyPath);
|
||||
throw ConfigLoadExceptionFactory.Create(
|
||||
ConfigLoadFailureKind.SchemaUnsupported,
|
||||
tableName,
|
||||
$"{targetDescription} in schema file '{schemaPath}' declares 'minProperties' greater than 'maxProperties'.",
|
||||
schemaPath: schemaPath,
|
||||
displayPath: GetDiagnosticPath(propertyPath));
|
||||
}
|
||||
|
||||
return !minProperties.HasValue && !maxProperties.HasValue
|
||||
? null
|
||||
: new YamlConfigObjectConstraints(minProperties, maxProperties);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取数值区间约束。
|
||||
/// </summary>
|
||||
@ -1100,6 +1457,56 @@ internal static class YamlConfigSchemaValidator
|
||||
return constraintValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取对象属性数量约束。
|
||||
/// </summary>
|
||||
/// <param name="tableName">所属配置表名称。</param>
|
||||
/// <param name="schemaPath">Schema 文件路径。</param>
|
||||
/// <param name="propertyPath">对象字段路径。</param>
|
||||
/// <param name="element">Schema 节点。</param>
|
||||
/// <param name="keywordName">关键字名称。</param>
|
||||
/// <returns>属性数量约束;未声明时返回空。</returns>
|
||||
private static int? TryParseObjectPropertyCountConstraint(
|
||||
string tableName,
|
||||
string schemaPath,
|
||||
string propertyPath,
|
||||
JsonElement element,
|
||||
string keywordName)
|
||||
{
|
||||
if (!element.TryGetProperty(keywordName, out var constraintElement))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (constraintElement.ValueKind != JsonValueKind.Number ||
|
||||
!constraintElement.TryGetInt32(out var constraintValue) ||
|
||||
constraintValue < 0)
|
||||
{
|
||||
var targetDescription = DescribeObjectSchemaTarget(propertyPath);
|
||||
throw ConfigLoadExceptionFactory.Create(
|
||||
ConfigLoadFailureKind.SchemaUnsupported,
|
||||
tableName,
|
||||
$"{targetDescription} in schema file '{schemaPath}' must declare '{keywordName}' as a non-negative integer.",
|
||||
schemaPath: schemaPath,
|
||||
displayPath: GetDiagnosticPath(propertyPath));
|
||||
}
|
||||
|
||||
return constraintValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为对象级 schema 关键字构造稳定的诊断主体。
|
||||
/// 根对象不会再显示为空字符串属性名,避免坏 schema 诊断出现 <c>Property ''</c> 之类的文本。
|
||||
/// </summary>
|
||||
/// <param name="propertyPath">对象字段路径。</param>
|
||||
/// <returns>用于错误消息的对象主体描述。</returns>
|
||||
private static string DescribeObjectSchemaTarget(string propertyPath)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(propertyPath)
|
||||
? "Root object"
|
||||
: $"Property '{propertyPath}'";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取数组去重约束。
|
||||
/// </summary>
|
||||
@ -1269,6 +1676,48 @@ internal static class YamlConfigSchemaValidator
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验节点值是否满足 <c>const</c> 约束。
|
||||
/// 该检查复用与 <c>uniqueItems</c> 相同的稳定比较键,保证对象字段顺序、数字字面量和布尔大小写不会造成伪差异。
|
||||
/// </summary>
|
||||
/// <param name="tableName">所属配置表名称。</param>
|
||||
/// <param name="yamlPath">YAML 文件路径。</param>
|
||||
/// <param name="displayPath">字段路径;根节点时为空。</param>
|
||||
/// <param name="node">当前 YAML 节点。</param>
|
||||
/// <param name="schemaNode">对应的 schema 节点。</param>
|
||||
private static void ValidateConstantValue(
|
||||
string tableName,
|
||||
string yamlPath,
|
||||
string displayPath,
|
||||
YamlNode node,
|
||||
YamlConfigSchemaNode schemaNode)
|
||||
{
|
||||
var constantValue = schemaNode.ConstantValue;
|
||||
if (constantValue is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var comparableValue = BuildComparableNodeValue(node, schemaNode);
|
||||
if (string.Equals(comparableValue, constantValue.ComparableValue, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var subject = string.IsNullOrWhiteSpace(displayPath)
|
||||
? "Root object"
|
||||
: $"Property '{displayPath}'";
|
||||
throw ConfigLoadExceptionFactory.Create(
|
||||
ConfigLoadFailureKind.ConstraintViolation,
|
||||
tableName,
|
||||
$"{subject} in config file '{yamlPath}' must match constant value {constantValue.DisplayValue}.",
|
||||
yamlPath: yamlPath,
|
||||
schemaPath: schemaNode.SchemaPathHint,
|
||||
displayPath: GetDiagnosticPath(displayPath),
|
||||
rawValue: DescribeYamlNodeForDiagnostics(node, schemaNode),
|
||||
detail: $"Required constant value: {constantValue.DisplayValue}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据已读取的数值关键字创建数值约束对象。
|
||||
/// 该分组让调用方不必再维护一个超过 Sonar 默认阈值的长参数构造函数。
|
||||
@ -1626,7 +2075,8 @@ internal static class YamlConfigSchemaValidator
|
||||
|
||||
/// <summary>
|
||||
/// 将一个已通过结构校验的 YAML 节点归一化为可比较字符串。
|
||||
/// 该键仅用于 <c>uniqueItems</c>,因此要忽略对象字段顺序和字符串引号形式。
|
||||
/// 该键同时服务于 <c>uniqueItems</c> 与 <c>const</c>,
|
||||
/// 因此要忽略对象字段顺序和字符串引号形式。
|
||||
/// </summary>
|
||||
/// <param name="node">YAML 节点。</param>
|
||||
/// <param name="schemaNode">对应 schema 节点。</param>
|
||||
@ -1989,16 +2439,16 @@ internal static class YamlConfigSchemaValidator
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将 schema 中的 enum 单值归一化到运行时比较字符串。
|
||||
/// 将 schema 关键字中的标量值归一化到运行时比较字符串。
|
||||
/// </summary>
|
||||
/// <param name="tableName">所属配置表名称。</param>
|
||||
/// <param name="schemaPath">Schema 文件路径。</param>
|
||||
/// <param name="propertyPath">字段路径。</param>
|
||||
/// <param name="keywordName">关键字名称。</param>
|
||||
/// <param name="expectedType">期望的标量类型。</param>
|
||||
/// <param name="item">当前枚举值节点。</param>
|
||||
/// <param name="item">当前关键字值节点。</param>
|
||||
/// <returns>归一化后的字符串值。</returns>
|
||||
private static string NormalizeEnumValue(
|
||||
private static string NormalizeKeywordScalarValue(
|
||||
string tableName,
|
||||
string schemaPath,
|
||||
string propertyPath,
|
||||
@ -2201,17 +2651,25 @@ internal sealed class YamlConfigSchemaNode
|
||||
/// </summary>
|
||||
/// <param name="properties">对象属性集合。</param>
|
||||
/// <param name="requiredProperties">对象必填属性集合。</param>
|
||||
/// <param name="objectConstraints">对象属性数量约束。</param>
|
||||
/// <param name="schemaPathHint">用于错误信息的 schema 文件路径提示。</param>
|
||||
/// <returns>对象节点模型。</returns>
|
||||
public static YamlConfigSchemaNode CreateObject(
|
||||
IReadOnlyDictionary<string, YamlConfigSchemaNode>? properties,
|
||||
IReadOnlyCollection<string>? requiredProperties,
|
||||
YamlConfigObjectConstraints? objectConstraints,
|
||||
string schemaPathHint)
|
||||
{
|
||||
return new YamlConfigSchemaNode(
|
||||
YamlConfigSchemaPropertyType.Object,
|
||||
new NodeChildren(properties, requiredProperties, itemNode: null),
|
||||
NodeValidation.None,
|
||||
new NodeValidation(
|
||||
referenceTableName: null,
|
||||
allowedValues: null,
|
||||
constraints: null,
|
||||
arrayConstraints: null,
|
||||
objectConstraints,
|
||||
constantValue: null),
|
||||
schemaPathHint);
|
||||
}
|
||||
|
||||
@ -2234,7 +2692,9 @@ internal sealed class YamlConfigSchemaNode
|
||||
referenceTableName: null,
|
||||
allowedValues: null,
|
||||
constraints: null,
|
||||
arrayConstraints),
|
||||
arrayConstraints,
|
||||
objectConstraints: null,
|
||||
constantValue: null),
|
||||
schemaPathHint);
|
||||
}
|
||||
|
||||
@ -2261,7 +2721,9 @@ internal sealed class YamlConfigSchemaNode
|
||||
referenceTableName,
|
||||
allowedValues,
|
||||
constraints,
|
||||
arrayConstraints: null),
|
||||
arrayConstraints: null,
|
||||
objectConstraints: null,
|
||||
constantValue: null),
|
||||
schemaPathHint);
|
||||
}
|
||||
|
||||
@ -2285,6 +2747,8 @@ internal sealed class YamlConfigSchemaNode
|
||||
AllowedValues = validation.AllowedValues;
|
||||
Constraints = validation.Constraints;
|
||||
ArrayConstraints = validation.ArrayConstraints;
|
||||
ObjectConstraints = validation.ObjectConstraints;
|
||||
ConstantValue = validation.ConstantValue;
|
||||
SchemaPathHint = schemaPathHint;
|
||||
}
|
||||
|
||||
@ -2323,11 +2787,21 @@ internal sealed class YamlConfigSchemaNode
|
||||
/// </summary>
|
||||
public YamlConfigScalarConstraints? Constraints { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取对象属性数量约束;未声明时返回空。
|
||||
/// </summary>
|
||||
public YamlConfigObjectConstraints? ObjectConstraints { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取数组元素数量约束;未声明时返回空。
|
||||
/// </summary>
|
||||
public YamlConfigArrayConstraints? ArrayConstraints { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取节点常量约束;未声明 <c>const</c> 时返回空。
|
||||
/// </summary>
|
||||
public YamlConfigConstantValue? ConstantValue { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取用于诊断显示的 schema 路径提示。
|
||||
/// 当前节点本身不记录独立路径,因此对象校验会回退到所属根 schema 路径。
|
||||
@ -2349,6 +2823,20 @@ internal sealed class YamlConfigSchemaNode
|
||||
SchemaPathHint);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 基于当前节点复制一个只替换常量约束的新节点。
|
||||
/// </summary>
|
||||
/// <param name="constantValue">新的常量约束。</param>
|
||||
/// <returns>复制后的节点。</returns>
|
||||
public YamlConfigSchemaNode WithConstantValue(YamlConfigConstantValue? constantValue)
|
||||
{
|
||||
return new YamlConfigSchemaNode(
|
||||
NodeType,
|
||||
_children,
|
||||
_validation.WithConstantValue(constantValue),
|
||||
SchemaPathHint);
|
||||
}
|
||||
|
||||
private sealed class NodeChildren
|
||||
{
|
||||
public static NodeChildren None { get; } = new(properties: null, requiredProperties: null, itemNode: null);
|
||||
@ -2376,18 +2864,24 @@ internal sealed class YamlConfigSchemaNode
|
||||
referenceTableName: null,
|
||||
allowedValues: null,
|
||||
constraints: null,
|
||||
arrayConstraints: null);
|
||||
arrayConstraints: null,
|
||||
objectConstraints: null,
|
||||
constantValue: null);
|
||||
|
||||
public NodeValidation(
|
||||
string? referenceTableName,
|
||||
IReadOnlyCollection<string>? allowedValues,
|
||||
YamlConfigScalarConstraints? constraints,
|
||||
YamlConfigArrayConstraints? arrayConstraints)
|
||||
YamlConfigArrayConstraints? arrayConstraints,
|
||||
YamlConfigObjectConstraints? objectConstraints,
|
||||
YamlConfigConstantValue? constantValue)
|
||||
{
|
||||
ReferenceTableName = referenceTableName;
|
||||
AllowedValues = allowedValues;
|
||||
Constraints = constraints;
|
||||
ArrayConstraints = arrayConstraints;
|
||||
ObjectConstraints = objectConstraints;
|
||||
ConstantValue = constantValue;
|
||||
}
|
||||
|
||||
public string? ReferenceTableName { get; }
|
||||
@ -2398,13 +2892,83 @@ internal sealed class YamlConfigSchemaNode
|
||||
|
||||
public YamlConfigArrayConstraints? ArrayConstraints { get; }
|
||||
|
||||
public YamlConfigObjectConstraints? ObjectConstraints { get; }
|
||||
|
||||
public YamlConfigConstantValue? ConstantValue { get; }
|
||||
|
||||
public NodeValidation WithReferenceTable(string referenceTableName)
|
||||
{
|
||||
return new NodeValidation(referenceTableName, AllowedValues, Constraints, ArrayConstraints);
|
||||
return new NodeValidation(referenceTableName, AllowedValues, Constraints, ArrayConstraints,
|
||||
ObjectConstraints, ConstantValue);
|
||||
}
|
||||
|
||||
public NodeValidation WithConstantValue(YamlConfigConstantValue? constantValue)
|
||||
{
|
||||
return new NodeValidation(ReferenceTableName, AllowedValues, Constraints, ArrayConstraints,
|
||||
ObjectConstraints, constantValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 表示一个节点上声明的 <c>const</c> 约束。
|
||||
/// 该模型同时保留稳定比较键与原始 JSON 文本,分别供运行时匹配和诊断输出复用。
|
||||
/// </summary>
|
||||
internal sealed class YamlConfigConstantValue
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化常量约束模型。
|
||||
/// </summary>
|
||||
/// <param name="comparableValue">用于与 YAML 节点比较的稳定键。</param>
|
||||
/// <param name="displayValue">用于诊断输出的原始常量文本。</param>
|
||||
public YamlConfigConstantValue(string comparableValue, string displayValue)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(comparableValue);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(displayValue);
|
||||
|
||||
ComparableValue = comparableValue;
|
||||
DisplayValue = displayValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取用于运行时比较的稳定键。
|
||||
/// </summary>
|
||||
public string ComparableValue { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取用于诊断输出的原始 JSON 常量文本。
|
||||
/// </summary>
|
||||
public string DisplayValue { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 表示一个对象节点上声明的属性数量约束。
|
||||
/// 该模型将对象级约束与数组 / 标量约束拆开保存,避免运行时节点继续暴露无关成员。
|
||||
/// </summary>
|
||||
internal sealed class YamlConfigObjectConstraints
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化对象约束模型。
|
||||
/// </summary>
|
||||
/// <param name="minProperties">最小属性数量约束。</param>
|
||||
/// <param name="maxProperties">最大属性数量约束。</param>
|
||||
public YamlConfigObjectConstraints(int? minProperties, int? maxProperties)
|
||||
{
|
||||
MinProperties = minProperties;
|
||||
MaxProperties = maxProperties;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取最小属性数量约束。
|
||||
/// </summary>
|
||||
public int? MinProperties { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取最大属性数量约束。
|
||||
/// </summary>
|
||||
public int? MaxProperties { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 聚合一个标量节点上声明的数值约束与字符串约束。
|
||||
/// 该包装层保留“标量字段有约束”的统一入口,同时把不同语义的约束分成更小的专用模型。
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
<Folder Include="logging\"/>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Meziantou.Analyzer" Version="3.0.43">
|
||||
<PackageReference Update="Meziantou.Analyzer" Version="3.0.46">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.Common" Version="4.14.0"/>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.14.0"/>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing" Version="1.1.3"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0"/>
|
||||
<PackageReference Include="NUnit" Version="4.5.1"/>
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="6.2.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0"/>
|
||||
<PackageReference Include="NUnit" Version="4.5.1"/>
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="6.2.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
<Using Include="GFramework.SourceGenerators.Abstractions"/>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Meziantou.Analyzer" Version="3.0.43">
|
||||
<PackageReference Update="Meziantou.Analyzer" Version="3.0.46">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@ -22,7 +22,7 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" PrivateAssets="all"/>
|
||||
<PackageReference Update="Meziantou.Analyzer" Version="3.0.43">
|
||||
<PackageReference Update="Meziantou.Analyzer" Version="3.0.46">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@ -69,6 +69,8 @@ public class SchemaConfigGeneratorSnapshotTests
|
||||
"title": "Monster Config",
|
||||
"description": "Represents one monster entry generated from schema metadata.",
|
||||
"type": "object",
|
||||
"minProperties": 4,
|
||||
"maxProperties": 8,
|
||||
"required": ["id", "name", "reward", "phases"],
|
||||
"properties": {
|
||||
"id": {
|
||||
@ -88,6 +90,7 @@ public class SchemaConfigGeneratorSnapshotTests
|
||||
},
|
||||
"hp": {
|
||||
"type": "integer",
|
||||
"const": 10,
|
||||
"minimum": 1,
|
||||
"maximum": 999,
|
||||
"exclusiveMinimum": 0,
|
||||
@ -113,6 +116,8 @@ public class SchemaConfigGeneratorSnapshotTests
|
||||
"reward": {
|
||||
"type": "object",
|
||||
"description": "Reward payload.",
|
||||
"minProperties": 2,
|
||||
"maxProperties": 2,
|
||||
"required": ["gold", "currency"],
|
||||
"properties": {
|
||||
"gold": {
|
||||
|
||||
@ -46,6 +46,51 @@ public class SchemaConfigGeneratorTests
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证空字符串 <c>const</c> 不会在生成 XML 文档时被当成“缺失约束”跳过。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Run_Should_Preserve_Empty_String_Const_In_Generated_Documentation()
|
||||
{
|
||||
const string source = """
|
||||
namespace TestApp
|
||||
{
|
||||
public sealed class Dummy
|
||||
{
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
const string schema = """
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["id", "name"],
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"name": {
|
||||
"type": "string",
|
||||
"const": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var result = SchemaGeneratorTestDriver.Run(
|
||||
source,
|
||||
("monster.schema.json", schema));
|
||||
|
||||
var generatedSources = result.Results
|
||||
.Single()
|
||||
.GeneratedSources
|
||||
.ToDictionary(
|
||||
static sourceResult => sourceResult.HintName,
|
||||
static sourceResult => sourceResult.SourceText.ToString(),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
Assert.That(result.Results.Single().Diagnostics, Is.Empty);
|
||||
Assert.That(generatedSources["MonsterConfig.g.cs"], Does.Contain("Constraints: const = \"\"."));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证深层不支持的数组嵌套会带着完整字段路径产生命名明确的诊断。
|
||||
/// </summary>
|
||||
@ -314,6 +359,48 @@ public class SchemaConfigGeneratorTests
|
||||
Does.Contain("Throwing here would permanently poison the cached index for this wrapper instance."));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证生成器对 <c>required</c> 名称保持大小写敏感,避免与运行时 validator 对同一 schema 产生分歧。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Run_Should_Treat_Required_Property_Names_As_Case_Sensitive()
|
||||
{
|
||||
const string source = """
|
||||
namespace TestApp
|
||||
{
|
||||
public sealed class Dummy
|
||||
{
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
const string schema = """
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["id", "Name"],
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"name": { "type": "string" }
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var result = SchemaGeneratorTestDriver.Run(
|
||||
source,
|
||||
("monster.schema.json", schema));
|
||||
|
||||
var generatedSources = result.Results
|
||||
.Single()
|
||||
.GeneratedSources
|
||||
.ToDictionary(
|
||||
static sourceResult => sourceResult.HintName,
|
||||
static sourceResult => sourceResult.SourceText.ToString(),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
Assert.That(result.Results.Single().Diagnostics, Is.Empty);
|
||||
Assert.That(generatedSources["MonsterConfig.g.cs"], Does.Contain("public string? Name { get; set; }"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 schema 顶层自定义配置目录元数据不能逃逸配置根目录。
|
||||
/// </summary>
|
||||
|
||||
@ -7,6 +7,9 @@ namespace GFramework.Game.Config.Generated;
|
||||
/// Auto-generated config type for schema file 'monster.schema.json'.
|
||||
/// Represents one monster entry generated from schema metadata.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Constraints: minProperties = 4, maxProperties = 8.
|
||||
/// </remarks>
|
||||
public sealed partial class MonsterConfig
|
||||
{
|
||||
/// <summary>
|
||||
@ -34,7 +37,7 @@ public sealed partial class MonsterConfig
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Schema property path: 'hp'.
|
||||
/// Constraints: minimum = 1, exclusiveMinimum = 0, maximum = 999, exclusiveMaximum = 1000, multipleOf = 5.
|
||||
/// Constraints: const = 10, minimum = 1, exclusiveMinimum = 0, maximum = 999, exclusiveMaximum = 1000, multipleOf = 5.
|
||||
/// Generated default initializer: = 10;
|
||||
/// </remarks>
|
||||
public int? Hp { get; set; } = 10;
|
||||
@ -74,6 +77,9 @@ public sealed partial class MonsterConfig
|
||||
/// Auto-generated nested config type for schema property path 'reward'.
|
||||
/// Reward payload.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Constraints: minProperties = 2, maxProperties = 2.
|
||||
/// </remarks>
|
||||
public sealed partial class RewardConfig
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@ -17,7 +17,7 @@
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.Common" Version="4.14.0"/>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.14.0"/>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing" Version="1.1.3"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0"/>
|
||||
<PackageReference Include="NUnit" Version="4.5.1"/>
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="6.2.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
@ -5,8 +5,9 @@ namespace GFramework.SourceGenerators.Config;
|
||||
/// <summary>
|
||||
/// 根据 AdditionalFiles 中的 JSON schema 生成配置类型和配置表包装。
|
||||
/// 当前实现聚焦 AI-First 配置系统共享的最小 schema 子集,
|
||||
/// 支持嵌套对象、对象数组、标量数组,以及可映射的 default / enum / ref-table 元数据。
|
||||
/// 当前共享子集也会把 <c>multipleOf</c> 与 <c>uniqueItems</c> 写入生成代码文档,
|
||||
/// 支持嵌套对象、对象数组、标量数组,以及可映射的 default / enum / const / ref-table 元数据。
|
||||
/// 当前共享子集也会把 <c>multipleOf</c>、<c>uniqueItems</c>、
|
||||
/// <c>minProperties</c> 与 <c>maxProperties</c> 写入生成代码文档,
|
||||
/// 让消费者能直接在强类型 API 上看到运行时生效的约束。
|
||||
/// </summary>
|
||||
[Generator]
|
||||
@ -219,7 +220,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
Path.GetFileName(filePath)));
|
||||
}
|
||||
|
||||
var requiredProperties = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var requiredProperties = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (element.TryGetProperty("required", out var requiredElement) &&
|
||||
requiredElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
@ -258,6 +259,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
className,
|
||||
TryGetMetadataString(element, "title"),
|
||||
TryGetMetadataString(element, "description"),
|
||||
TryBuildConstraintDocumentation(element, "object"),
|
||||
properties));
|
||||
}
|
||||
|
||||
@ -1876,6 +1878,14 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
}
|
||||
|
||||
builder.AppendLine($"{indent}/// </summary>");
|
||||
if (!string.IsNullOrWhiteSpace(objectSpec.ConstraintDocumentation))
|
||||
{
|
||||
builder.AppendLine($"{indent}/// <remarks>");
|
||||
builder.AppendLine(
|
||||
$"{indent}/// Constraints: {EscapeXmlDocumentation(objectSpec.ConstraintDocumentation!)}.");
|
||||
builder.AppendLine($"{indent}/// </remarks>");
|
||||
}
|
||||
|
||||
builder.AppendLine($"{indent}public sealed partial class {objectSpec.ClassName}");
|
||||
builder.AppendLine($"{indent}{{");
|
||||
|
||||
@ -2432,7 +2442,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将 shared schema 子集中的范围、步进、长度、模式与数组数量 / 去重约束整理成 XML 文档可读字符串。
|
||||
/// 将 shared schema 子集中的范围、步进、长度、数组数量 / 去重与对象属性数量约束整理成 XML 文档可读字符串。
|
||||
/// </summary>
|
||||
/// <param name="element">Schema 节点。</param>
|
||||
/// <param name="schemaType">标量类型。</param>
|
||||
@ -2441,6 +2451,12 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
{
|
||||
var parts = new List<string>();
|
||||
|
||||
var constDocumentation = TryBuildConstDocumentation(element, schemaType);
|
||||
if (constDocumentation is not null)
|
||||
{
|
||||
parts.Add($"const = {constDocumentation}");
|
||||
}
|
||||
|
||||
if ((schemaType == "integer" || schemaType == "number") &&
|
||||
TryGetFiniteNumber(element, "minimum", out var minimum))
|
||||
{
|
||||
@ -2510,9 +2526,51 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
parts.Add("uniqueItems = true");
|
||||
}
|
||||
|
||||
if (schemaType == "object" &&
|
||||
TryGetNonNegativeInt32(element, "minProperties", out var minProperties))
|
||||
{
|
||||
parts.Add($"minProperties = {minProperties.ToString(CultureInfo.InvariantCulture)}");
|
||||
}
|
||||
|
||||
if (schemaType == "object" &&
|
||||
TryGetNonNegativeInt32(element, "maxProperties", out var maxProperties))
|
||||
{
|
||||
parts.Add($"maxProperties = {maxProperties.ToString(CultureInfo.InvariantCulture)}");
|
||||
}
|
||||
|
||||
return parts.Count > 0 ? string.Join(", ", parts) : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将 const 值整理成 XML 文档可读字符串。
|
||||
/// </summary>
|
||||
/// <param name="element">Schema 节点。</param>
|
||||
/// <param name="schemaType">当前 schema 类型。</param>
|
||||
/// <returns>格式化后的常量说明。</returns>
|
||||
private static string? TryBuildConstDocumentation(JsonElement element, string schemaType)
|
||||
{
|
||||
if (!element.TryGetProperty("const", out var constElement))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return schemaType switch
|
||||
{
|
||||
"integer" when constElement.ValueKind == JsonValueKind.Number && constElement.TryGetInt64(out var intValue) =>
|
||||
intValue.ToString(CultureInfo.InvariantCulture),
|
||||
"number" when constElement.ValueKind == JsonValueKind.Number =>
|
||||
constElement.GetDouble().ToString(CultureInfo.InvariantCulture),
|
||||
"boolean" when constElement.ValueKind == JsonValueKind.True => "true",
|
||||
"boolean" when constElement.ValueKind == JsonValueKind.False => "false",
|
||||
// Preserve the exact JSON literal so empty strings and other string-shaped constants
|
||||
// remain unambiguous in generated XML documentation.
|
||||
"string" when constElement.ValueKind == JsonValueKind.String => constElement.GetRawText(),
|
||||
"array" when constElement.ValueKind == JsonValueKind.Array => constElement.GetRawText(),
|
||||
"object" when constElement.ValueKind == JsonValueKind.Object => constElement.GetRawText(),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取有限数值元数据。
|
||||
/// </summary>
|
||||
@ -2654,12 +2712,14 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
/// <param name="ClassName">要生成的 CLR 类型名。</param>
|
||||
/// <param name="Title">对象标题元数据。</param>
|
||||
/// <param name="Description">对象描述元数据。</param>
|
||||
/// <param name="ConstraintDocumentation">对象约束说明。</param>
|
||||
/// <param name="Properties">对象属性集合。</param>
|
||||
private sealed record SchemaObjectSpec(
|
||||
string DisplayPath,
|
||||
string ClassName,
|
||||
string? Title,
|
||||
string? Description,
|
||||
string? ConstraintDocumentation,
|
||||
IReadOnlyList<SchemaPropertySpec> Properties);
|
||||
|
||||
/// <summary>
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
- JSON Schema 作为结构描述
|
||||
- 一对象一文件的目录组织
|
||||
- 运行时只读查询
|
||||
- Runtime / Generator / Tooling 共享支持 `minimum`、`maximum`、`exclusiveMinimum`、`exclusiveMaximum`、`multipleOf`、`minLength`、`maxLength`、`pattern`、`minItems`、`maxItems`、`uniqueItems`
|
||||
- Runtime / Generator / Tooling 共享支持 `const`、`minimum`、`maximum`、`exclusiveMinimum`、`exclusiveMaximum`、`multipleOf`、`minLength`、`maxLength`、`pattern`、`minItems`、`maxItems`、`uniqueItems`、`minProperties`、`maxProperties`
|
||||
- Source Generator 生成配置类型、表包装、单表注册/访问辅助,以及项目级聚合注册目录
|
||||
- VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口
|
||||
|
||||
@ -657,6 +657,8 @@ var loader = new YamlConfigLoader("config-root")
|
||||
- 字符串字段违反 `pattern`
|
||||
- 数组字段违反 `minItems` / `maxItems`
|
||||
- 数组字段违反 `uniqueItems`
|
||||
- 对象字段违反 `minProperties` / `maxProperties`
|
||||
- 标量 / 对象 / 数组字段违反 `const`
|
||||
- 标量 `enum` 不匹配
|
||||
- 标量数组元素 `enum` 不匹配
|
||||
- 通过 `x-gframework-ref-table` 声明的跨表引用缺失目标行
|
||||
@ -703,6 +705,7 @@ if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var re
|
||||
- `title`:供 VS Code 插件表单和批量编辑入口显示更友好的字段标题
|
||||
- `description`:供表单提示、生成代码 XML 文档和接入说明复用
|
||||
- `default`:供生成类型属性初始值和工具提示复用
|
||||
- `const`:供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;对象会忽略字段顺序比较,数组保留元素顺序,标量按运行时同一套类型归一化规则比较
|
||||
- `enum`:供运行时校验、VS Code 校验和表单枚举选择复用
|
||||
- `minimum` / `maximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用
|
||||
- `exclusiveMinimum` / `exclusiveMaximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用
|
||||
@ -711,6 +714,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 字段顺序
|
||||
- `minProperties` / `maxProperties`:供运行时校验、VS Code 校验、对象 section 表单 hint 和生成代码 XML 文档复用;根对象与嵌套对象都会按实际属性数量执行同一套约束
|
||||
|
||||
这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。
|
||||
|
||||
@ -807,7 +811,7 @@ var hotReload = loader.EnableHotReload(
|
||||
- 对带 `x-gframework-ref-table` 的字段提供引用 schema / 配置域 / 引用文件跳转入口
|
||||
- 对空配置文件提供基于 schema 的示例 YAML 初始化入口
|
||||
- 对同一配置域内的多份 YAML 文件执行批量字段更新
|
||||
- 在表单入口中显示 `title / description / default / enum / ref-table / multipleOf / uniqueItems` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息
|
||||
- 在表单入口中显示 `title / description / default / const / enum / ref-table / multipleOf / uniqueItems / minProperties / maxProperties` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息
|
||||
|
||||
当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。
|
||||
|
||||
|
||||
@ -10,6 +10,22 @@ const IntegerScalarPattern = /^[+-]?\d+$/u;
|
||||
const NumberScalarPattern = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/u;
|
||||
const BooleanScalarPattern = /^(true|false)$/iu;
|
||||
|
||||
/**
|
||||
* Compare two strings using the same UTF-16 code-unit ordering as C#'s
|
||||
* string.CompareOrdinal so tooling stays aligned with the runtime.
|
||||
*
|
||||
* @param {string} left Left operand.
|
||||
* @param {string} right Right operand.
|
||||
* @returns {number} Negative when left < right, positive when left > right, zero when equal.
|
||||
*/
|
||||
function compareStringsOrdinal(left, right) {
|
||||
if (left === right) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return left < right ? -1 : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the repository's minimal config-schema subset into a recursive tree.
|
||||
* The parser intentionally mirrors the same high-level contract used by the
|
||||
@ -20,7 +36,9 @@ const BooleanScalarPattern = /^(true|false)$/iu;
|
||||
* @returns {{
|
||||
* type: "object",
|
||||
* required: string[],
|
||||
* properties: Record<string, SchemaNode>
|
||||
* properties: Record<string, SchemaNode>,
|
||||
* minProperties?: number,
|
||||
* maxProperties?: number
|
||||
* }} Parsed schema info.
|
||||
*/
|
||||
function parseSchemaContent(content) {
|
||||
@ -87,7 +105,7 @@ function getEditableSchemaFields(schemaInfo) {
|
||||
}
|
||||
}
|
||||
|
||||
return editableFields.sort((left, right) => left.key.localeCompare(right.key));
|
||||
return editableFields.sort((left, right) => compareStringsOrdinal(left.key, right.key));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -459,6 +477,84 @@ function formatSchemaDefaultValue(value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a schema const value into the raw scalar text used by sample YAML
|
||||
* generation and scalar editors.
|
||||
*
|
||||
* @param {SchemaNode} schemaNode Parsed schema node.
|
||||
* @param {unknown} value Raw schema const value.
|
||||
* @returns {string | undefined} Raw scalar text, or a JSON literal fallback.
|
||||
*/
|
||||
function formatSchemaConstEditableValue(schemaNode, value) {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (schemaNode.type === "string" && typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
|
||||
if ((schemaNode.type === "integer" || schemaNode.type === "number") &&
|
||||
typeof value === "number" &&
|
||||
Number.isFinite(value)) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
if (schemaNode.type === "boolean" && typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
return formatSchemaConstDisplayValue(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a schema const value into an exact JSON-style literal for diagnostics
|
||||
* and metadata hints.
|
||||
*
|
||||
* @param {unknown} value Raw schema const value.
|
||||
* @returns {string | undefined} Display string for the const value.
|
||||
*/
|
||||
function formatSchemaConstDisplayValue(value) {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
if (value === null || Array.isArray(value) || typeof value === "object") {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach parsed const metadata to one schema node.
|
||||
*
|
||||
* @param {SchemaNode} schemaNode Parsed schema node.
|
||||
* @param {unknown} rawConst Raw schema const value.
|
||||
* @param {string} displayPath Logical property path.
|
||||
* @returns {SchemaNode} Schema node with optional const metadata.
|
||||
*/
|
||||
function applyConstMetadata(schemaNode, rawConst, displayPath) {
|
||||
if (rawConst === undefined) {
|
||||
return schemaNode;
|
||||
}
|
||||
|
||||
return {
|
||||
...schemaNode,
|
||||
constValue: formatSchemaConstEditableValue(schemaNode, rawConst),
|
||||
constDisplayValue: formatSchemaConstDisplayValue(rawConst),
|
||||
constComparableValue: buildSchemaConstComparableValue(schemaNode, rawConst, displayPath)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Test one scalar value against one compiled schema pattern.
|
||||
*
|
||||
@ -474,6 +570,131 @@ function matchesSchemaPattern(scalarValue, patternRegex) {
|
||||
return patternRegex.test(scalarValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one schema-normalized comparable key for a const value declared in
|
||||
* JSON Schema so tooling comparisons align with runtime comparisons.
|
||||
*
|
||||
* @param {SchemaNode} schemaNode Parsed schema node.
|
||||
* @param {unknown} rawConst Raw schema const value.
|
||||
* @param {string} displayPath Logical property path.
|
||||
* @returns {string} Comparable key.
|
||||
*/
|
||||
function buildSchemaConstComparableValue(schemaNode, rawConst, displayPath) {
|
||||
if (schemaNode.type === "object") {
|
||||
return buildSchemaConstObjectComparableValue(schemaNode, rawConst, displayPath);
|
||||
}
|
||||
|
||||
if (schemaNode.type === "array") {
|
||||
return buildSchemaConstArrayComparableValue(schemaNode, rawConst, displayPath);
|
||||
}
|
||||
|
||||
return buildSchemaConstScalarComparableValue(schemaNode, rawConst, displayPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one comparable key for an object-shaped const value.
|
||||
*
|
||||
* @param {Extract<SchemaNode, {type: "object"}>} schemaNode Parsed object schema node.
|
||||
* @param {unknown} rawConst Raw schema const value.
|
||||
* @param {string} displayPath Logical property path.
|
||||
* @returns {string} Comparable key.
|
||||
*/
|
||||
function buildSchemaConstObjectComparableValue(schemaNode, rawConst, displayPath) {
|
||||
if (!rawConst || typeof rawConst !== "object" || Array.isArray(rawConst)) {
|
||||
throw new Error(`Schema property '${displayPath}' declares 'const', but the value is not compatible with schema type 'object'.`);
|
||||
}
|
||||
|
||||
const objectEntries = [];
|
||||
for (const [key, value] of Object.entries(rawConst)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(schemaNode.properties, key)) {
|
||||
const childPath = joinPropertyPath(displayPath, key);
|
||||
throw new Error(`Schema property '${displayPath}' declares 'const', but nested property '${childPath}' is not declared in the object schema.`);
|
||||
}
|
||||
|
||||
const childComparableValue = buildSchemaConstComparableValue(
|
||||
schemaNode.properties[key],
|
||||
value,
|
||||
joinPropertyPath(displayPath, key));
|
||||
objectEntries.push([key, childComparableValue]);
|
||||
}
|
||||
|
||||
objectEntries.sort((left, right) => compareStringsOrdinal(left[0], right[0]));
|
||||
return objectEntries.map(([key, value]) => `${key.length}:${key}=${value.length}:${value}`).join("|");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one comparable key for an array-shaped const value.
|
||||
*
|
||||
* @param {Extract<SchemaNode, {type: "array"}>} schemaNode Parsed array schema node.
|
||||
* @param {unknown} rawConst Raw schema const value.
|
||||
* @param {string} displayPath Logical property path.
|
||||
* @returns {string} Comparable key.
|
||||
*/
|
||||
function buildSchemaConstArrayComparableValue(schemaNode, rawConst, displayPath) {
|
||||
if (!Array.isArray(rawConst)) {
|
||||
throw new Error(`Schema property '${displayPath}' declares 'const', but the value is not compatible with schema type 'array'.`);
|
||||
}
|
||||
|
||||
return `[${rawConst.map((item, index) => {
|
||||
const comparableValue = buildSchemaConstComparableValue(
|
||||
schemaNode.items,
|
||||
item,
|
||||
joinArrayIndexPath(displayPath, index));
|
||||
return `${comparableValue.length}:${comparableValue}`;
|
||||
}).join(",")}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one comparable key for a scalar const value.
|
||||
*
|
||||
* @param {Extract<SchemaNode, {type: "string" | "integer" | "number" | "boolean"}>} schemaNode Parsed scalar schema node.
|
||||
* @param {unknown} rawConst Raw schema const value.
|
||||
* @param {string} displayPath Logical property path.
|
||||
* @returns {string} Comparable key.
|
||||
*/
|
||||
function buildSchemaConstScalarComparableValue(schemaNode, rawConst, displayPath) {
|
||||
const normalizedValue = normalizeSchemaConstScalarValue(schemaNode.type, rawConst, displayPath);
|
||||
return `${schemaNode.type}:${normalizedValue.length}:${normalizedValue}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize one scalar const value into the same comparison format used by
|
||||
* parsed YAML scalar nodes.
|
||||
*
|
||||
* @param {"string" | "integer" | "number" | "boolean"} schemaType Scalar schema type.
|
||||
* @param {unknown} rawConst Raw schema const value.
|
||||
* @param {string} displayPath Logical property path.
|
||||
* @returns {string} Normalized scalar value.
|
||||
*/
|
||||
function normalizeSchemaConstScalarValue(schemaType, rawConst, displayPath) {
|
||||
switch (schemaType) {
|
||||
case "integer":
|
||||
if (typeof rawConst === "number" && Number.isInteger(rawConst)) {
|
||||
return String(rawConst);
|
||||
}
|
||||
break;
|
||||
case "number":
|
||||
if (typeof rawConst === "number" && Number.isFinite(rawConst)) {
|
||||
return String(rawConst);
|
||||
}
|
||||
break;
|
||||
case "boolean":
|
||||
if (typeof rawConst === "boolean") {
|
||||
return String(rawConst);
|
||||
}
|
||||
break;
|
||||
case "string":
|
||||
if (typeof rawConst === "string") {
|
||||
return rawConst;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
throw new Error(`Schema property '${displayPath}' declares 'const', but the value is not compatible with schema type '${schemaType}'.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether one numeric scalar satisfies a multipleOf constraint.
|
||||
*
|
||||
@ -639,6 +860,8 @@ function parseSchemaNode(rawNode, displayPath) {
|
||||
patternRegex: patternMetadata ? patternMetadata.regex : undefined,
|
||||
minItems: normalizeSchemaNonNegativeInteger(value.minItems),
|
||||
maxItems: normalizeSchemaNonNegativeInteger(value.maxItems),
|
||||
minProperties: normalizeSchemaNonNegativeInteger(value.minProperties),
|
||||
maxProperties: normalizeSchemaNonNegativeInteger(value.maxProperties),
|
||||
uniqueItems: normalizeSchemaBoolean(value.uniqueItems),
|
||||
refTable: typeof value["x-gframework-ref-table"] === "string"
|
||||
? value["x-gframework-ref-table"]
|
||||
@ -654,20 +877,22 @@ function parseSchemaNode(rawNode, displayPath) {
|
||||
properties[key] = parseSchemaNode(propertyNode, joinPropertyPath(displayPath, key));
|
||||
}
|
||||
|
||||
return {
|
||||
return applyConstMetadata({
|
||||
type: "object",
|
||||
displayPath,
|
||||
required,
|
||||
properties,
|
||||
minProperties: metadata.minProperties,
|
||||
maxProperties: metadata.maxProperties,
|
||||
title: metadata.title,
|
||||
description: metadata.description,
|
||||
defaultValue: metadata.defaultValue
|
||||
};
|
||||
}, value.const, displayPath);
|
||||
}
|
||||
|
||||
if (type === "array") {
|
||||
const itemNode = parseSchemaNode(value.items || {}, joinArrayTemplatePath(displayPath));
|
||||
return {
|
||||
return applyConstMetadata({
|
||||
type: "array",
|
||||
displayPath,
|
||||
title: metadata.title,
|
||||
@ -678,10 +903,10 @@ function parseSchemaNode(rawNode, displayPath) {
|
||||
uniqueItems: metadata.uniqueItems === true,
|
||||
refTable: metadata.refTable,
|
||||
items: itemNode
|
||||
};
|
||||
}, value.const, displayPath);
|
||||
}
|
||||
|
||||
return {
|
||||
return applyConstMetadata({
|
||||
type,
|
||||
displayPath,
|
||||
title: metadata.title,
|
||||
@ -716,7 +941,7 @@ function parseSchemaNode(rawNode, displayPath) {
|
||||
: undefined,
|
||||
enumValues: normalizeSchemaEnumValues(value.enum),
|
||||
refTable: metadata.refTable
|
||||
};
|
||||
}, value.const, displayPath);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -803,6 +1028,8 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
|
||||
}
|
||||
}
|
||||
|
||||
validateConstComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@ -939,6 +1166,8 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
validateConstComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -952,23 +1181,21 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
|
||||
*/
|
||||
function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) {
|
||||
if (!yamlNode || yamlNode.kind !== "object") {
|
||||
const subject = displayPath.length === 0
|
||||
? localizer && localizer.isChinese
|
||||
? "根对象应为对象。"
|
||||
: "Root object is expected to be an object."
|
||||
: localizer && localizer.isChinese
|
||||
? `属性“${displayPath}”应为对象。`
|
||||
: `Property '${displayPath}' is expected to be an object.`;
|
||||
diagnostics.push({
|
||||
severity: "error",
|
||||
message: localizeValidationMessage(ValidationMessageKeys.expectedObject, localizer, {
|
||||
subject,
|
||||
displayPath
|
||||
})
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
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)) {
|
||||
diagnostics.push({
|
||||
@ -998,6 +1225,59 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca
|
||||
diagnostics,
|
||||
localizer);
|
||||
}
|
||||
|
||||
if (typeof schemaNode.minProperties === "number" &&
|
||||
propertyCount < schemaNode.minProperties) {
|
||||
diagnostics.push({
|
||||
severity: "error",
|
||||
message: localizeValidationMessage(ValidationMessageKeys.minPropertiesViolation, localizer, {
|
||||
displayPath,
|
||||
value: String(schemaNode.minProperties)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof schemaNode.maxProperties === "number" &&
|
||||
propertyCount > schemaNode.maxProperties) {
|
||||
diagnostics.push({
|
||||
severity: "error",
|
||||
message: localizeValidationMessage(ValidationMessageKeys.maxPropertiesViolation, localizer, {
|
||||
displayPath,
|
||||
value: String(schemaNode.maxProperties)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
validateConstComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one parsed YAML node against one normalized const comparable value.
|
||||
* The helper reuses the same comparable-key logic as uniqueItems so array order
|
||||
* and scalar normalization stay aligned with runtime behavior.
|
||||
*
|
||||
* @param {SchemaNode} schemaNode Schema node.
|
||||
* @param {YamlNode} yamlNode YAML node.
|
||||
* @param {string} displayPath Current logical path.
|
||||
* @param {Array<{severity: "error" | "warning", message: string}>} diagnostics Diagnostic sink.
|
||||
* @param {{isChinese?: boolean} | undefined} localizer Optional runtime localizer.
|
||||
*/
|
||||
function validateConstComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer) {
|
||||
if (typeof schemaNode.constComparableValue !== "string") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (buildComparableNodeValue(schemaNode, yamlNode) === schemaNode.constComparableValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
diagnostics.push({
|
||||
severity: "error",
|
||||
message: localizeValidationMessage(ValidationMessageKeys.constMismatch, localizer, {
|
||||
displayPath,
|
||||
value: schemaNode.constDisplayValue ?? schemaNode.constValue
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1019,7 +1299,7 @@ function buildComparableNodeValue(schemaNode, yamlNode) {
|
||||
|
||||
return Object.keys(schemaNode.properties)
|
||||
.filter((key) => yamlNode.map.has(key))
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
.sort(compareStringsOrdinal)
|
||||
.map((key) => {
|
||||
const valueKey = buildComparableNodeValue(schemaNode.properties[key], yamlNode.map.get(key));
|
||||
return `${key.length}:${key}=${valueKey.length}:${valueKey}`;
|
||||
@ -1060,12 +1340,42 @@ function buildComparableNodeValue(schemaNode, yamlNode) {
|
||||
* @returns {string} Localized validation message.
|
||||
*/
|
||||
function localizeValidationMessage(key, localizer, params) {
|
||||
if (key === ValidationMessageKeys.expectedObject) {
|
||||
return formatExpectedObjectMessage(params.displayPath, Boolean(localizer && localizer.isChinese));
|
||||
}
|
||||
|
||||
if (key === ValidationMessageKeys.minPropertiesViolation) {
|
||||
if (localizer && typeof localizer.t === "function" && params.displayPath) {
|
||||
return localizer.t(key, params);
|
||||
}
|
||||
|
||||
return formatObjectPropertyCountMessage(
|
||||
params.displayPath,
|
||||
params.value,
|
||||
"min",
|
||||
Boolean(localizer && localizer.isChinese));
|
||||
}
|
||||
|
||||
if (key === ValidationMessageKeys.maxPropertiesViolation) {
|
||||
if (localizer && typeof localizer.t === "function" && params.displayPath) {
|
||||
return localizer.t(key, params);
|
||||
}
|
||||
|
||||
return formatObjectPropertyCountMessage(
|
||||
params.displayPath,
|
||||
params.value,
|
||||
"max",
|
||||
Boolean(localizer && localizer.isChinese));
|
||||
}
|
||||
|
||||
if (localizer && typeof localizer.t === "function") {
|
||||
return localizer.t(key, params);
|
||||
}
|
||||
|
||||
if (localizer && localizer.isChinese) {
|
||||
switch (key) {
|
||||
case ValidationMessageKeys.constMismatch:
|
||||
return `属性“${params.displayPath}”必须匹配固定值 ${params.value}。`;
|
||||
case ValidationMessageKeys.expectedArray:
|
||||
return `属性“${params.displayPath}”应为数组。`;
|
||||
case ValidationMessageKeys.expectedScalarShape:
|
||||
@ -1096,8 +1406,6 @@ function localizeValidationMessage(key, localizer, params) {
|
||||
return `属性“${params.displayPath}”必须匹配正则模式“${params.value}”。`;
|
||||
case ValidationMessageKeys.uniqueItemsViolation:
|
||||
return `属性“${params.displayPath}”与更早的数组元素 ${params.duplicatePath} 重复;该数组要求元素唯一。`;
|
||||
case ValidationMessageKeys.expectedObject:
|
||||
return params.subject;
|
||||
case ValidationMessageKeys.missingRequired:
|
||||
return `缺少必填属性“${params.displayPath}”。`;
|
||||
case ValidationMessageKeys.unknownProperty:
|
||||
@ -1108,6 +1416,8 @@ function localizeValidationMessage(key, localizer, params) {
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case ValidationMessageKeys.constMismatch:
|
||||
return `Property '${params.displayPath}' must match constant value ${params.value}.`;
|
||||
case ValidationMessageKeys.expectedArray:
|
||||
return `Property '${params.displayPath}' is expected to be an array.`;
|
||||
case ValidationMessageKeys.expectedScalarShape:
|
||||
@ -1138,8 +1448,6 @@ function localizeValidationMessage(key, localizer, params) {
|
||||
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:
|
||||
return params.subject;
|
||||
case ValidationMessageKeys.missingRequired:
|
||||
return `Required property '${params.displayPath}' is missing.`;
|
||||
case ValidationMessageKeys.unknownProperty:
|
||||
@ -1149,6 +1457,60 @@ function localizeValidationMessage(key, localizer, params) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format one object-shape expectation diagnostic.
|
||||
*
|
||||
* @param {string} displayPath Logical object path, or empty for the root object.
|
||||
* @param {boolean} isChinese Whether Chinese text should be produced.
|
||||
* @returns {string} Formatted message.
|
||||
*/
|
||||
function formatExpectedObjectMessage(displayPath, isChinese) {
|
||||
const isRoot = !displayPath;
|
||||
if (isChinese) {
|
||||
return isRoot
|
||||
? "根对象应为对象。"
|
||||
: `属性“${displayPath}”应为对象。`;
|
||||
}
|
||||
|
||||
return isRoot
|
||||
? "Root object is expected to be an object."
|
||||
: `Property '${displayPath}' is expected to be an object.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format one object-property-count validation message.
|
||||
*
|
||||
* @param {string} displayPath Logical object path, or empty for the root object.
|
||||
* @param {string} value Constraint value.
|
||||
* @param {"min" | "max"} mode Whether the message describes a minimum or maximum.
|
||||
* @param {boolean} isChinese Whether Chinese text should be produced.
|
||||
* @returns {string} Formatted message.
|
||||
*/
|
||||
function formatObjectPropertyCountMessage(displayPath, value, mode, isChinese) {
|
||||
const isRoot = !displayPath;
|
||||
if (isChinese) {
|
||||
if (mode === "min") {
|
||||
return isRoot
|
||||
? `根对象至少需要包含 ${value} 个属性。`
|
||||
: `对象属性“${displayPath}”至少需要包含 ${value} 个子属性。`;
|
||||
}
|
||||
|
||||
return isRoot
|
||||
? `根对象最多只能包含 ${value} 个属性。`
|
||||
: `对象属性“${displayPath}”最多只能包含 ${value} 个子属性。`;
|
||||
}
|
||||
|
||||
if (mode === "min") {
|
||||
return isRoot
|
||||
? `Root object must contain at least ${value} properties.`
|
||||
: `Property '${displayPath}' must contain at least ${value} properties.`;
|
||||
}
|
||||
|
||||
return isRoot
|
||||
? `Root object must contain at most ${value} properties.`
|
||||
: `Property '${displayPath}' must contain at most ${value} properties.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize YAML lines into indentation-aware units.
|
||||
*
|
||||
@ -1562,6 +1924,10 @@ function collectSchemaComments(schemaNode, currentPath, commentMap) {
|
||||
* @returns {string} Sample scalar value.
|
||||
*/
|
||||
function getSampleScalarValue(schemaNode) {
|
||||
if (schemaNode.constValue !== undefined) {
|
||||
return schemaNode.constValue;
|
||||
}
|
||||
|
||||
if (schemaNode.defaultValue !== undefined) {
|
||||
return schemaNode.defaultValue;
|
||||
}
|
||||
@ -1782,15 +2148,23 @@ module.exports = {
|
||||
* displayPath: string,
|
||||
* required: string[],
|
||||
* properties: Record<string, SchemaNode>,
|
||||
* minProperties?: number,
|
||||
* maxProperties?: number,
|
||||
* title?: string,
|
||||
* description?: string,
|
||||
* defaultValue?: string
|
||||
* defaultValue?: string,
|
||||
* constValue?: string,
|
||||
* constDisplayValue?: string,
|
||||
* constComparableValue?: string
|
||||
* } | {
|
||||
* type: "array",
|
||||
* displayPath: string,
|
||||
* title?: string,
|
||||
* description?: string,
|
||||
* defaultValue?: string,
|
||||
* constValue?: string,
|
||||
* constDisplayValue?: string,
|
||||
* constComparableValue?: string,
|
||||
* minItems?: number,
|
||||
* maxItems?: number,
|
||||
* uniqueItems?: boolean,
|
||||
@ -1802,6 +2176,9 @@ module.exports = {
|
||||
* title?: string,
|
||||
* description?: string,
|
||||
* defaultValue?: string,
|
||||
* constValue?: string,
|
||||
* constDisplayValue?: string,
|
||||
* constComparableValue?: string,
|
||||
* minimum?: number,
|
||||
* exclusiveMinimum?: number,
|
||||
* maximum?: number,
|
||||
|
||||
@ -1095,6 +1095,7 @@ function renderFormField(field) {
|
||||
<div class="meta-key">${escapeHtml(field.displayPath || field.path)}</div>
|
||||
${renderYamlCommentBlock(field)}
|
||||
${field.description ? `<span class="hint">${escapeHtml(field.description)}</span>` : ""}
|
||||
${field.schema ? renderFieldHint(field.schema, false, false) : ""}
|
||||
${renderCommentEditor(field)}
|
||||
</div>
|
||||
`;
|
||||
@ -1302,6 +1303,7 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns
|
||||
path: propertyPath,
|
||||
label,
|
||||
description: propertySchema.description,
|
||||
schema: propertySchema,
|
||||
comment: commentLookup[propertyPath] || "",
|
||||
required: requiredSet.has(key),
|
||||
depth
|
||||
@ -1370,7 +1372,7 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns
|
||||
label,
|
||||
required: requiredSet.has(key),
|
||||
depth,
|
||||
value: getScalarFieldValue(propertyValue, propertySchema.defaultValue),
|
||||
value: getScalarFieldValue(propertyValue, propertySchema.constValue ?? propertySchema.defaultValue),
|
||||
schema: propertySchema,
|
||||
comment: commentLookup[propertyPath] || ""
|
||||
});
|
||||
@ -1468,6 +1470,7 @@ function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPa
|
||||
displayPath: itemDisplayPath,
|
||||
label,
|
||||
description: propertySchema.description,
|
||||
schema: propertySchema,
|
||||
comment: commentLookup[itemDisplayPath] || "",
|
||||
required: requiredSet.has(key),
|
||||
depth
|
||||
@ -1511,7 +1514,7 @@ function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPa
|
||||
label,
|
||||
required: requiredSet.has(key),
|
||||
depth,
|
||||
value: getScalarFieldValue(propertyValue, propertySchema.defaultValue),
|
||||
value: getScalarFieldValue(propertyValue, propertySchema.constValue ?? propertySchema.defaultValue),
|
||||
schema: propertySchema,
|
||||
itemMode: true,
|
||||
comment: commentLookup[itemDisplayPath] || ""
|
||||
@ -1544,15 +1547,15 @@ function getYamlObjectMap(yamlNode) {
|
||||
* Extract a scalar field value from a parsed YAML node.
|
||||
*
|
||||
* @param {unknown} yamlNode YAML node.
|
||||
* @param {string | undefined} defaultValue Default value from schema metadata.
|
||||
* @param {string | undefined} fallbackValue Schema-provided fallback value.
|
||||
* @returns {string} Scalar display value.
|
||||
*/
|
||||
function getScalarFieldValue(yamlNode, defaultValue) {
|
||||
function getScalarFieldValue(yamlNode, fallbackValue) {
|
||||
if (yamlNode && yamlNode.kind === "scalar") {
|
||||
return unquoteScalar(yamlNode.value || "");
|
||||
}
|
||||
|
||||
return defaultValue || "";
|
||||
return fallbackValue ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1574,14 +1577,15 @@ function getScalarArrayValue(yamlNode) {
|
||||
/**
|
||||
* Render human-facing metadata hints for one schema field.
|
||||
*
|
||||
* @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 {{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 {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.
|
||||
*/
|
||||
function renderFieldHint(propertySchema, isArrayField) {
|
||||
function renderFieldHint(propertySchema, isArrayField, includeDescription = true) {
|
||||
const hints = [];
|
||||
|
||||
if (propertySchema.description) {
|
||||
if (includeDescription && propertySchema.description) {
|
||||
hints.push(escapeHtml(propertySchema.description));
|
||||
}
|
||||
|
||||
@ -1589,6 +1593,12 @@ function renderFieldHint(propertySchema, isArrayField) {
|
||||
hints.push(escapeHtml(localizer.t("webview.hint.default", {value: propertySchema.defaultValue})));
|
||||
}
|
||||
|
||||
if (propertySchema.constValue !== undefined) {
|
||||
hints.push(escapeHtml(localizer.t("webview.hint.const", {
|
||||
value: propertySchema.constDisplayValue ?? propertySchema.constValue
|
||||
})));
|
||||
}
|
||||
|
||||
const enumValues = isArrayField
|
||||
? propertySchema.items && Array.isArray(propertySchema.items.enumValues)
|
||||
? propertySchema.items.enumValues
|
||||
@ -1630,6 +1640,14 @@ function renderFieldHint(propertySchema, isArrayField) {
|
||||
hints.push(escapeHtml(localizer.t("webview.hint.pattern", {value: propertySchema.pattern})));
|
||||
}
|
||||
|
||||
if (propertySchema.type === "object" && typeof propertySchema.minProperties === "number") {
|
||||
hints.push(escapeHtml(localizer.t("webview.hint.minProperties", {value: propertySchema.minProperties})));
|
||||
}
|
||||
|
||||
if (propertySchema.type === "object" && typeof propertySchema.maxProperties === "number") {
|
||||
hints.push(escapeHtml(localizer.t("webview.hint.maxProperties", {value: propertySchema.maxProperties})));
|
||||
}
|
||||
|
||||
if (isArrayField && typeof propertySchema.minItems === "number") {
|
||||
hints.push(escapeHtml(localizer.t("webview.hint.minItems", {value: propertySchema.minItems})));
|
||||
}
|
||||
@ -1646,6 +1664,12 @@ function renderFieldHint(propertySchema, isArrayField) {
|
||||
hints.push(escapeHtml(localizer.t("webview.hint.itemMinimum", {value: propertySchema.items.minimum})));
|
||||
}
|
||||
|
||||
if (isArrayField && propertySchema.items && propertySchema.items.constValue !== undefined) {
|
||||
hints.push(escapeHtml(localizer.t("webview.hint.itemConst", {
|
||||
value: propertySchema.items.constDisplayValue ?? propertySchema.items.constValue
|
||||
})));
|
||||
}
|
||||
|
||||
if (isArrayField && propertySchema.items && typeof propertySchema.items.exclusiveMinimum === "number") {
|
||||
hints.push(escapeHtml(localizer.t("webview.hint.itemExclusiveMinimum", {value: propertySchema.items.exclusiveMinimum})));
|
||||
}
|
||||
|
||||
@ -104,6 +104,7 @@ const enMessages = {
|
||||
"webview.objectArray.remove": "Remove",
|
||||
"webview.array.hint": "One item per line. Expected type: {itemType}",
|
||||
"webview.hint.default": "Default: {value}",
|
||||
"webview.hint.const": "Const: {value}",
|
||||
"webview.hint.allowed": "Allowed: {values}",
|
||||
"webview.hint.minimum": "Minimum: {value}",
|
||||
"webview.hint.exclusiveMinimum": "Exclusive minimum: {value}",
|
||||
@ -117,6 +118,7 @@ const enMessages = {
|
||||
"webview.hint.maxItems": "Max items: {value}",
|
||||
"webview.hint.uniqueItems": "Items must be unique",
|
||||
"webview.hint.itemMinimum": "Item minimum: {value}",
|
||||
"webview.hint.itemConst": "Item const: {value}",
|
||||
"webview.hint.itemExclusiveMinimum": "Item exclusive minimum: {value}",
|
||||
"webview.hint.itemMaximum": "Item maximum: {value}",
|
||||
"webview.hint.itemExclusiveMaximum": "Item exclusive maximum: {value}",
|
||||
@ -124,25 +126,29 @@ const enMessages = {
|
||||
"webview.hint.itemMinLength": "Item min length: {value}",
|
||||
"webview.hint.itemMaxLength": "Item max length: {value}",
|
||||
"webview.hint.itemPattern": "Item pattern: {value}",
|
||||
"webview.hint.minProperties": "Min properties: {value}",
|
||||
"webview.hint.maxProperties": "Max properties: {value}",
|
||||
"webview.hint.refTable": "Ref table: {refTable}",
|
||||
"webview.unsupported.array": "Unsupported array shapes are currently raw-YAML-only in the form preview.",
|
||||
"webview.unsupported.type": "{type} fields are currently raw-YAML-only.",
|
||||
"webview.unsupported.objectArrayMixed": "Object-array items must be mappings. Use raw YAML if the current file mixes scalar and object items.",
|
||||
"webview.unsupported.nestedObjectArray": "Nested object-array fields are currently raw-YAML-only inside the object-array editor.",
|
||||
[ValidationMessageKeys.constMismatch]: "Property '{displayPath}' must match constant value {value}.",
|
||||
[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.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.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.",
|
||||
[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.expectedArray]: "Property '{displayPath}' is expected to be an array.",
|
||||
[ValidationMessageKeys.expectedObject]: "{subject} is expected to be an object.",
|
||||
[ValidationMessageKeys.expectedScalarShape]: "Property '{displayPath}' is expected to be '{schemaType}', but the current YAML shape is '{yamlKind}'.",
|
||||
[ValidationMessageKeys.expectedScalarValue]: "Property '{displayPath}' is expected to be '{schemaType}', but the current scalar value is incompatible.",
|
||||
[ValidationMessageKeys.missingRequired]: "Required property '{displayPath}' is missing.",
|
||||
@ -208,6 +214,7 @@ const zhCnMessages = {
|
||||
"webview.objectArray.remove": "删除",
|
||||
"webview.array.hint": "每行一个元素。期望类型:{itemType}",
|
||||
"webview.hint.default": "默认值:{value}",
|
||||
"webview.hint.const": "固定值:{value}",
|
||||
"webview.hint.allowed": "允许值:{values}",
|
||||
"webview.hint.minimum": "最小值:{value}",
|
||||
"webview.hint.exclusiveMinimum": "开区间最小值:{value}",
|
||||
@ -221,6 +228,7 @@ const zhCnMessages = {
|
||||
"webview.hint.maxItems": "最多元素数:{value}",
|
||||
"webview.hint.uniqueItems": "元素必须唯一",
|
||||
"webview.hint.itemMinimum": "元素最小值:{value}",
|
||||
"webview.hint.itemConst": "元素固定值:{value}",
|
||||
"webview.hint.itemExclusiveMinimum": "元素开区间最小值:{value}",
|
||||
"webview.hint.itemMaximum": "元素最大值:{value}",
|
||||
"webview.hint.itemExclusiveMaximum": "元素开区间最大值:{value}",
|
||||
@ -228,25 +236,29 @@ const zhCnMessages = {
|
||||
"webview.hint.itemMinLength": "元素最小长度:{value}",
|
||||
"webview.hint.itemMaxLength": "元素最大长度:{value}",
|
||||
"webview.hint.itemPattern": "元素正则模式:{value}",
|
||||
"webview.hint.minProperties": "最少属性数:{value}",
|
||||
"webview.hint.maxProperties": "最多属性数:{value}",
|
||||
"webview.hint.refTable": "引用表:{refTable}",
|
||||
"webview.unsupported.array": "当前表单预览暂不支持这种数组结构,请改用原始 YAML。",
|
||||
"webview.unsupported.type": "当前表单预览暂不支持 {type} 字段,请改用原始 YAML。",
|
||||
"webview.unsupported.objectArrayMixed": "对象数组中的每一项都必须是映射对象。如果当前文件混用了标量项和对象项,请改用原始 YAML。",
|
||||
"webview.unsupported.nestedObjectArray": "对象数组编辑器内暂不支持更深层的对象数组字段,请改用原始 YAML。",
|
||||
[ValidationMessageKeys.constMismatch]: "属性“{displayPath}”必须匹配固定值 {value}。",
|
||||
[ValidationMessageKeys.exclusiveMaximumViolation]: "属性“{displayPath}”必须小于 {value}。",
|
||||
[ValidationMessageKeys.exclusiveMinimumViolation]: "属性“{displayPath}”必须大于 {value}。",
|
||||
[ValidationMessageKeys.maximumViolation]: "属性“{displayPath}”必须小于或等于 {value}。",
|
||||
[ValidationMessageKeys.maxItemsViolation]: "属性“{displayPath}”最多只能包含 {value} 个元素。",
|
||||
[ValidationMessageKeys.maxLengthViolation]: "属性“{displayPath}”长度必须不超过 {value} 个字符。",
|
||||
[ValidationMessageKeys.maxPropertiesViolation]: "对象属性“{displayPath}”最多只能包含 {value} 个子属性。",
|
||||
[ValidationMessageKeys.minimumViolation]: "属性“{displayPath}”必须大于或等于 {value}。",
|
||||
[ValidationMessageKeys.multipleOfViolation]: "属性“{displayPath}”必须是 {value} 的整数倍。",
|
||||
[ValidationMessageKeys.minItemsViolation]: "属性“{displayPath}”至少需要包含 {value} 个元素。",
|
||||
[ValidationMessageKeys.minLengthViolation]: "属性“{displayPath}”长度必须至少为 {value} 个字符。",
|
||||
[ValidationMessageKeys.minPropertiesViolation]: "对象属性“{displayPath}”至少需要包含 {value} 个子属性。",
|
||||
[ValidationMessageKeys.patternViolation]: "属性“{displayPath}”必须匹配正则模式“{value}”。",
|
||||
[ValidationMessageKeys.uniqueItemsViolation]: "属性“{displayPath}”与更早的数组元素“{duplicatePath}”重复;该数组要求元素唯一。",
|
||||
[ValidationMessageKeys.enumMismatch]: "属性“{displayPath}”必须是以下值之一:{values}。",
|
||||
[ValidationMessageKeys.expectedArray]: "属性“{displayPath}”应为数组。",
|
||||
[ValidationMessageKeys.expectedObject]: "{subject}",
|
||||
[ValidationMessageKeys.expectedScalarShape]: "属性“{displayPath}”应为“{schemaType}”,但当前 YAML 结构是“{yamlKind}”。",
|
||||
[ValidationMessageKeys.expectedScalarValue]: "属性“{displayPath}”应为“{schemaType}”,但当前标量值不兼容。",
|
||||
[ValidationMessageKeys.missingRequired]: "缺少必填属性“{displayPath}”。",
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
const ValidationMessageKeys = Object.freeze({
|
||||
constMismatch: "validation.constMismatch",
|
||||
enumMismatch: "validation.enumMismatch",
|
||||
exclusiveMaximumViolation: "validation.exclusiveMaximumViolation",
|
||||
exclusiveMinimumViolation: "validation.exclusiveMinimumViolation",
|
||||
@ -9,10 +10,12 @@ const ValidationMessageKeys = Object.freeze({
|
||||
maximumViolation: "validation.maximumViolation",
|
||||
maxItemsViolation: "validation.maxItemsViolation",
|
||||
maxLengthViolation: "validation.maxLengthViolation",
|
||||
maxPropertiesViolation: "validation.maxPropertiesViolation",
|
||||
minimumViolation: "validation.minimumViolation",
|
||||
multipleOfViolation: "validation.multipleOfViolation",
|
||||
minItemsViolation: "validation.minItemsViolation",
|
||||
minLengthViolation: "validation.minLengthViolation",
|
||||
minPropertiesViolation: "validation.minPropertiesViolation",
|
||||
missingRequired: "validation.missingRequired",
|
||||
patternViolation: "validation.patternViolation",
|
||||
uniqueItemsViolation: "validation.uniqueItemsViolation",
|
||||
|
||||
@ -65,6 +65,142 @@ test("parseSchemaContent should capture nested objects and object-array metadata
|
||||
assert.equal(schema.properties.phases.items.properties.wave.type, "integer");
|
||||
});
|
||||
|
||||
test("parseSchemaContent should capture const metadata for scalar, object, array, integer, and boolean nodes", () => {
|
||||
const schema = parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"rarity": {
|
||||
"type": "string",
|
||||
"const": "common"
|
||||
},
|
||||
"reward": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"gold": { "type": "integer" },
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"const": {
|
||||
"gold": 100,
|
||||
"items": [
|
||||
"potion",
|
||||
"sword"
|
||||
]
|
||||
}
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"const": ["daily", "quest"],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"maxAttempts": {
|
||||
"type": "integer",
|
||||
"const": 3
|
||||
},
|
||||
"allowRetry": {
|
||||
"type": "boolean",
|
||||
"const": true
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const reorderedSchema = parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reward": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"gold": { "type": "integer" },
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"const": {
|
||||
"items": [
|
||||
"potion",
|
||||
"sword"
|
||||
],
|
||||
"gold": 100
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
assert.equal(schema.properties.rarity.constValue, "common");
|
||||
assert.equal(schema.properties.rarity.constDisplayValue, "\"common\"");
|
||||
assert.ok(schema.properties.rarity.constComparableValue);
|
||||
|
||||
assert.equal(schema.properties.reward.constValue, "{\"gold\":100,\"items\":[\"potion\",\"sword\"]}");
|
||||
assert.equal(schema.properties.reward.constDisplayValue, "{\"gold\":100,\"items\":[\"potion\",\"sword\"]}");
|
||||
assert.equal(
|
||||
schema.properties.reward.constComparableValue,
|
||||
reorderedSchema.properties.reward.constComparableValue);
|
||||
|
||||
assert.equal(schema.properties.tags.constValue, "[\"daily\",\"quest\"]");
|
||||
assert.equal(schema.properties.tags.constDisplayValue, "[\"daily\",\"quest\"]");
|
||||
assert.ok(schema.properties.tags.constComparableValue);
|
||||
|
||||
assert.equal(schema.properties.maxAttempts.constValue, "3");
|
||||
assert.equal(schema.properties.maxAttempts.constDisplayValue, "3");
|
||||
assert.equal(schema.properties.maxAttempts.constComparableValue, "integer:1:3");
|
||||
|
||||
assert.equal(schema.properties.allowRetry.constValue, "true");
|
||||
assert.equal(schema.properties.allowRetry.constDisplayValue, "true");
|
||||
assert.equal(schema.properties.allowRetry.constComparableValue, "boolean:4:true");
|
||||
});
|
||||
|
||||
test("parseSchemaContent should preserve empty-string const raw and display metadata", () => {
|
||||
const schema = parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"const": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
assert.equal(schema.properties.name.constValue, "");
|
||||
assert.equal(schema.properties.name.constDisplayValue, "\"\"");
|
||||
});
|
||||
|
||||
test("parseSchemaContent should build object const comparable keys with ordinal property ordering", () => {
|
||||
const schema = parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"z": { "type": "integer" },
|
||||
"ä": { "type": "integer" }
|
||||
},
|
||||
"const": {
|
||||
"z": 1,
|
||||
"ä": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
assert.match(schema.properties.payload.constComparableValue, /^1:z=/u);
|
||||
});
|
||||
|
||||
test("parseTopLevelYaml should parse nested mappings and object arrays", () => {
|
||||
const yaml = parseTopLevelYaml(`
|
||||
id: 1
|
||||
@ -190,6 +326,257 @@ reward:
|
||||
assert.match(diagnostics[0].message, /coin, gem/u);
|
||||
});
|
||||
|
||||
test("validateParsedConfig should report scalar const mismatches", () => {
|
||||
const schema = parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"rarity": {
|
||||
"type": "string",
|
||||
"const": "common"
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const yaml = parseTopLevelYaml(`
|
||||
rarity: rare
|
||||
`);
|
||||
|
||||
const diagnostics = validateParsedConfig(schema, yaml);
|
||||
|
||||
assert.equal(diagnostics.length, 1);
|
||||
assert.match(diagnostics[0].message, /constant value "common"|固定值 "common"/u);
|
||||
});
|
||||
|
||||
test("validateParsedConfig should accept scalar, object, array, integer, and boolean const matches", () => {
|
||||
const schema = parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"rarity": {
|
||||
"type": "string",
|
||||
"const": "common"
|
||||
},
|
||||
"reward": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"gold": { "type": "integer" },
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"const": {
|
||||
"gold": 100,
|
||||
"items": [
|
||||
"potion",
|
||||
"sword"
|
||||
]
|
||||
}
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"const": [
|
||||
"daily",
|
||||
"quest"
|
||||
]
|
||||
},
|
||||
"maxAttempts": {
|
||||
"type": "integer",
|
||||
"const": 3
|
||||
},
|
||||
"allowRetry": {
|
||||
"type": "boolean",
|
||||
"const": true
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const yaml = parseTopLevelYaml(`
|
||||
rarity: common
|
||||
reward:
|
||||
gold: 100
|
||||
items:
|
||||
- potion
|
||||
- sword
|
||||
tags:
|
||||
- daily
|
||||
- quest
|
||||
maxAttempts: 3
|
||||
allowRetry: true
|
||||
`);
|
||||
|
||||
assert.deepEqual(validateParsedConfig(schema, yaml), []);
|
||||
});
|
||||
|
||||
test("validateParsedConfig should normalize object const comparisons but keep array const order", () => {
|
||||
const schema = parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reward": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"gold": { "type": "integer" },
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"const": {
|
||||
"gold": 100,
|
||||
"items": [
|
||||
"potion",
|
||||
"sword"
|
||||
]
|
||||
}
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"const": [
|
||||
"daily",
|
||||
"quest"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const normalizedYaml = parseTopLevelYaml(`
|
||||
reward:
|
||||
items:
|
||||
- potion
|
||||
- sword
|
||||
gold: 100
|
||||
tags:
|
||||
- daily
|
||||
- quest
|
||||
`);
|
||||
const arrayOrderMismatchYaml = parseTopLevelYaml(`
|
||||
reward:
|
||||
items:
|
||||
- potion
|
||||
- sword
|
||||
gold: 100
|
||||
tags:
|
||||
- quest
|
||||
- daily
|
||||
`);
|
||||
|
||||
assert.deepEqual(validateParsedConfig(schema, normalizedYaml), []);
|
||||
|
||||
const diagnostics = validateParsedConfig(schema, arrayOrderMismatchYaml);
|
||||
|
||||
assert.equal(diagnostics.length, 1);
|
||||
assert.match(diagnostics[0].message, /tags/u);
|
||||
assert.match(diagnostics[0].message, /constant value \["daily","quest"\]|固定值 \["daily","quest"\]/u);
|
||||
});
|
||||
|
||||
test("validateParsedConfig should report object and array const mismatches", () => {
|
||||
const schema = parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reward": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"gold": { "type": "integer" },
|
||||
"currency": { "type": "string" }
|
||||
},
|
||||
"const": {
|
||||
"gold": 10,
|
||||
"currency": "coin"
|
||||
}
|
||||
},
|
||||
"dropItemIds": {
|
||||
"type": "array",
|
||||
"const": ["potion", "gem"],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const yaml = parseTopLevelYaml(`
|
||||
reward:
|
||||
gold: 10
|
||||
currency: gem
|
||||
dropItemIds:
|
||||
- gem
|
||||
- potion
|
||||
`);
|
||||
|
||||
const diagnostics = validateParsedConfig(schema, yaml);
|
||||
|
||||
assert.equal(diagnostics.length, 2);
|
||||
assert.match(diagnostics[0].message, /reward/u);
|
||||
assert.match(diagnostics[1].message, /dropItemIds/u);
|
||||
});
|
||||
|
||||
test("validateParsedConfig should cover integer and boolean const scalar normalization and mismatches", () => {
|
||||
const schema = parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"maxAttempts": {
|
||||
"type": "integer",
|
||||
"const": 3
|
||||
},
|
||||
"allowRetry": {
|
||||
"type": "boolean",
|
||||
"const": true
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const normalizedYaml = parseTopLevelYaml(`
|
||||
maxAttempts: "3"
|
||||
allowRetry: "true"
|
||||
`);
|
||||
const integerMismatchYaml = parseTopLevelYaml(`
|
||||
maxAttempts: 3.5
|
||||
allowRetry: true
|
||||
`);
|
||||
const booleanConstMismatchYaml = parseTopLevelYaml(`
|
||||
maxAttempts: 3
|
||||
allowRetry: false
|
||||
`);
|
||||
const booleanTypeMismatchYaml = parseTopLevelYaml(`
|
||||
maxAttempts: 3
|
||||
allowRetry: 0
|
||||
`);
|
||||
|
||||
assert.deepEqual(validateParsedConfig(schema, normalizedYaml), []);
|
||||
|
||||
const integerDiagnostics = validateParsedConfig(schema, integerMismatchYaml);
|
||||
|
||||
assert.equal(integerDiagnostics.length, 1);
|
||||
assert.match(integerDiagnostics[0].message, /maxAttempts/u);
|
||||
assert.match(integerDiagnostics[0].message, /integer|整数/u);
|
||||
|
||||
const booleanConstDiagnostics = validateParsedConfig(schema, booleanConstMismatchYaml);
|
||||
|
||||
assert.equal(booleanConstDiagnostics.length, 1);
|
||||
assert.match(booleanConstDiagnostics[0].message, /allowRetry/u);
|
||||
assert.match(booleanConstDiagnostics[0].message, /constant value true|固定值 true/u);
|
||||
|
||||
const booleanTypeDiagnostics = validateParsedConfig(schema, booleanTypeMismatchYaml);
|
||||
|
||||
assert.equal(booleanTypeDiagnostics.length, 1);
|
||||
assert.match(booleanTypeDiagnostics[0].message, /allowRetry/u);
|
||||
assert.match(booleanTypeDiagnostics[0].message, /boolean|布尔/u);
|
||||
});
|
||||
|
||||
test("validateParsedConfig should report numeric range and string length mismatches", () => {
|
||||
const schema = parseSchemaContent(`
|
||||
{
|
||||
@ -308,6 +695,69 @@ tags:
|
||||
assert.match(diagnostics[1].message, /at most 3 items|最多只能包含 3 个元素/u);
|
||||
});
|
||||
|
||||
test("validateParsedConfig should report object property-count mismatches", () => {
|
||||
const schema = parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"minProperties": 2,
|
||||
"maxProperties": 3,
|
||||
"properties": {
|
||||
"reward": {
|
||||
"type": "object",
|
||||
"minProperties": 2,
|
||||
"maxProperties": 2,
|
||||
"properties": {
|
||||
"gold": { "type": "integer" },
|
||||
"currency": { "type": "string" },
|
||||
"tier": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const yaml = parseTopLevelYaml(`
|
||||
reward:
|
||||
gold: 10
|
||||
currency: coin
|
||||
tier: epic
|
||||
`);
|
||||
|
||||
const diagnostics = validateParsedConfig(schema, yaml);
|
||||
const messages = diagnostics.map((diagnostic) => diagnostic.message);
|
||||
|
||||
assert.equal(diagnostics.length, 2);
|
||||
assert.ok(messages.some((message) => /at least 2 properties|至少需要包含 2 个属性/u.test(message)));
|
||||
assert.ok(messages.some((message) => /reward.*at most 2 properties|reward.*最多只能包含 2 个子属性/u.test(message)));
|
||||
});
|
||||
|
||||
test("validateParsedConfig should count unique object properties for property-count constraints", () => {
|
||||
const schema = parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reward": {
|
||||
"type": "object",
|
||||
"minProperties": 2,
|
||||
"properties": {
|
||||
"gold": { "type": "integer" },
|
||||
"currency": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const yaml = parseTopLevelYaml(`
|
||||
reward:
|
||||
gold: 10
|
||||
gold: 20
|
||||
`);
|
||||
|
||||
const diagnostics = validateParsedConfig(schema, yaml);
|
||||
|
||||
assert.equal(diagnostics.length, 1);
|
||||
assert.match(diagnostics[0].message, /reward.*at least 2 properties|reward.*至少需要包含 2 个子属性/u);
|
||||
});
|
||||
|
||||
test("validateParsedConfig should report multipleOf and uniqueItems violations", () => {
|
||||
const schema = parseSchemaContent(`
|
||||
{
|
||||
@ -615,6 +1065,31 @@ test("parseSchemaContent should capture multipleOf and uniqueItems metadata", ()
|
||||
assert.equal(schema.properties.dropRates.items.multipleOf, 0.5);
|
||||
});
|
||||
|
||||
test("parseSchemaContent should capture object property-count metadata", () => {
|
||||
const schema = parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"minProperties": 2,
|
||||
"maxProperties": 4,
|
||||
"properties": {
|
||||
"reward": {
|
||||
"type": "object",
|
||||
"minProperties": 1,
|
||||
"maxProperties": 2,
|
||||
"properties": {
|
||||
"gold": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
assert.equal(schema.minProperties, 2);
|
||||
assert.equal(schema.maxProperties, 4);
|
||||
assert.equal(schema.properties.reward.minProperties, 1);
|
||||
assert.equal(schema.properties.reward.maxProperties, 2);
|
||||
});
|
||||
|
||||
test("parseSchemaContent should reject invalid pattern declarations instead of dropping them", () => {
|
||||
assert.throws(
|
||||
() => parseSchemaContent(`
|
||||
@ -675,6 +1150,30 @@ id: 1
|
||||
assert.match(diagnostics[1].message, /未在匹配的 schema 中声明/u);
|
||||
});
|
||||
|
||||
test("validateParsedConfig should localize expected object diagnostics when Chinese UI is requested", () => {
|
||||
const schema = parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reward": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"gold": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const yaml = parseTopLevelYaml(`
|
||||
reward: 1
|
||||
`);
|
||||
|
||||
const diagnostics = validateParsedConfig(schema, yaml, {isChinese: true});
|
||||
|
||||
assert.equal(diagnostics.length, 1);
|
||||
assert.equal(diagnostics[0].message, "属性“reward”应为对象。");
|
||||
});
|
||||
|
||||
test("applyFormUpdates should update nested scalar and scalar-array paths", () => {
|
||||
const updated = applyFormUpdates(
|
||||
[
|
||||
@ -970,6 +1469,62 @@ test("getEditableSchemaFields should keep batch editing limited to top-level sca
|
||||
]);
|
||||
});
|
||||
|
||||
test("getEditableSchemaFields should sort keys with ordinal semantics", () => {
|
||||
const schema = parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"a": { "type": "string" },
|
||||
"A": { "type": "string" },
|
||||
"ä": { "type": "string" },
|
||||
"z": { "type": "string" }
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
assert.deepEqual(
|
||||
getEditableSchemaFields(schema).map((field) => field.key),
|
||||
["A", "a", "z", "ä"]);
|
||||
});
|
||||
|
||||
test("createSampleConfigYaml should preserve empty-string scalar const values", () => {
|
||||
const schema = parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"const": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const sample = createSampleConfigYaml(schema);
|
||||
|
||||
assert.match(sample, /^name: ""$/mu);
|
||||
});
|
||||
|
||||
test("createSampleConfigYaml should prefer scalar const values over defaults", () => {
|
||||
const schema = parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"rarity": {
|
||||
"type": "string",
|
||||
"const": "common",
|
||||
"default": "rare"
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const sample = createSampleConfigYaml(schema);
|
||||
|
||||
assert.match(sample, /^rarity: common$/mu);
|
||||
assert.ok(!/^rarity: rare$/mu.test(sample));
|
||||
});
|
||||
|
||||
test("parseBatchArrayValue should keep comma-separated batch editing behavior", () => {
|
||||
assert.deepEqual(parseBatchArrayValue(" potion, bomb , ,elixir "), ["potion", "bomb", "elixir"]);
|
||||
});
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {createLocalizer} = require("../src/localization");
|
||||
const {ValidationMessageKeys} = require("../src/localizationKeys");
|
||||
|
||||
test("createLocalizer should default to English strings", () => {
|
||||
const localizer = createLocalizer("en");
|
||||
@ -34,3 +35,25 @@ test("createLocalizer should fall back to English for Traditional Chinese locale
|
||||
localizer.t("message.batchEditUpdated", {count: 2, domain: "monster"}),
|
||||
"Batch updated 2 config file(s) in 'monster'.");
|
||||
});
|
||||
|
||||
test("createLocalizer should expose object property-count validation keys in English", () => {
|
||||
const localizer = createLocalizer("en");
|
||||
|
||||
assert.equal(
|
||||
localizer.t(ValidationMessageKeys.minPropertiesViolation, {displayPath: "reward", value: 2}),
|
||||
"Property 'reward' must contain at least 2 properties.");
|
||||
assert.equal(
|
||||
localizer.t(ValidationMessageKeys.maxPropertiesViolation, {displayPath: "reward", value: 3}),
|
||||
"Property 'reward' must contain at most 3 properties.");
|
||||
});
|
||||
|
||||
test("createLocalizer should expose object property-count validation keys in Simplified Chinese", () => {
|
||||
const localizer = createLocalizer("zh-cn");
|
||||
|
||||
assert.equal(
|
||||
localizer.t(ValidationMessageKeys.minPropertiesViolation, {displayPath: "reward", value: 2}),
|
||||
"对象属性“reward”至少需要包含 2 个子属性。");
|
||||
assert.equal(
|
||||
localizer.t(ValidationMessageKeys.maxPropertiesViolation, {displayPath: "reward", value: 3}),
|
||||
"对象属性“reward”最多只能包含 3 个子属性。");
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user