mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-06 16:16:44 +08:00
feat(config): 添加配置验证功能
- 实现配置模式解析器,支持对象、数组和标量类型的递归验证 - 添加 YAML 解析和注释提取功能,支持嵌套对象和数组结构 - 实现配置验证诊断,提供详细的错误和警告信息 - 添加表单更新应用功能,支持标量值和数组的批量编辑 - 实现配置示例生成功能,包含描述信息作为 YAML 注释 - 添加数值约束验证,包括最小值、最大值、倍数和长度限制 - 实现枚举值和模式匹配验证,确保数据符合预定义规则 - 添加常量值比较功能,支持对象和数组类型的深度比较
This commit is contained in:
parent
039ef9817a
commit
925af56b1c
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -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(`
|
||||
{
|
||||
|
||||
@ -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"
|
||||
]);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user