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" + ]); +});