mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-12 22:03:30 +08:00
Merge pull request #206 from GeWuYou/docs/config-system
docs(config): 添加游戏内容配置系统文档与验证工具
This commit is contained in:
commit
50161a2f28
@ -1248,6 +1248,276 @@ public class YamlConfigLoaderTests
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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>
|
/// <summary>
|
||||||
/// 验证对象数组中的嵌套字段也会按 schema 递归校验。
|
/// 验证对象数组中的嵌套字段也会按 schema 递归校验。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -8,8 +8,9 @@ namespace GFramework.Game.Config;
|
|||||||
/// 提供 YAML 配置文件与 JSON Schema 之间的最小运行时校验能力。
|
/// 提供 YAML 配置文件与 JSON Schema 之间的最小运行时校验能力。
|
||||||
/// 该校验器与当前配置生成器、VS Code 工具支持的 schema 子集保持一致,
|
/// 该校验器与当前配置生成器、VS Code 工具支持的 schema 子集保持一致,
|
||||||
/// 并通过递归遍历方式覆盖嵌套对象、对象数组、标量数组与深层 enum / 引用约束。
|
/// 并通过递归遍历方式覆盖嵌套对象、对象数组、标量数组与深层 enum / 引用约束。
|
||||||
/// 当前共享子集额外支持 <c>multipleOf</c> 与 <c>uniqueItems</c>,
|
/// 当前共享子集额外支持 <c>multipleOf</c>、<c>uniqueItems</c>、
|
||||||
/// 让数值步进和数组去重规则在运行时与生成器 / 工具侧保持一致。
|
/// <c>minProperties</c> 与 <c>maxProperties</c>,
|
||||||
|
/// 让数值步进、数组去重和对象属性数量规则在运行时与生成器 / 工具侧保持一致。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class YamlConfigSchemaValidator
|
internal static class YamlConfigSchemaValidator
|
||||||
{
|
{
|
||||||
@ -321,7 +322,11 @@ internal static class YamlConfigSchemaValidator
|
|||||||
property.Value);
|
property.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
return YamlConfigSchemaNode.CreateObject(properties, requiredProperties, schemaPath);
|
return YamlConfigSchemaNode.CreateObject(
|
||||||
|
properties,
|
||||||
|
requiredProperties,
|
||||||
|
ParseObjectConstraints(tableName, schemaPath, propertyPath, element),
|
||||||
|
schemaPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -555,6 +560,66 @@ internal static class YamlConfigSchemaValidator
|
|||||||
schemaPath: schemaNode.SchemaPathHint,
|
schemaPath: schemaNode.SchemaPathHint,
|
||||||
displayPath: requiredPath);
|
displayPath: requiredPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (schemaNode.ObjectConstraints is not null)
|
||||||
|
{
|
||||||
|
ValidateObjectConstraints(tableName, yamlPath, displayPath, seenProperties.Count, 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>
|
/// <summary>
|
||||||
@ -870,6 +935,49 @@ internal static class YamlConfigSchemaValidator
|
|||||||
: new YamlConfigArrayConstraints(minItems, maxItems, uniqueItems);
|
: 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>
|
||||||
/// 读取数值区间约束。
|
/// 读取数值区间约束。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -1100,6 +1208,56 @@ internal static class YamlConfigSchemaValidator
|
|||||||
return constraintValue;
|
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>
|
||||||
/// 读取数组去重约束。
|
/// 读取数组去重约束。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -2201,17 +2359,24 @@ internal sealed class YamlConfigSchemaNode
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="properties">对象属性集合。</param>
|
/// <param name="properties">对象属性集合。</param>
|
||||||
/// <param name="requiredProperties">对象必填属性集合。</param>
|
/// <param name="requiredProperties">对象必填属性集合。</param>
|
||||||
|
/// <param name="objectConstraints">对象属性数量约束。</param>
|
||||||
/// <param name="schemaPathHint">用于错误信息的 schema 文件路径提示。</param>
|
/// <param name="schemaPathHint">用于错误信息的 schema 文件路径提示。</param>
|
||||||
/// <returns>对象节点模型。</returns>
|
/// <returns>对象节点模型。</returns>
|
||||||
public static YamlConfigSchemaNode CreateObject(
|
public static YamlConfigSchemaNode CreateObject(
|
||||||
IReadOnlyDictionary<string, YamlConfigSchemaNode>? properties,
|
IReadOnlyDictionary<string, YamlConfigSchemaNode>? properties,
|
||||||
IReadOnlyCollection<string>? requiredProperties,
|
IReadOnlyCollection<string>? requiredProperties,
|
||||||
|
YamlConfigObjectConstraints? objectConstraints,
|
||||||
string schemaPathHint)
|
string schemaPathHint)
|
||||||
{
|
{
|
||||||
return new YamlConfigSchemaNode(
|
return new YamlConfigSchemaNode(
|
||||||
YamlConfigSchemaPropertyType.Object,
|
YamlConfigSchemaPropertyType.Object,
|
||||||
new NodeChildren(properties, requiredProperties, itemNode: null),
|
new NodeChildren(properties, requiredProperties, itemNode: null),
|
||||||
NodeValidation.None,
|
new NodeValidation(
|
||||||
|
referenceTableName: null,
|
||||||
|
allowedValues: null,
|
||||||
|
constraints: null,
|
||||||
|
arrayConstraints: null,
|
||||||
|
objectConstraints),
|
||||||
schemaPathHint);
|
schemaPathHint);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2234,7 +2399,8 @@ internal sealed class YamlConfigSchemaNode
|
|||||||
referenceTableName: null,
|
referenceTableName: null,
|
||||||
allowedValues: null,
|
allowedValues: null,
|
||||||
constraints: null,
|
constraints: null,
|
||||||
arrayConstraints),
|
arrayConstraints,
|
||||||
|
objectConstraints: null),
|
||||||
schemaPathHint);
|
schemaPathHint);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2261,7 +2427,8 @@ internal sealed class YamlConfigSchemaNode
|
|||||||
referenceTableName,
|
referenceTableName,
|
||||||
allowedValues,
|
allowedValues,
|
||||||
constraints,
|
constraints,
|
||||||
arrayConstraints: null),
|
arrayConstraints: null,
|
||||||
|
objectConstraints: null),
|
||||||
schemaPathHint);
|
schemaPathHint);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2285,6 +2452,7 @@ internal sealed class YamlConfigSchemaNode
|
|||||||
AllowedValues = validation.AllowedValues;
|
AllowedValues = validation.AllowedValues;
|
||||||
Constraints = validation.Constraints;
|
Constraints = validation.Constraints;
|
||||||
ArrayConstraints = validation.ArrayConstraints;
|
ArrayConstraints = validation.ArrayConstraints;
|
||||||
|
ObjectConstraints = validation.ObjectConstraints;
|
||||||
SchemaPathHint = schemaPathHint;
|
SchemaPathHint = schemaPathHint;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2323,6 +2491,11 @@ internal sealed class YamlConfigSchemaNode
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public YamlConfigScalarConstraints? Constraints { get; }
|
public YamlConfigScalarConstraints? Constraints { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取对象属性数量约束;未声明时返回空。
|
||||||
|
/// </summary>
|
||||||
|
public YamlConfigObjectConstraints? ObjectConstraints { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取数组元素数量约束;未声明时返回空。
|
/// 获取数组元素数量约束;未声明时返回空。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -2376,18 +2549,21 @@ internal sealed class YamlConfigSchemaNode
|
|||||||
referenceTableName: null,
|
referenceTableName: null,
|
||||||
allowedValues: null,
|
allowedValues: null,
|
||||||
constraints: null,
|
constraints: null,
|
||||||
arrayConstraints: null);
|
arrayConstraints: null,
|
||||||
|
objectConstraints: null);
|
||||||
|
|
||||||
public NodeValidation(
|
public NodeValidation(
|
||||||
string? referenceTableName,
|
string? referenceTableName,
|
||||||
IReadOnlyCollection<string>? allowedValues,
|
IReadOnlyCollection<string>? allowedValues,
|
||||||
YamlConfigScalarConstraints? constraints,
|
YamlConfigScalarConstraints? constraints,
|
||||||
YamlConfigArrayConstraints? arrayConstraints)
|
YamlConfigArrayConstraints? arrayConstraints,
|
||||||
|
YamlConfigObjectConstraints? objectConstraints)
|
||||||
{
|
{
|
||||||
ReferenceTableName = referenceTableName;
|
ReferenceTableName = referenceTableName;
|
||||||
AllowedValues = allowedValues;
|
AllowedValues = allowedValues;
|
||||||
Constraints = constraints;
|
Constraints = constraints;
|
||||||
ArrayConstraints = arrayConstraints;
|
ArrayConstraints = arrayConstraints;
|
||||||
|
ObjectConstraints = objectConstraints;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string? ReferenceTableName { get; }
|
public string? ReferenceTableName { get; }
|
||||||
@ -2398,13 +2574,44 @@ internal sealed class YamlConfigSchemaNode
|
|||||||
|
|
||||||
public YamlConfigArrayConstraints? ArrayConstraints { get; }
|
public YamlConfigArrayConstraints? ArrayConstraints { get; }
|
||||||
|
|
||||||
|
public YamlConfigObjectConstraints? ObjectConstraints { get; }
|
||||||
|
|
||||||
public NodeValidation WithReferenceTable(string referenceTableName)
|
public NodeValidation WithReferenceTable(string referenceTableName)
|
||||||
{
|
{
|
||||||
return new NodeValidation(referenceTableName, AllowedValues, Constraints, ArrayConstraints);
|
return new NodeValidation(referenceTableName, AllowedValues, Constraints, ArrayConstraints,
|
||||||
|
ObjectConstraints);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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>
|
/// <summary>
|
||||||
/// 聚合一个标量节点上声明的数值约束与字符串约束。
|
/// 聚合一个标量节点上声明的数值约束与字符串约束。
|
||||||
/// 该包装层保留“标量字段有约束”的统一入口,同时把不同语义的约束分成更小的专用模型。
|
/// 该包装层保留“标量字段有约束”的统一入口,同时把不同语义的约束分成更小的专用模型。
|
||||||
|
|||||||
@ -69,6 +69,8 @@ public class SchemaConfigGeneratorSnapshotTests
|
|||||||
"title": "Monster Config",
|
"title": "Monster Config",
|
||||||
"description": "Represents one monster entry generated from schema metadata.",
|
"description": "Represents one monster entry generated from schema metadata.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
"minProperties": 4,
|
||||||
|
"maxProperties": 8,
|
||||||
"required": ["id", "name", "reward", "phases"],
|
"required": ["id", "name", "reward", "phases"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": {
|
"id": {
|
||||||
@ -113,6 +115,8 @@ public class SchemaConfigGeneratorSnapshotTests
|
|||||||
"reward": {
|
"reward": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Reward payload.",
|
"description": "Reward payload.",
|
||||||
|
"minProperties": 2,
|
||||||
|
"maxProperties": 2,
|
||||||
"required": ["gold", "currency"],
|
"required": ["gold", "currency"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"gold": {
|
"gold": {
|
||||||
|
|||||||
@ -314,6 +314,48 @@ public class SchemaConfigGeneratorTests
|
|||||||
Does.Contain("Throwing here would permanently poison the cached index for this wrapper instance."));
|
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>
|
/// <summary>
|
||||||
/// 验证 schema 顶层自定义配置目录元数据不能逃逸配置根目录。
|
/// 验证 schema 顶层自定义配置目录元数据不能逃逸配置根目录。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -7,6 +7,9 @@ namespace GFramework.Game.Config.Generated;
|
|||||||
/// Auto-generated config type for schema file 'monster.schema.json'.
|
/// Auto-generated config type for schema file 'monster.schema.json'.
|
||||||
/// Represents one monster entry generated from schema metadata.
|
/// Represents one monster entry generated from schema metadata.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Constraints: minProperties = 4, maxProperties = 8.
|
||||||
|
/// </remarks>
|
||||||
public sealed partial class MonsterConfig
|
public sealed partial class MonsterConfig
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -74,6 +77,9 @@ public sealed partial class MonsterConfig
|
|||||||
/// Auto-generated nested config type for schema property path 'reward'.
|
/// Auto-generated nested config type for schema property path 'reward'.
|
||||||
/// Reward payload.
|
/// Reward payload.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Constraints: minProperties = 2, maxProperties = 2.
|
||||||
|
/// </remarks>
|
||||||
public sealed partial class RewardConfig
|
public sealed partial class RewardConfig
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -124,4 +130,4 @@ public sealed partial class MonsterConfig
|
|||||||
public string MonsterId { get; set; } = string.Empty;
|
public string MonsterId { get; set; } = string.Empty;
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -6,7 +6,8 @@ namespace GFramework.SourceGenerators.Config;
|
|||||||
/// 根据 AdditionalFiles 中的 JSON schema 生成配置类型和配置表包装。
|
/// 根据 AdditionalFiles 中的 JSON schema 生成配置类型和配置表包装。
|
||||||
/// 当前实现聚焦 AI-First 配置系统共享的最小 schema 子集,
|
/// 当前实现聚焦 AI-First 配置系统共享的最小 schema 子集,
|
||||||
/// 支持嵌套对象、对象数组、标量数组,以及可映射的 default / enum / ref-table 元数据。
|
/// 支持嵌套对象、对象数组、标量数组,以及可映射的 default / enum / ref-table 元数据。
|
||||||
/// 当前共享子集也会把 <c>multipleOf</c> 与 <c>uniqueItems</c> 写入生成代码文档,
|
/// 当前共享子集也会把 <c>multipleOf</c>、<c>uniqueItems</c>、
|
||||||
|
/// <c>minProperties</c> 与 <c>maxProperties</c> 写入生成代码文档,
|
||||||
/// 让消费者能直接在强类型 API 上看到运行时生效的约束。
|
/// 让消费者能直接在强类型 API 上看到运行时生效的约束。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Generator]
|
[Generator]
|
||||||
@ -219,7 +220,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
|||||||
Path.GetFileName(filePath)));
|
Path.GetFileName(filePath)));
|
||||||
}
|
}
|
||||||
|
|
||||||
var requiredProperties = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
var requiredProperties = new HashSet<string>(StringComparer.Ordinal);
|
||||||
if (element.TryGetProperty("required", out var requiredElement) &&
|
if (element.TryGetProperty("required", out var requiredElement) &&
|
||||||
requiredElement.ValueKind == JsonValueKind.Array)
|
requiredElement.ValueKind == JsonValueKind.Array)
|
||||||
{
|
{
|
||||||
@ -258,6 +259,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
|||||||
className,
|
className,
|
||||||
TryGetMetadataString(element, "title"),
|
TryGetMetadataString(element, "title"),
|
||||||
TryGetMetadataString(element, "description"),
|
TryGetMetadataString(element, "description"),
|
||||||
|
TryBuildConstraintDocumentation(element, "object"),
|
||||||
properties));
|
properties));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1876,6 +1878,14 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
|||||||
}
|
}
|
||||||
|
|
||||||
builder.AppendLine($"{indent}/// </summary>");
|
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}public sealed partial class {objectSpec.ClassName}");
|
||||||
builder.AppendLine($"{indent}{{");
|
builder.AppendLine($"{indent}{{");
|
||||||
|
|
||||||
@ -2432,7 +2442,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 将 shared schema 子集中的范围、步进、长度、模式与数组数量 / 去重约束整理成 XML 文档可读字符串。
|
/// 将 shared schema 子集中的范围、步进、长度、数组数量 / 去重与对象属性数量约束整理成 XML 文档可读字符串。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="element">Schema 节点。</param>
|
/// <param name="element">Schema 节点。</param>
|
||||||
/// <param name="schemaType">标量类型。</param>
|
/// <param name="schemaType">标量类型。</param>
|
||||||
@ -2510,6 +2520,18 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
|||||||
parts.Add("uniqueItems = true");
|
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;
|
return parts.Count > 0 ? string.Join(", ", parts) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2654,12 +2676,14 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
|||||||
/// <param name="ClassName">要生成的 CLR 类型名。</param>
|
/// <param name="ClassName">要生成的 CLR 类型名。</param>
|
||||||
/// <param name="Title">对象标题元数据。</param>
|
/// <param name="Title">对象标题元数据。</param>
|
||||||
/// <param name="Description">对象描述元数据。</param>
|
/// <param name="Description">对象描述元数据。</param>
|
||||||
|
/// <param name="ConstraintDocumentation">对象约束说明。</param>
|
||||||
/// <param name="Properties">对象属性集合。</param>
|
/// <param name="Properties">对象属性集合。</param>
|
||||||
private sealed record SchemaObjectSpec(
|
private sealed record SchemaObjectSpec(
|
||||||
string DisplayPath,
|
string DisplayPath,
|
||||||
string ClassName,
|
string ClassName,
|
||||||
string? Title,
|
string? Title,
|
||||||
string? Description,
|
string? Description,
|
||||||
|
string? ConstraintDocumentation,
|
||||||
IReadOnlyList<SchemaPropertySpec> Properties);
|
IReadOnlyList<SchemaPropertySpec> Properties);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
- JSON Schema 作为结构描述
|
- JSON Schema 作为结构描述
|
||||||
- 一对象一文件的目录组织
|
- 一对象一文件的目录组织
|
||||||
- 运行时只读查询
|
- 运行时只读查询
|
||||||
- Runtime / Generator / Tooling 共享支持 `minimum`、`maximum`、`exclusiveMinimum`、`exclusiveMaximum`、`multipleOf`、`minLength`、`maxLength`、`pattern`、`minItems`、`maxItems`、`uniqueItems`
|
- Runtime / Generator / Tooling 共享支持 `minimum`、`maximum`、`exclusiveMinimum`、`exclusiveMaximum`、`multipleOf`、`minLength`、`maxLength`、`pattern`、`minItems`、`maxItems`、`uniqueItems`、`minProperties`、`maxProperties`
|
||||||
- Source Generator 生成配置类型、表包装、单表注册/访问辅助,以及项目级聚合注册目录
|
- Source Generator 生成配置类型、表包装、单表注册/访问辅助,以及项目级聚合注册目录
|
||||||
- VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口
|
- VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口
|
||||||
|
|
||||||
@ -657,6 +657,7 @@ var loader = new YamlConfigLoader("config-root")
|
|||||||
- 字符串字段违反 `pattern`
|
- 字符串字段违反 `pattern`
|
||||||
- 数组字段违反 `minItems` / `maxItems`
|
- 数组字段违反 `minItems` / `maxItems`
|
||||||
- 数组字段违反 `uniqueItems`
|
- 数组字段违反 `uniqueItems`
|
||||||
|
- 对象字段违反 `minProperties` / `maxProperties`
|
||||||
- 标量 `enum` 不匹配
|
- 标量 `enum` 不匹配
|
||||||
- 标量数组元素 `enum` 不匹配
|
- 标量数组元素 `enum` 不匹配
|
||||||
- 通过 `x-gframework-ref-table` 声明的跨表引用缺失目标行
|
- 通过 `x-gframework-ref-table` 声明的跨表引用缺失目标行
|
||||||
@ -711,6 +712,7 @@ if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var re
|
|||||||
- `pattern`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用;当前按 C# `CultureInvariant` 与 JS Unicode `u` 模式解释,非法模式会在 schema 解析阶段直接报错
|
- `pattern`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用;当前按 C# `CultureInvariant` 与 JS Unicode `u` 模式解释,非法模式会在 schema 解析阶段直接报错
|
||||||
- `minItems` / `maxItems`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用
|
- `minItems` / `maxItems`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用
|
||||||
- `uniqueItems`:供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;对象数组会按 schema 归一化后的结构比较重复项,而不是依赖 YAML 字段顺序
|
- `uniqueItems`:供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;对象数组会按 schema 归一化后的结构比较重复项,而不是依赖 YAML 字段顺序
|
||||||
|
- `minProperties` / `maxProperties`:供运行时校验、VS Code 校验、对象 section 表单 hint 和生成代码 XML 文档复用;根对象与嵌套对象都会按实际属性数量执行同一套约束
|
||||||
|
|
||||||
这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。
|
这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。
|
||||||
|
|
||||||
@ -807,7 +809,7 @@ var hotReload = loader.EnableHotReload(
|
|||||||
- 对带 `x-gframework-ref-table` 的字段提供引用 schema / 配置域 / 引用文件跳转入口
|
- 对带 `x-gframework-ref-table` 的字段提供引用 schema / 配置域 / 引用文件跳转入口
|
||||||
- 对空配置文件提供基于 schema 的示例 YAML 初始化入口
|
- 对空配置文件提供基于 schema 的示例 YAML 初始化入口
|
||||||
- 对同一配置域内的多份 YAML 文件执行批量字段更新
|
- 对同一配置域内的多份 YAML 文件执行批量字段更新
|
||||||
- 在表单入口中显示 `title / description / default / enum / ref-table / multipleOf / uniqueItems` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息
|
- 在表单入口中显示 `title / description / default / enum / ref-table / multipleOf / uniqueItems / minProperties / maxProperties` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息
|
||||||
|
|
||||||
当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。
|
当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。
|
||||||
|
|
||||||
|
|||||||
@ -20,7 +20,9 @@ const BooleanScalarPattern = /^(true|false)$/iu;
|
|||||||
* @returns {{
|
* @returns {{
|
||||||
* type: "object",
|
* type: "object",
|
||||||
* required: string[],
|
* required: string[],
|
||||||
* properties: Record<string, SchemaNode>
|
* properties: Record<string, SchemaNode>,
|
||||||
|
* minProperties?: number,
|
||||||
|
* maxProperties?: number
|
||||||
* }} Parsed schema info.
|
* }} Parsed schema info.
|
||||||
*/
|
*/
|
||||||
function parseSchemaContent(content) {
|
function parseSchemaContent(content) {
|
||||||
@ -639,6 +641,8 @@ function parseSchemaNode(rawNode, displayPath) {
|
|||||||
patternRegex: patternMetadata ? patternMetadata.regex : undefined,
|
patternRegex: patternMetadata ? patternMetadata.regex : undefined,
|
||||||
minItems: normalizeSchemaNonNegativeInteger(value.minItems),
|
minItems: normalizeSchemaNonNegativeInteger(value.minItems),
|
||||||
maxItems: normalizeSchemaNonNegativeInteger(value.maxItems),
|
maxItems: normalizeSchemaNonNegativeInteger(value.maxItems),
|
||||||
|
minProperties: normalizeSchemaNonNegativeInteger(value.minProperties),
|
||||||
|
maxProperties: normalizeSchemaNonNegativeInteger(value.maxProperties),
|
||||||
uniqueItems: normalizeSchemaBoolean(value.uniqueItems),
|
uniqueItems: normalizeSchemaBoolean(value.uniqueItems),
|
||||||
refTable: typeof value["x-gframework-ref-table"] === "string"
|
refTable: typeof value["x-gframework-ref-table"] === "string"
|
||||||
? value["x-gframework-ref-table"]
|
? value["x-gframework-ref-table"]
|
||||||
@ -659,6 +663,8 @@ function parseSchemaNode(rawNode, displayPath) {
|
|||||||
displayPath,
|
displayPath,
|
||||||
required,
|
required,
|
||||||
properties,
|
properties,
|
||||||
|
minProperties: metadata.minProperties,
|
||||||
|
maxProperties: metadata.maxProperties,
|
||||||
title: metadata.title,
|
title: metadata.title,
|
||||||
description: metadata.description,
|
description: metadata.description,
|
||||||
defaultValue: metadata.defaultValue
|
defaultValue: metadata.defaultValue
|
||||||
@ -952,23 +958,21 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
|
|||||||
*/
|
*/
|
||||||
function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) {
|
function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) {
|
||||||
if (!yamlNode || yamlNode.kind !== "object") {
|
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({
|
diagnostics.push({
|
||||||
severity: "error",
|
severity: "error",
|
||||||
message: localizeValidationMessage(ValidationMessageKeys.expectedObject, localizer, {
|
message: localizeValidationMessage(ValidationMessageKeys.expectedObject, localizer, {
|
||||||
subject,
|
|
||||||
displayPath
|
displayPath
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
return;
|
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) {
|
for (const requiredProperty of schemaNode.required) {
|
||||||
if (!yamlNode.map.has(requiredProperty)) {
|
if (!yamlNode.map.has(requiredProperty)) {
|
||||||
diagnostics.push({
|
diagnostics.push({
|
||||||
@ -998,6 +1002,28 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca
|
|||||||
diagnostics,
|
diagnostics,
|
||||||
localizer);
|
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)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1060,6 +1086,34 @@ function buildComparableNodeValue(schemaNode, yamlNode) {
|
|||||||
* @returns {string} Localized validation message.
|
* @returns {string} Localized validation message.
|
||||||
*/
|
*/
|
||||||
function localizeValidationMessage(key, localizer, params) {
|
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") {
|
if (localizer && typeof localizer.t === "function") {
|
||||||
return localizer.t(key, params);
|
return localizer.t(key, params);
|
||||||
}
|
}
|
||||||
@ -1096,8 +1150,6 @@ function localizeValidationMessage(key, localizer, params) {
|
|||||||
return `属性“${params.displayPath}”必须匹配正则模式“${params.value}”。`;
|
return `属性“${params.displayPath}”必须匹配正则模式“${params.value}”。`;
|
||||||
case ValidationMessageKeys.uniqueItemsViolation:
|
case ValidationMessageKeys.uniqueItemsViolation:
|
||||||
return `属性“${params.displayPath}”与更早的数组元素 ${params.duplicatePath} 重复;该数组要求元素唯一。`;
|
return `属性“${params.displayPath}”与更早的数组元素 ${params.duplicatePath} 重复;该数组要求元素唯一。`;
|
||||||
case ValidationMessageKeys.expectedObject:
|
|
||||||
return params.subject;
|
|
||||||
case ValidationMessageKeys.missingRequired:
|
case ValidationMessageKeys.missingRequired:
|
||||||
return `缺少必填属性“${params.displayPath}”。`;
|
return `缺少必填属性“${params.displayPath}”。`;
|
||||||
case ValidationMessageKeys.unknownProperty:
|
case ValidationMessageKeys.unknownProperty:
|
||||||
@ -1138,8 +1190,6 @@ function localizeValidationMessage(key, localizer, params) {
|
|||||||
return `Property '${params.displayPath}' must match pattern '${params.value}'.`;
|
return `Property '${params.displayPath}' must match pattern '${params.value}'.`;
|
||||||
case ValidationMessageKeys.uniqueItemsViolation:
|
case ValidationMessageKeys.uniqueItemsViolation:
|
||||||
return `Property '${params.displayPath}' duplicates earlier array item '${params.duplicatePath}', but uniqueItems is required.`;
|
return `Property '${params.displayPath}' duplicates earlier array item '${params.duplicatePath}', but uniqueItems is required.`;
|
||||||
case ValidationMessageKeys.expectedObject:
|
|
||||||
return params.subject;
|
|
||||||
case ValidationMessageKeys.missingRequired:
|
case ValidationMessageKeys.missingRequired:
|
||||||
return `Required property '${params.displayPath}' is missing.`;
|
return `Required property '${params.displayPath}' is missing.`;
|
||||||
case ValidationMessageKeys.unknownProperty:
|
case ValidationMessageKeys.unknownProperty:
|
||||||
@ -1149,6 +1199,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.
|
* Tokenize YAML lines into indentation-aware units.
|
||||||
*
|
*
|
||||||
@ -1782,6 +1886,8 @@ module.exports = {
|
|||||||
* displayPath: string,
|
* displayPath: string,
|
||||||
* required: string[],
|
* required: string[],
|
||||||
* properties: Record<string, SchemaNode>,
|
* properties: Record<string, SchemaNode>,
|
||||||
|
* minProperties?: number,
|
||||||
|
* maxProperties?: number,
|
||||||
* title?: string,
|
* title?: string,
|
||||||
* description?: string,
|
* description?: string,
|
||||||
* defaultValue?: string
|
* defaultValue?: string
|
||||||
|
|||||||
@ -1095,6 +1095,7 @@ function renderFormField(field) {
|
|||||||
<div class="meta-key">${escapeHtml(field.displayPath || field.path)}</div>
|
<div class="meta-key">${escapeHtml(field.displayPath || field.path)}</div>
|
||||||
${renderYamlCommentBlock(field)}
|
${renderYamlCommentBlock(field)}
|
||||||
${field.description ? `<span class="hint">${escapeHtml(field.description)}</span>` : ""}
|
${field.description ? `<span class="hint">${escapeHtml(field.description)}</span>` : ""}
|
||||||
|
${field.schema ? renderFieldHint(field.schema, false, false) : ""}
|
||||||
${renderCommentEditor(field)}
|
${renderCommentEditor(field)}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@ -1302,6 +1303,7 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns
|
|||||||
path: propertyPath,
|
path: propertyPath,
|
||||||
label,
|
label,
|
||||||
description: propertySchema.description,
|
description: propertySchema.description,
|
||||||
|
schema: propertySchema,
|
||||||
comment: commentLookup[propertyPath] || "",
|
comment: commentLookup[propertyPath] || "",
|
||||||
required: requiredSet.has(key),
|
required: requiredSet.has(key),
|
||||||
depth
|
depth
|
||||||
@ -1468,6 +1470,7 @@ function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPa
|
|||||||
displayPath: itemDisplayPath,
|
displayPath: itemDisplayPath,
|
||||||
label,
|
label,
|
||||||
description: propertySchema.description,
|
description: propertySchema.description,
|
||||||
|
schema: propertySchema,
|
||||||
comment: commentLookup[itemDisplayPath] || "",
|
comment: commentLookup[itemDisplayPath] || "",
|
||||||
required: requiredSet.has(key),
|
required: requiredSet.has(key),
|
||||||
depth
|
depth
|
||||||
@ -1574,14 +1577,15 @@ function getScalarArrayValue(yamlNode) {
|
|||||||
/**
|
/**
|
||||||
* Render human-facing metadata hints for one schema field.
|
* Render human-facing metadata hints for one schema field.
|
||||||
*
|
*
|
||||||
* @param {{description?: string, defaultValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, 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, 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[], minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string}, refTable?: string}} propertySchema Property schema metadata.
|
||||||
* @param {boolean} isArrayField Whether the field is an array.
|
* @param {boolean} isArrayField Whether the field is an array.
|
||||||
|
* @param {boolean} includeDescription Whether description text should be included in the hint output.
|
||||||
* @returns {string} HTML fragment.
|
* @returns {string} HTML fragment.
|
||||||
*/
|
*/
|
||||||
function renderFieldHint(propertySchema, isArrayField) {
|
function renderFieldHint(propertySchema, isArrayField, includeDescription = true) {
|
||||||
const hints = [];
|
const hints = [];
|
||||||
|
|
||||||
if (propertySchema.description) {
|
if (includeDescription && propertySchema.description) {
|
||||||
hints.push(escapeHtml(propertySchema.description));
|
hints.push(escapeHtml(propertySchema.description));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1630,6 +1634,14 @@ function renderFieldHint(propertySchema, isArrayField) {
|
|||||||
hints.push(escapeHtml(localizer.t("webview.hint.pattern", {value: propertySchema.pattern})));
|
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") {
|
if (isArrayField && typeof propertySchema.minItems === "number") {
|
||||||
hints.push(escapeHtml(localizer.t("webview.hint.minItems", {value: propertySchema.minItems})));
|
hints.push(escapeHtml(localizer.t("webview.hint.minItems", {value: propertySchema.minItems})));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -124,6 +124,8 @@ const enMessages = {
|
|||||||
"webview.hint.itemMinLength": "Item min length: {value}",
|
"webview.hint.itemMinLength": "Item min length: {value}",
|
||||||
"webview.hint.itemMaxLength": "Item max length: {value}",
|
"webview.hint.itemMaxLength": "Item max length: {value}",
|
||||||
"webview.hint.itemPattern": "Item pattern: {value}",
|
"webview.hint.itemPattern": "Item pattern: {value}",
|
||||||
|
"webview.hint.minProperties": "Min properties: {value}",
|
||||||
|
"webview.hint.maxProperties": "Max properties: {value}",
|
||||||
"webview.hint.refTable": "Ref table: {refTable}",
|
"webview.hint.refTable": "Ref table: {refTable}",
|
||||||
"webview.unsupported.array": "Unsupported array shapes are currently raw-YAML-only in the form preview.",
|
"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.type": "{type} fields are currently raw-YAML-only.",
|
||||||
@ -134,15 +136,16 @@ const enMessages = {
|
|||||||
[ValidationMessageKeys.maximumViolation]: "Property '{displayPath}' must be less than or equal to {value}.",
|
[ValidationMessageKeys.maximumViolation]: "Property '{displayPath}' must be less than or equal to {value}.",
|
||||||
[ValidationMessageKeys.maxItemsViolation]: "Property '{displayPath}' must contain at most {value} items.",
|
[ValidationMessageKeys.maxItemsViolation]: "Property '{displayPath}' must contain at most {value} items.",
|
||||||
[ValidationMessageKeys.maxLengthViolation]: "Property '{displayPath}' must be at most {value} characters long.",
|
[ValidationMessageKeys.maxLengthViolation]: "Property '{displayPath}' must be at most {value} characters long.",
|
||||||
|
[ValidationMessageKeys.maxPropertiesViolation]: "Property '{displayPath}' must contain at most {value} properties.",
|
||||||
[ValidationMessageKeys.minimumViolation]: "Property '{displayPath}' must be greater than or equal to {value}.",
|
[ValidationMessageKeys.minimumViolation]: "Property '{displayPath}' must be greater than or equal to {value}.",
|
||||||
[ValidationMessageKeys.multipleOfViolation]: "Property '{displayPath}' must be a multiple of {value}.",
|
[ValidationMessageKeys.multipleOfViolation]: "Property '{displayPath}' must be a multiple of {value}.",
|
||||||
[ValidationMessageKeys.minItemsViolation]: "Property '{displayPath}' must contain at least {value} items.",
|
[ValidationMessageKeys.minItemsViolation]: "Property '{displayPath}' must contain at least {value} items.",
|
||||||
[ValidationMessageKeys.minLengthViolation]: "Property '{displayPath}' must be at least {value} characters long.",
|
[ValidationMessageKeys.minLengthViolation]: "Property '{displayPath}' must be at least {value} characters long.",
|
||||||
|
[ValidationMessageKeys.minPropertiesViolation]: "Property '{displayPath}' must contain at least {value} properties.",
|
||||||
[ValidationMessageKeys.patternViolation]: "Property '{displayPath}' must match pattern '{value}'.",
|
[ValidationMessageKeys.patternViolation]: "Property '{displayPath}' must match pattern '{value}'.",
|
||||||
[ValidationMessageKeys.uniqueItemsViolation]: "Property '{displayPath}' duplicates earlier array item '{duplicatePath}', but uniqueItems is required.",
|
[ValidationMessageKeys.uniqueItemsViolation]: "Property '{displayPath}' duplicates earlier array item '{duplicatePath}', but uniqueItems is required.",
|
||||||
[ValidationMessageKeys.enumMismatch]: "Property '{displayPath}' must be one of: {values}.",
|
[ValidationMessageKeys.enumMismatch]: "Property '{displayPath}' must be one of: {values}.",
|
||||||
[ValidationMessageKeys.expectedArray]: "Property '{displayPath}' is expected to be an array.",
|
[ValidationMessageKeys.expectedArray]: "Property '{displayPath}' is expected to be an array.",
|
||||||
[ValidationMessageKeys.expectedObject]: "{subject} is expected to be an object.",
|
|
||||||
[ValidationMessageKeys.expectedScalarShape]: "Property '{displayPath}' is expected to be '{schemaType}', but the current YAML shape is '{yamlKind}'.",
|
[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.expectedScalarValue]: "Property '{displayPath}' is expected to be '{schemaType}', but the current scalar value is incompatible.",
|
||||||
[ValidationMessageKeys.missingRequired]: "Required property '{displayPath}' is missing.",
|
[ValidationMessageKeys.missingRequired]: "Required property '{displayPath}' is missing.",
|
||||||
@ -228,6 +231,8 @@ const zhCnMessages = {
|
|||||||
"webview.hint.itemMinLength": "元素最小长度:{value}",
|
"webview.hint.itemMinLength": "元素最小长度:{value}",
|
||||||
"webview.hint.itemMaxLength": "元素最大长度:{value}",
|
"webview.hint.itemMaxLength": "元素最大长度:{value}",
|
||||||
"webview.hint.itemPattern": "元素正则模式:{value}",
|
"webview.hint.itemPattern": "元素正则模式:{value}",
|
||||||
|
"webview.hint.minProperties": "最少属性数:{value}",
|
||||||
|
"webview.hint.maxProperties": "最多属性数:{value}",
|
||||||
"webview.hint.refTable": "引用表:{refTable}",
|
"webview.hint.refTable": "引用表:{refTable}",
|
||||||
"webview.unsupported.array": "当前表单预览暂不支持这种数组结构,请改用原始 YAML。",
|
"webview.unsupported.array": "当前表单预览暂不支持这种数组结构,请改用原始 YAML。",
|
||||||
"webview.unsupported.type": "当前表单预览暂不支持 {type} 字段,请改用原始 YAML。",
|
"webview.unsupported.type": "当前表单预览暂不支持 {type} 字段,请改用原始 YAML。",
|
||||||
@ -238,15 +243,16 @@ const zhCnMessages = {
|
|||||||
[ValidationMessageKeys.maximumViolation]: "属性“{displayPath}”必须小于或等于 {value}。",
|
[ValidationMessageKeys.maximumViolation]: "属性“{displayPath}”必须小于或等于 {value}。",
|
||||||
[ValidationMessageKeys.maxItemsViolation]: "属性“{displayPath}”最多只能包含 {value} 个元素。",
|
[ValidationMessageKeys.maxItemsViolation]: "属性“{displayPath}”最多只能包含 {value} 个元素。",
|
||||||
[ValidationMessageKeys.maxLengthViolation]: "属性“{displayPath}”长度必须不超过 {value} 个字符。",
|
[ValidationMessageKeys.maxLengthViolation]: "属性“{displayPath}”长度必须不超过 {value} 个字符。",
|
||||||
|
[ValidationMessageKeys.maxPropertiesViolation]: "对象属性“{displayPath}”最多只能包含 {value} 个子属性。",
|
||||||
[ValidationMessageKeys.minimumViolation]: "属性“{displayPath}”必须大于或等于 {value}。",
|
[ValidationMessageKeys.minimumViolation]: "属性“{displayPath}”必须大于或等于 {value}。",
|
||||||
[ValidationMessageKeys.multipleOfViolation]: "属性“{displayPath}”必须是 {value} 的整数倍。",
|
[ValidationMessageKeys.multipleOfViolation]: "属性“{displayPath}”必须是 {value} 的整数倍。",
|
||||||
[ValidationMessageKeys.minItemsViolation]: "属性“{displayPath}”至少需要包含 {value} 个元素。",
|
[ValidationMessageKeys.minItemsViolation]: "属性“{displayPath}”至少需要包含 {value} 个元素。",
|
||||||
[ValidationMessageKeys.minLengthViolation]: "属性“{displayPath}”长度必须至少为 {value} 个字符。",
|
[ValidationMessageKeys.minLengthViolation]: "属性“{displayPath}”长度必须至少为 {value} 个字符。",
|
||||||
|
[ValidationMessageKeys.minPropertiesViolation]: "对象属性“{displayPath}”至少需要包含 {value} 个子属性。",
|
||||||
[ValidationMessageKeys.patternViolation]: "属性“{displayPath}”必须匹配正则模式“{value}”。",
|
[ValidationMessageKeys.patternViolation]: "属性“{displayPath}”必须匹配正则模式“{value}”。",
|
||||||
[ValidationMessageKeys.uniqueItemsViolation]: "属性“{displayPath}”与更早的数组元素“{duplicatePath}”重复;该数组要求元素唯一。",
|
[ValidationMessageKeys.uniqueItemsViolation]: "属性“{displayPath}”与更早的数组元素“{duplicatePath}”重复;该数组要求元素唯一。",
|
||||||
[ValidationMessageKeys.enumMismatch]: "属性“{displayPath}”必须是以下值之一:{values}。",
|
[ValidationMessageKeys.enumMismatch]: "属性“{displayPath}”必须是以下值之一:{values}。",
|
||||||
[ValidationMessageKeys.expectedArray]: "属性“{displayPath}”应为数组。",
|
[ValidationMessageKeys.expectedArray]: "属性“{displayPath}”应为数组。",
|
||||||
[ValidationMessageKeys.expectedObject]: "{subject}",
|
|
||||||
[ValidationMessageKeys.expectedScalarShape]: "属性“{displayPath}”应为“{schemaType}”,但当前 YAML 结构是“{yamlKind}”。",
|
[ValidationMessageKeys.expectedScalarShape]: "属性“{displayPath}”应为“{schemaType}”,但当前 YAML 结构是“{yamlKind}”。",
|
||||||
[ValidationMessageKeys.expectedScalarValue]: "属性“{displayPath}”应为“{schemaType}”,但当前标量值不兼容。",
|
[ValidationMessageKeys.expectedScalarValue]: "属性“{displayPath}”应为“{schemaType}”,但当前标量值不兼容。",
|
||||||
[ValidationMessageKeys.missingRequired]: "缺少必填属性“{displayPath}”。",
|
[ValidationMessageKeys.missingRequired]: "缺少必填属性“{displayPath}”。",
|
||||||
|
|||||||
@ -9,10 +9,12 @@ const ValidationMessageKeys = Object.freeze({
|
|||||||
maximumViolation: "validation.maximumViolation",
|
maximumViolation: "validation.maximumViolation",
|
||||||
maxItemsViolation: "validation.maxItemsViolation",
|
maxItemsViolation: "validation.maxItemsViolation",
|
||||||
maxLengthViolation: "validation.maxLengthViolation",
|
maxLengthViolation: "validation.maxLengthViolation",
|
||||||
|
maxPropertiesViolation: "validation.maxPropertiesViolation",
|
||||||
minimumViolation: "validation.minimumViolation",
|
minimumViolation: "validation.minimumViolation",
|
||||||
multipleOfViolation: "validation.multipleOfViolation",
|
multipleOfViolation: "validation.multipleOfViolation",
|
||||||
minItemsViolation: "validation.minItemsViolation",
|
minItemsViolation: "validation.minItemsViolation",
|
||||||
minLengthViolation: "validation.minLengthViolation",
|
minLengthViolation: "validation.minLengthViolation",
|
||||||
|
minPropertiesViolation: "validation.minPropertiesViolation",
|
||||||
missingRequired: "validation.missingRequired",
|
missingRequired: "validation.missingRequired",
|
||||||
patternViolation: "validation.patternViolation",
|
patternViolation: "validation.patternViolation",
|
||||||
uniqueItemsViolation: "validation.uniqueItemsViolation",
|
uniqueItemsViolation: "validation.uniqueItemsViolation",
|
||||||
|
|||||||
@ -308,6 +308,69 @@ tags:
|
|||||||
assert.match(diagnostics[1].message, /at most 3 items|最多只能包含 3 个元素/u);
|
assert.match(diagnostics[1].message, /at most 3 items|最多只能包含 3 个元素/u);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("validateParsedConfig should report 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", () => {
|
test("validateParsedConfig should report multipleOf and uniqueItems violations", () => {
|
||||||
const schema = parseSchemaContent(`
|
const schema = parseSchemaContent(`
|
||||||
{
|
{
|
||||||
@ -615,6 +678,31 @@ test("parseSchemaContent should capture multipleOf and uniqueItems metadata", ()
|
|||||||
assert.equal(schema.properties.dropRates.items.multipleOf, 0.5);
|
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", () => {
|
test("parseSchemaContent should reject invalid pattern declarations instead of dropping them", () => {
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => parseSchemaContent(`
|
() => parseSchemaContent(`
|
||||||
@ -675,6 +763,30 @@ id: 1
|
|||||||
assert.match(diagnostics[1].message, /未在匹配的 schema 中声明/u);
|
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", () => {
|
test("applyFormUpdates should update nested scalar and scalar-array paths", () => {
|
||||||
const updated = applyFormUpdates(
|
const updated = applyFormUpdates(
|
||||||
[
|
[
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
const test = require("node:test");
|
const test = require("node:test");
|
||||||
const assert = require("node:assert/strict");
|
const assert = require("node:assert/strict");
|
||||||
const {createLocalizer} = require("../src/localization");
|
const {createLocalizer} = require("../src/localization");
|
||||||
|
const {ValidationMessageKeys} = require("../src/localizationKeys");
|
||||||
|
|
||||||
test("createLocalizer should default to English strings", () => {
|
test("createLocalizer should default to English strings", () => {
|
||||||
const localizer = createLocalizer("en");
|
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"}),
|
localizer.t("message.batchEditUpdated", {count: 2, domain: "monster"}),
|
||||||
"Batch updated 2 config file(s) in '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