Merge pull request #207 from GeWuYou/feat/config-system-docs-and-validation

docs(config): 添加游戏内容配置系统完整文档与验证工具实现
This commit is contained in:
gewuyou 2026-04-10 17:04:23 +08:00 committed by GitHub
commit 9f73421532
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1414 additions and 34 deletions

View File

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

View File

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

View File

@ -322,11 +322,13 @@ internal static class YamlConfigSchemaValidator
property.Value);
}
return YamlConfigSchemaNode.CreateObject(
var objectNode = YamlConfigSchemaNode.CreateObject(
properties,
requiredProperties,
ParseObjectConstraints(tableName, schemaPath, propertyPath, element),
schemaPath);
return objectNode.WithConstantValue(
ParseConstantValue(tableName, schemaPath, propertyPath, element, objectNode));
}
/// <summary>
@ -386,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>
@ -411,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>
@ -565,6 +571,8 @@ internal static class YamlConfigSchemaValidator
{
ValidateObjectConstraints(tableName, yamlPath, displayPath, seenProperties.Count, schemaNode);
}
ValidateConstantValue(tableName, yamlPath, displayPath, mappingNode, schemaNode);
}
/// <summary>
@ -678,6 +686,7 @@ internal static class YamlConfigSchemaValidator
}
ValidateArrayUniqueItemsConstraint(tableName, yamlPath, displayPath, sequenceNode, schemaNode);
ValidateConstantValue(tableName, yamlPath, displayPath, sequenceNode, schemaNode);
}
/// <summary>
@ -770,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)
{
@ -821,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>
/// 解析标量字段支持的范围、长度与模式约束。
/// 当前共享子集支持:
@ -1427,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 默认阈值的长参数构造函数。
@ -1784,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>
@ -2147,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,
@ -2376,7 +2668,8 @@ internal sealed class YamlConfigSchemaNode
allowedValues: null,
constraints: null,
arrayConstraints: null,
objectConstraints),
objectConstraints,
constantValue: null),
schemaPathHint);
}
@ -2400,7 +2693,8 @@ internal sealed class YamlConfigSchemaNode
allowedValues: null,
constraints: null,
arrayConstraints,
objectConstraints: null),
objectConstraints: null,
constantValue: null),
schemaPathHint);
}
@ -2428,7 +2722,8 @@ internal sealed class YamlConfigSchemaNode
allowedValues,
constraints,
arrayConstraints: null,
objectConstraints: null),
objectConstraints: null,
constantValue: null),
schemaPathHint);
}
@ -2453,6 +2748,7 @@ internal sealed class YamlConfigSchemaNode
Constraints = validation.Constraints;
ArrayConstraints = validation.ArrayConstraints;
ObjectConstraints = validation.ObjectConstraints;
ConstantValue = validation.ConstantValue;
SchemaPathHint = schemaPathHint;
}
@ -2501,6 +2797,11 @@ internal sealed class YamlConfigSchemaNode
/// </summary>
public YamlConfigArrayConstraints? ArrayConstraints { get; }
/// <summary>
/// 获取节点常量约束;未声明 <c>const</c> 时返回空。
/// </summary>
public YamlConfigConstantValue? ConstantValue { get; }
/// <summary>
/// 获取用于诊断显示的 schema 路径提示。
/// 当前节点本身不记录独立路径,因此对象校验会回退到所属根 schema 路径。
@ -2522,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);
@ -2550,20 +2865,23 @@ internal sealed class YamlConfigSchemaNode
allowedValues: null,
constraints: null,
arrayConstraints: null,
objectConstraints: null);
objectConstraints: null,
constantValue: null);
public NodeValidation(
string? referenceTableName,
IReadOnlyCollection<string>? allowedValues,
YamlConfigScalarConstraints? constraints,
YamlConfigArrayConstraints? arrayConstraints,
YamlConfigObjectConstraints? objectConstraints)
YamlConfigObjectConstraints? objectConstraints,
YamlConfigConstantValue? constantValue)
{
ReferenceTableName = referenceTableName;
AllowedValues = allowedValues;
Constraints = constraints;
ArrayConstraints = arrayConstraints;
ObjectConstraints = objectConstraints;
ConstantValue = constantValue;
}
public string? ReferenceTableName { get; }
@ -2576,14 +2894,53 @@ internal sealed class YamlConfigSchemaNode
public YamlConfigObjectConstraints? ObjectConstraints { get; }
public YamlConfigConstantValue? ConstantValue { get; }
public NodeValidation WithReferenceTable(string referenceTableName)
{
return new NodeValidation(referenceTableName, AllowedValues, Constraints, ArrayConstraints,
ObjectConstraints);
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>
/// 表示一个对象节点上声明的属性数量约束。
/// 该模型将对象级约束与数组 / 标量约束拆开保存,避免运行时节点继续暴露无关成员。

View File

@ -90,6 +90,7 @@ public class SchemaConfigGeneratorSnapshotTests
},
"hp": {
"type": "integer",
"const": 10,
"minimum": 1,
"maximum": 999,
"exclusiveMinimum": 0,

View File

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

View File

@ -37,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;
@ -130,4 +130,4 @@ public sealed partial class MonsterConfig
public string MonsterId { get; set; } = string.Empty;
}
}
}

View File

