feat(config): 添加配置验证功能

- 实现配置模式解析器,支持对象、数组和标量类型的递归验证
- 添加 YAML 解析和注释提取功能,支持嵌套对象和数组结构
- 实现配置验证诊断,提供详细的错误和警告信息
- 添加表单更新应用功能,支持标量值和数组的批量编辑
- 实现配置示例生成功能,包含描述信息作为 YAML 注释
- 添加数值约束验证,包括最小值、最大值、倍数和长度限制
- 实现枚举值和模式匹配验证,确保数据符合预定义规则
- 添加常量值比较功能,支持对象和数组类型的深度比较
This commit is contained in:
GeWuYou 2026-04-10 19:58:42 +08:00
parent 039ef9817a
commit 925af56b1c
7 changed files with 301 additions and 18 deletions

View File

@ -1375,6 +1375,78 @@ public class YamlConfigLoaderTests
});
}
/// <summary>
/// 验证对象数组的 <c>contains</c> 试匹配会按声明属性子集工作,而不会因额外字段误判为不匹配。
/// </summary>
[Test]
public async Task LoadAsync_Should_Accept_Object_Array_When_Contains_Matches_Declared_Subset_Properties()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
entries:
-
id: 1
weight: 2
-
id: 2
weight: 3
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name", "entries"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"entries": {
"type": "array",
"minContains": 1,
"contains": {
"type": "object",
"required": ["id"],
"properties": {
"id": {
"type": "integer",
"const": 1
}
}
},
"items": {
"type": "object",
"required": ["id", "weight"],
"properties": {
"id": { "type": "integer" },
"weight": { "type": "integer" }
}
}
}
}
}
""");
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable<int, MonsterWeightedEntryArrayConfigStub>("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
await loader.LoadAsync(registry);
var table = registry.GetTable<int, MonsterWeightedEntryArrayConfigStub>("monster");
Assert.Multiple(() =>
{
Assert.That(table.Count, Is.EqualTo(1));
Assert.That(table.Get(1).Entries.Count, Is.EqualTo(2));
Assert.That(table.Get(1).Entries[0].Id, Is.EqualTo(1));
Assert.That(table.Get(1).Entries[0].Weight, Is.EqualTo(2));
});
}
/// <summary>
/// 验证数组在未声明 <c>contains</c> 时不能单独使用 <c>minContains</c>。
/// </summary>
@ -3204,6 +3276,43 @@ public class YamlConfigLoaderTests
public List<ComparableEntryConfigStub> Entries { get; set; } = new();
}
/// <summary>
/// 用于对象数组 <c>contains</c> 子集匹配回归测试的最小配置类型。
/// </summary>
private sealed class MonsterWeightedEntryArrayConfigStub
{
/// <summary>
/// 获取或设置主键。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 获取或设置名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 获取或设置对象数组条目。
/// </summary>
public List<WeightedEntryConfigStub> Entries { get; set; } = new();
}
/// <summary>
/// 表示对象数组 <c>contains</c> 子集匹配回归测试中的条目元素。
/// </summary>
private sealed class WeightedEntryConfigStub
{
/// <summary>
/// 获取或设置条目标识。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 获取或设置权重。
/// </summary>
public int Weight { get; set; }
}
/// <summary>
/// 表示对象数组中的阶段元素。
/// </summary>

View File

@ -436,22 +436,42 @@ internal static class YamlConfigSchemaValidator
/// <param name="node">实际 YAML 节点。</param>
/// <param name="schemaNode">对应的 schema 节点。</param>
/// <param name="references">已收集的跨表引用。</param>
/// <param name="allowUnknownObjectProperties">
/// 是否允许对象节点出现当前 schema 子树未声明的额外字段。
/// 该开关仅用于 <c>contains</c> 试匹配,让对象子 schema 可以按“声明属性子集匹配”工作;
/// 正常加载主链路仍保持未知字段即失败的严格语义。
/// </param>
private static void ValidateNode(
string tableName,
string yamlPath,
string displayPath,
YamlNode node,
YamlConfigSchemaNode schemaNode,
ICollection<YamlConfigReferenceUsage>? references)
ICollection<YamlConfigReferenceUsage>? references,
bool allowUnknownObjectProperties = false)
{
switch (schemaNode.NodeType)
{
case YamlConfigSchemaPropertyType.Object:
ValidateObjectNode(tableName, yamlPath, displayPath, node, schemaNode, references);
ValidateObjectNode(
tableName,
yamlPath,
displayPath,
node,
schemaNode,
references,
allowUnknownObjectProperties);
return;
case YamlConfigSchemaPropertyType.Array:
ValidateArrayNode(tableName, yamlPath, displayPath, node, schemaNode, references);
ValidateArrayNode(
tableName,
yamlPath,
displayPath,
node,
schemaNode,
references,
allowUnknownObjectProperties);
return;
case YamlConfigSchemaPropertyType.Integer:
@ -482,13 +502,17 @@ internal static class YamlConfigSchemaValidator
/// <param name="node">实际 YAML 节点。</param>
/// <param name="schemaNode">对象 schema 节点。</param>
/// <param name="references">已收集的跨表引用。</param>
/// <param name="allowUnknownObjectProperties">
/// 是否允许当前对象包含 schema 子树未声明的额外字段。
/// </param>
private static void ValidateObjectNode(
string tableName,
string yamlPath,
string displayPath,
YamlNode node,
YamlConfigSchemaNode schemaNode,
ICollection<YamlConfigReferenceUsage>? references)
ICollection<YamlConfigReferenceUsage>? references,
bool allowUnknownObjectProperties)
{
if (node is not YamlMappingNode mappingNode)
{
@ -534,6 +558,11 @@ internal static class YamlConfigSchemaValidator
if (schemaNode.Properties is null ||
!schemaNode.Properties.TryGetValue(propertyName, out var propertySchema))
{
if (allowUnknownObjectProperties)
{
continue;
}
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.UnknownProperty,
tableName,
@ -543,7 +572,14 @@ internal static class YamlConfigSchemaValidator
displayPath: propertyPath);
}
ValidateNode(tableName, yamlPath, propertyPath, entry.Value, propertySchema, references);
ValidateNode(
tableName,
yamlPath,
propertyPath,
entry.Value,
propertySchema,
references,
allowUnknownObjectProperties);
}
if (schemaNode.RequiredProperties is null)
@ -640,13 +676,17 @@ internal static class YamlConfigSchemaValidator
/// <param name="node">实际 YAML 节点。</param>
/// <param name="schemaNode">数组 schema 节点。</param>
/// <param name="references">已收集的跨表引用。</param>
/// <param name="allowUnknownObjectProperties">
/// 是否允许数组元素内的对象节点包含 schema 子树未声明的额外字段。
/// </param>
private static void ValidateArrayNode(
string tableName,
string yamlPath,
string displayPath,
YamlNode node,
YamlConfigSchemaNode schemaNode,
ICollection<YamlConfigReferenceUsage>? references)
ICollection<YamlConfigReferenceUsage>? references,
bool allowUnknownObjectProperties)
{
if (node is not YamlSequenceNode sequenceNode)
{
@ -683,7 +723,8 @@ internal static class YamlConfigSchemaValidator
$"{displayPath}[{itemIndex}]",
sequenceNode.Children[itemIndex],
schemaNode.ItemNode,
references);
references,
allowUnknownObjectProperties);
}
ValidateArrayUniqueItemsConstraint(tableName, yamlPath, displayPath, sequenceNode, schemaNode);
@ -2267,7 +2308,14 @@ internal static class YamlConfigSchemaValidator
try
{
ValidateNode(tableName, yamlPath, displayPath, itemNode, containsNode, matchedReferences);
ValidateNode(
tableName,
yamlPath,
displayPath,
itemNode,
containsNode,
matchedReferences,
allowUnknownObjectProperties: true);
if (references is not null &&
matchedReferences is not null)

View File

@ -897,6 +897,19 @@ function parseSchemaNode(rawNode, displayPath) {
const containsNode = value.contains && typeof value.contains === "object"
? parseSchemaNode(value.contains, joinArrayTemplatePath(displayPath))
: undefined;
if (containsNode && containsNode.type === "array") {
throw new Error(`Schema property '${displayPath}' uses unsupported nested array 'contains' schemas.`);
}
const effectiveMinContains = containsNode
? (typeof metadata.minContains === "number" ? metadata.minContains : 1)
: undefined;
if (containsNode &&
typeof metadata.maxContains === "number" &&
effectiveMinContains > metadata.maxContains) {
throw new Error(`Schema property '${displayPath}' declares 'minContains' greater than 'maxContains'.`);
}
return applyConstMetadata({
type: "array",
displayPath,

View File

@ -36,6 +36,32 @@ function describeContainsSchema(containsSchema, localizer) {
return parts.join(", ") || localizer.t("webview.objectArray.item");
}
/**
* Build localized contains-related hint lines for array fields.
*
* @param {{contains?: {type?: string, enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, refTable?: string}, minContains?: number}} propertySchema Array property schema metadata.
* @param {{t: (key: string, params?: Record<string, string | number>) => string}} localizer Runtime localizer.
* @returns {string[]} Localized contains hint lines.
*/
function buildContainsHintLines(propertySchema, localizer) {
if (!propertySchema.contains) {
return [];
}
const effectiveMinContains = typeof propertySchema.minContains === "number"
? propertySchema.minContains
: 1;
return [
localizer.t("webview.hint.contains", {
summary: describeContainsSchema(propertySchema.contains, localizer)
}),
localizer.t("webview.hint.minContains", {
value: effectiveMinContains
})
];
}
module.exports = {
describeContainsSchema
describeContainsSchema,
buildContainsHintLines
};

View File

@ -18,7 +18,7 @@ const {
joinArrayTemplatePath,
joinPropertyPath
} = require("./configPath");
const {describeContainsSchema} = require("./containsSummary");
const {buildContainsHintLines} = require("./containsSummary");
const {createLocalizer} = require("./localization");
const localizer = createLocalizer(vscode.env.language);
@ -1658,13 +1658,10 @@ function renderFieldHint(propertySchema, isArrayField, includeDescription = true
}
if (isArrayField && propertySchema.contains) {
hints.push(escapeHtml(localizer.t("webview.hint.contains", {
summary: describeContainsSchema(propertySchema.contains, localizer)
})));
}
if (isArrayField && typeof propertySchema.minContains === "number") {
hints.push(escapeHtml(localizer.t("webview.hint.minContains", {value: propertySchema.minContains})));
const containsHints = buildContainsHintLines(propertySchema, localizer);
for (const containsHint of containsHints) {
hints.push(escapeHtml(containsHint));
}
}
if (isArrayField && typeof propertySchema.maxContains === "number") {

View File

@ -1156,6 +1156,77 @@ test("parseSchemaContent should capture contains metadata", () => {
assert.equal(schema.properties.dropRates.contains.constDisplayValue, "5");
});
test("parseSchemaContent should reject nested-array contains schemas", () => {
assert.throws(
() => parseSchemaContent(`
{
"type": "object",
"properties": {
"dropRates": {
"type": "array",
"contains": {
"type": "array",
"items": {
"type": "integer"
}
},
"items": {
"type": "integer"
}
}
}
}
`),
/unsupported nested array 'contains' schemas/u);
});
test("parseSchemaContent should reject contains schemas where default minContains exceeds maxContains", () => {
assert.throws(
() => parseSchemaContent(`
{
"type": "object",
"properties": {
"dropRates": {
"type": "array",
"maxContains": 0,
"contains": {
"type": "integer",
"const": 5
},
"items": {
"type": "integer"
}
}
}
}
`),
/'minContains' greater than 'maxContains'/u);
});
test("parseSchemaContent should reject contains schemas where minContains is greater than maxContains", () => {
assert.throws(
() => parseSchemaContent(`
{
"type": "object",
"properties": {
"dropRates": {
"type": "array",
"minContains": 3,
"maxContains": 1,
"contains": {
"type": "integer",
"const": 5
},
"items": {
"type": "integer"
}
}
}
}
`),
/'minContains' greater than 'maxContains'/u);
});
test("parseSchemaContent should capture object property-count metadata", () => {
const schema = parseSchemaContent(`
{

View File

@ -1,6 +1,6 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {describeContainsSchema} = require("../src/containsSummary");
const {buildContainsHintLines, describeContainsSchema} = require("../src/containsSummary");
const {createLocalizer} = require("../src/localization");
test("describeContainsSchema should reuse localized Chinese hint strings", () => {
@ -25,3 +25,22 @@ test("describeContainsSchema should fall back to localized item label", () => {
assert.equal(summary, "Item");
});
test("buildContainsHintLines should include default minContains when schema omits it", () => {
const localizer = createLocalizer("en");
const lines = buildContainsHintLines(
{
contains: {
type: "integer",
constValue: "5",
constDisplayValue: "5"
}
},
localizer);
assert.deepEqual(lines, [
"Contains: integer, Const: 5",
"Min contains: 1"
]);
});