From 925af56b1cff9fa0ce2e49c9a709252513bcff42 Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Fri, 10 Apr 2026 19:58:42 +0800
Subject: [PATCH] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0=E9=85=8D?=
=?UTF-8?q?=E7=BD=AE=E9=AA=8C=E8=AF=81=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 实现配置模式解析器,支持对象、数组和标量类型的递归验证
- 添加 YAML 解析和注释提取功能,支持嵌套对象和数组结构
- 实现配置验证诊断,提供详细的错误和警告信息
- 添加表单更新应用功能,支持标量值和数组的批量编辑
- 实现配置示例生成功能,包含描述信息作为 YAML 注释
- 添加数值约束验证,包括最小值、最大值、倍数和长度限制
- 实现枚举值和模式匹配验证,确保数据符合预定义规则
- 添加常量值比较功能,支持对象和数组类型的深度比较
---
.../Config/YamlConfigLoaderTests.cs | 109 ++++++++++++++++++
.../Config/YamlConfigSchemaValidator.cs | 64 ++++++++--
.../src/configValidation.js | 13 +++
.../src/containsSummary.js | 28 ++++-
tools/gframework-config-tool/src/extension.js | 13 +--
.../test/configValidation.test.js | 71 ++++++++++++
.../test/containsSummary.test.js | 21 +++-
7 files changed, 301 insertions(+), 18 deletions(-)
diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
index af964a0b..1d0c4a10 100644
--- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
+++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
@@ -1375,6 +1375,78 @@ public class YamlConfigLoaderTests
});
}
+ ///
+ /// 验证对象数组的 contains 试匹配会按声明属性子集工作,而不会因额外字段误判为不匹配。
+ ///
+ [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("monster", "monster", "schemas/monster.schema.json",
+ static config => config.Id);
+ var registry = new ConfigRegistry();
+
+ await loader.LoadAsync(registry);
+
+ var table = registry.GetTable("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));
+ });
+ }
+
///
/// 验证数组在未声明 contains 时不能单独使用 minContains。
///
@@ -3204,6 +3276,43 @@ public class YamlConfigLoaderTests
public List Entries { get; set; } = new();
}
+ ///
+ /// 用于对象数组 contains 子集匹配回归测试的最小配置类型。
+ ///
+ private sealed class MonsterWeightedEntryArrayConfigStub
+ {
+ ///
+ /// 获取或设置主键。
+ ///
+ public int Id { get; set; }
+
+ ///
+ /// 获取或设置名称。
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// 获取或设置对象数组条目。
+ ///
+ public List Entries { get; set; } = new();
+ }
+
+ ///
+ /// 表示对象数组 contains 子集匹配回归测试中的条目元素。
+ ///
+ private sealed class WeightedEntryConfigStub
+ {
+ ///
+ /// 获取或设置条目标识。
+ ///
+ public int Id { get; set; }
+
+ ///
+ /// 获取或设置权重。
+ ///
+ public int Weight { get; set; }
+ }
+
///
/// 表示对象数组中的阶段元素。
///
diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs
index a2b76c6d..8c588e56 100644
--- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs
+++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs
@@ -436,22 +436,42 @@ internal static class YamlConfigSchemaValidator
/// 实际 YAML 节点。
/// 对应的 schema 节点。
/// 已收集的跨表引用。
+ ///
+ /// 是否允许对象节点出现当前 schema 子树未声明的额外字段。
+ /// 该开关仅用于 contains 试匹配,让对象子 schema 可以按“声明属性子集匹配”工作;
+ /// 正常加载主链路仍保持未知字段即失败的严格语义。
+ ///
private static void ValidateNode(
string tableName,
string yamlPath,
string displayPath,
YamlNode node,
YamlConfigSchemaNode schemaNode,
- ICollection? references)
+ ICollection? 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
/// 实际 YAML 节点。
/// 对象 schema 节点。
/// 已收集的跨表引用。
+ ///
+ /// 是否允许当前对象包含 schema 子树未声明的额外字段。
+ ///
private static void ValidateObjectNode(
string tableName,
string yamlPath,
string displayPath,
YamlNode node,
YamlConfigSchemaNode schemaNode,
- ICollection? references)
+ ICollection? 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
/// 实际 YAML 节点。
/// 数组 schema 节点。
/// 已收集的跨表引用。
+ ///
+ /// 是否允许数组元素内的对象节点包含 schema 子树未声明的额外字段。
+ ///
private static void ValidateArrayNode(
string tableName,
string yamlPath,
string displayPath,
YamlNode node,
YamlConfigSchemaNode schemaNode,
- ICollection? references)
+ ICollection? 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)
diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js
index e034ddb8..8dcb5de6 100644
--- a/tools/gframework-config-tool/src/configValidation.js
+++ b/tools/gframework-config-tool/src/configValidation.js
@@ -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,
diff --git a/tools/gframework-config-tool/src/containsSummary.js b/tools/gframework-config-tool/src/containsSummary.js
index 7b28c193..a6fdbbe4 100644
--- a/tools/gframework-config-tool/src/containsSummary.js
+++ b/tools/gframework-config-tool/src/containsSummary.js
@@ -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}} 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
};
diff --git a/tools/gframework-config-tool/src/extension.js b/tools/gframework-config-tool/src/extension.js
index f07b6c3f..e263612d 100644
--- a/tools/gframework-config-tool/src/extension.js
+++ b/tools/gframework-config-tool/src/extension.js
@@ -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") {
diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js
index 812b45f4..3d3ba6e4 100644
--- a/tools/gframework-config-tool/test/configValidation.test.js
+++ b/tools/gframework-config-tool/test/configValidation.test.js
@@ -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(`
{
diff --git a/tools/gframework-config-tool/test/containsSummary.test.js b/tools/gframework-config-tool/test/containsSummary.test.js
index 4ad19c24..5d10e12e 100644
--- a/tools/gframework-config-tool/test/containsSummary.test.js
+++ b/tools/gframework-config-tool/test/containsSummary.test.js
@@ -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"
+ ]);
+});