@ -5,7 +5,7 @@ namespace GFramework.SourceGenerators.Config;
/// <summary>
/// 根据 AdditionalFiles 中的 JSON schema 生成配置类型和配置表包装。
/// 当前实现聚焦 AI-First 配置系统共享的最小 schema 子集,
/// 支持嵌套对象、对象数组、标量数组,以及可映射的 default / enum / ref-table 元数据。
/// 支持嵌套对象、对象数组、标量数组,以及可映射的 default / enum / const / ref-table 元数据。
/// 当前共享子集也会把 <c>multipleOf</c>、<c>uniqueItems</c>、
/// <c>minProperties</c> 与 <c>maxProperties</c> 写入生成代码文档,
/// 让消费者能直接在强类型 API 上看到运行时生效的约束。
@ -2451,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))
{
@ -2535,6 +2541,36 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
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>

View File

@ -12,7 +12,7 @@
- JSON Schema 作为结构描述
- 一对象一文件的目录组织
- 运行时只读查询
- Runtime / Generator / Tooling 共享支持 `minimum`、`maximum``exclusiveMinimum``exclusiveMaximum``multipleOf``minLength``maxLength``pattern``minItems``maxItems``uniqueItems``minProperties``maxProperties`
- Runtime / Generator / Tooling 共享支持 `const`、`minimum`、`maximum``exclusiveMinimum``exclusiveMaximum``multipleOf``minLength``maxLength``pattern``minItems``maxItems``uniqueItems``minProperties``maxProperties`
- Source Generator 生成配置类型、表包装、单表注册/访问辅助,以及项目级聚合注册目录
- VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口
@ -658,6 +658,7 @@ var loader = new YamlConfigLoader("config-root")
- 数组字段违反 `minItems` / `maxItems`
- 数组字段违反 `uniqueItems`
- 对象字段违反 `minProperties` / `maxProperties`
- 标量 / 对象 / 数组字段违反 `const`
- 标量 `enum` 不匹配
- 标量数组元素 `enum` 不匹配
- 通过 `x-gframework-ref-table` 声明的跨表引用缺失目标行
@ -704,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 文档复用
@ -809,7 +811,7 @@ var hotReload = loader.EnableHotReload(
- 对带 `x-gframework-ref-table` 的字段提供引用 schema / 配置域 / 引用文件跳转入口
- 对空配置文件提供基于 schema 的示例 YAML 初始化入口
- 对同一配置域内的多份 YAML 文件执行批量字段更新
- 在表单入口中显示 `title / description / default / enum / ref-table / multipleOf / uniqueItems / minProperties / maxProperties` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息
- 在表单入口中显示 `title / description / default / const / enum / ref-table / multipleOf / uniqueItems / minProperties / maxProperties` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息
当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。

View File

@ -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
@ -89,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));
}
/**
@ -461,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.
*
@ -476,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.
*
@ -658,7 +877,7 @@ function parseSchemaNode(rawNode, displayPath) {
properties[key] = parseSchemaNode(propertyNode, joinPropertyPath(displayPath, key));
}
return {
return applyConstMetadata({
type: "object",
displayPath,
required,
@ -668,12 +887,12 @@ function parseSchemaNode(rawNode, displayPath) {
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,
@ -684,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,
@ -722,7 +941,7 @@ function parseSchemaNode(rawNode, displayPath) {
: undefined,
enumValues: normalizeSchemaEnumValues(value.enum),
refTable: metadata.refTable
};
}, value.const, displayPath);
}
/**
@ -809,6 +1028,8 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
}
}
validateConstComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer);
return;
}
@ -945,6 +1166,8 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
})
});
}
validateConstComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer);
}
/**
@ -1024,6 +1247,37 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca
})
});
}
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
})
});
}
/**
@ -1045,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}`;
@ -1120,6 +1374,8 @@ function localizeValidationMessage(key, localizer, params) {
if (localizer && localizer.isChinese) {
switch (key) {
case ValidationMessageKeys.constMismatch:
return `属性“${params.displayPath}”必须匹配固定值 ${params.value}`;
case ValidationMessageKeys.expectedArray:
return `属性“${params.displayPath}”应为数组。`;
case ValidationMessageKeys.expectedScalarShape:
@ -1160,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:
@ -1666,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;
}
@ -1890,13 +2152,19 @@ module.exports = {
* 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,
@ -1908,6 +2176,9 @@ module.exports = {
* title?: string,
* description?: string,
* defaultValue?: string,
* constValue?: string,
* constDisplayValue?: string,
* constComparableValue?: string,
* minimum?: number,
* exclusiveMinimum?: number,
* maximum?: number,

View File

@ -1372,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] || ""
});
@ -1514,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] || ""
@ -1547,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 ?? "";
}
/**
@ -1577,7 +1577,7 @@ function getScalarArrayValue(yamlNode) {
/**
* Render human-facing metadata hints for one schema field.
*
* @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 {{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.
@ -1593,6 +1593,12 @@ function renderFieldHint(propertySchema, isArrayField, includeDescription = true
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
@ -1658,6 +1664,12 @@ function renderFieldHint(propertySchema, isArrayField, includeDescription = true
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})));
}

View File

@ -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}",
@ -131,6 +133,7 @@ const enMessages = {
"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}.",
@ -211,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}",
@ -224,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}",
@ -238,6 +243,7 @@ const zhCnMessages = {
"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}。",

View File

@ -1,4 +1,5 @@
const ValidationMessageKeys = Object.freeze({
constMismatch: "validation.constMismatch",
enumMismatch: "validation.enumMismatch",
exclusiveMaximumViolation: "validation.exclusiveMaximumViolation",
exclusiveMinimumViolation: "validation.exclusiveMinimumViolation",

View File

@ -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(`
{
@ -1082,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"]);
});