From 039ef9817ac85fab94135031f36c12182908be3a Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:52:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(extension):=20=E6=B7=BB=E5=8A=A0GFramework?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=B7=A5=E5=85=B7=E6=89=A9=E5=B1=95=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现配置文件浏览器树视图,支持工作区配置目录导航 - 集成轻量级验证系统,支持YAML配置文件语法检查 - 添加模式感知表单预览功能,支持结构化配置编辑 - 实现批量编辑功能,支持跨多个配置文件统一修改字段值 - 集成国际化支持,提供中英文本地化界面 - 添加实时配置文件保存验证,在文件保存时自动校验 - 实现引用导航功能,支持跳转到关联配置表和文件 - 添加工作区变更响应,支持动态刷新配置树视图 --- .../Config/YamlConfigLoaderTests.cs | 350 +++++++++++++++++- .../Config/YamlConfigSchemaValidator.cs | 42 ++- .../src/containsSummary.js | 41 ++ tools/gframework-config-tool/src/extension.js | 32 +- .../test/containsSummary.test.js | 27 ++ 5 files changed, 454 insertions(+), 38 deletions(-) create mode 100644 tools/gframework-config-tool/src/containsSummary.js create mode 100644 tools/gframework-config-tool/test/containsSummary.test.js diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs index e4a8a1df..af964a0b 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -1211,6 +1211,170 @@ public class YamlConfigLoaderTests }); } + /// + /// 验证匹配数量刚好等于 minContains / maxContains 时会被视为合法边界。 + /// + [Test] + public async Task LoadAsync_Should_Accept_Array_When_Contains_Match_Count_Equals_Min_And_Max_Bounds() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + dropRates: + - 5 + - 7 + - 5 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "dropRates"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "dropRates": { + "type": "array", + "minContains": 2, + "maxContains": 2, + "contains": { + "type": "integer", + "const": 5 + }, + "items": { + "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).DropRates, Is.EqualTo(new[] { 5, 7, 5 })); + }); + } + + /// + /// 验证数组字段将 contains 声明为非对象 schema 时,会在 schema 解析阶段被拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Contains_Is_Not_Object_Schema() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + dropRates: + - 5 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "dropRates"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "dropRates": { + "type": "array", + "contains": 5, + "items": { + "type": "integer" + } + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.SchemaUnsupported)); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("dropRates")); + Assert.That(exception.Message, Does.Contain("'contains' as an object-valued schema")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证数组字段将 contains 声明为嵌套数组 schema 时,会在 schema 解析阶段被拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Contains_Uses_Nested_Array_Schema() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + dropRates: + - 5 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "dropRates"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "dropRates": { + "type": "array", + "contains": { + "type": "array", + "items": { + "type": "integer" + } + }, + "items": { + "type": "integer" + } + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.SchemaUnsupported)); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("dropRates")); + Assert.That(exception.Message, Does.Contain("unsupported nested array 'contains' schemas")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + /// /// 验证数组在未声明 contains 时不能单独使用 minContains。 /// @@ -2345,6 +2509,190 @@ public class YamlConfigLoaderTests }); } + /// + /// 验证仅声明在 contains 子 schema 里的跨表引用也会参与整批加载校验。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Contains_Matched_Reference_Target_Is_Missing() + { + CreateConfigFile( + "item/potion.yaml", + """ + id: potion + name: Potion + """); + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + dropItemIds: + - potion + - missing_item + """); + CreateSchemaFile( + "schemas/item.schema.json", + """ + { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" } + } + } + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "dropItemIds"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "dropItemIds": { + "type": "array", + "minContains": 1, + "contains": { + "type": "string", + "x-gframework-ref-table": "item" + }, + "items": { + "type": "string" + } + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("item", "item", "schemas/item.schema.json", + static config => config.Id) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.ReferencedKeyNotFound)); + Assert.That(exception.Diagnostic.TableName, Is.EqualTo("monster")); + Assert.That(exception.Diagnostic.ReferencedTableName, Is.EqualTo("item")); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("dropItemIds[1]")); + Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("missing_item")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证依赖关系仅来自 contains 子 schema 时,热重载仍会追踪该依赖并在目标表破坏引用后回滚。 + /// + [Test] + public async Task EnableHotReload_Should_Keep_Previous_State_When_Contains_Reference_Dependency_Breaks() + { + CreateConfigFile( + "item/potion.yaml", + """ + id: potion + name: Potion + """); + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + dropItemIds: + - potion + """); + CreateSchemaFile( + "schemas/item.schema.json", + """ + { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" } + } + } + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "dropItemIds"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "dropItemIds": { + "type": "array", + "minContains": 1, + "contains": { + "type": "string", + "x-gframework-ref-table": "item" + }, + "items": { + "type": "string" + } + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("item", "item", "schemas/item.schema.json", + static config => config.Id) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + await loader.LoadAsync(registry); + + var reloadFailureTaskSource = + new TaskCompletionSource<(string TableName, Exception Exception)>(TaskCreationOptions + .RunContinuationsAsynchronously); + var hotReload = loader.EnableHotReload( + registry, + onTableReloadFailed: (tableName, exception) => + reloadFailureTaskSource.TrySetResult((tableName, exception)), + debounceDelay: TimeSpan.FromMilliseconds(150)); + + try + { + CreateConfigFile( + "item/potion.yaml", + """ + id: elixir + name: Elixir + """); + + var failure = await WaitForTaskWithinAsync(reloadFailureTaskSource.Task, TimeSpan.FromSeconds(5)); + var diagnosticException = failure.Exception as ConfigLoadException; + + Assert.Multiple(() => + { + Assert.That(failure.TableName, Is.EqualTo("item")); + Assert.That(diagnosticException, Is.Not.Null); + Assert.That(diagnosticException!.Diagnostic.FailureKind, + Is.EqualTo(ConfigLoadFailureKind.ReferencedKeyNotFound)); + Assert.That(diagnosticException.Diagnostic.TableName, Is.EqualTo("monster")); + Assert.That(diagnosticException.Diagnostic.ReferencedTableName, Is.EqualTo("item")); + Assert.That(diagnosticException.Diagnostic.DisplayPath, Is.EqualTo("dropItemIds[0]")); + Assert.That(diagnosticException.Diagnostic.RawValue, Is.EqualTo("potion")); + Assert.That(registry.GetTable("item").ContainsKey("potion"), Is.True); + Assert.That(registry.GetTable("monster").Get(1).DropItemIds, + Is.EqualTo(new[] { "potion" })); + }); + } + finally + { + hotReload.UnRegister(); + } + } + /// /// 验证启用热重载后,配置文件内容变更会刷新已注册配置表。 /// @@ -2779,7 +3127,7 @@ public class YamlConfigLoaderTests /// /// 获取或设置掉落率列表。 /// - public IReadOnlyList DropRates { get; set; } = Array.Empty(); + public List DropRates { get; set; } = new(); } /// diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index 8b60101f..a2b76c6d 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -687,7 +687,7 @@ internal static class YamlConfigSchemaValidator } ValidateArrayUniqueItemsConstraint(tableName, yamlPath, displayPath, sequenceNode, schemaNode); - ValidateArrayContainsConstraints(tableName, yamlPath, displayPath, sequenceNode, schemaNode); + ValidateArrayContainsConstraints(tableName, yamlPath, displayPath, sequenceNode, schemaNode, references); ValidateConstantValue(tableName, yamlPath, displayPath, sequenceNode, schemaNode); } @@ -2153,12 +2153,14 @@ internal static class YamlConfigSchemaValidator /// 字段路径。 /// 实际数组节点。 /// 数组 schema 节点。 + /// 匹配成功的 contains 子树所声明的跨表引用收集器。 private static void ValidateArrayContainsConstraints( string tableName, string yamlPath, string displayPath, YamlSequenceNode sequenceNode, - YamlConfigSchemaNode schemaNode) + YamlConfigSchemaNode schemaNode, + ICollection? references) { var containsConstraints = schemaNode.ArrayConstraints?.ContainsConstraints; if (containsConstraints is null) @@ -2171,7 +2173,8 @@ internal static class YamlConfigSchemaValidator yamlPath, displayPath, sequenceNode, - containsConstraints.ContainsNode); + containsConstraints.ContainsNode, + references); var rawValue = matchingCount.ToString(CultureInfo.InvariantCulture); var requiredMinContains = containsConstraints.MinContains ?? 1; if (matchingCount < requiredMinContains) @@ -2211,13 +2214,15 @@ internal static class YamlConfigSchemaValidator /// 数组字段路径。 /// 实际数组节点。 /// contains 子 schema。 + /// 匹配成功元素的可选跨表引用收集器。 /// 匹配 contains 子 schema 的元素数量。 private static int CountMatchingContainsItems( string tableName, string yamlPath, string displayPath, YamlSequenceNode sequenceNode, - YamlConfigSchemaNode containsNode) + YamlConfigSchemaNode containsNode, + ICollection? references) { var matchingCount = 0; for (var itemIndex = 0; itemIndex < sequenceNode.Children.Count; itemIndex++) @@ -2227,7 +2232,8 @@ internal static class YamlConfigSchemaValidator yamlPath, $"{displayPath}[{itemIndex}]", sequenceNode.Children[itemIndex], - containsNode)) + containsNode, + references)) { matchingCount++; } @@ -2245,17 +2251,33 @@ internal static class YamlConfigSchemaValidator /// 当前数组元素路径。 /// 实际 YAML 元素。 /// contains 子 schema。 + /// 当前元素匹配成功后要写回的可选跨表引用收集器。 /// 当前元素是否匹配 contains 子 schema。 private static bool IsArrayItemMatchingContains( string tableName, string yamlPath, string displayPath, YamlNode itemNode, - YamlConfigSchemaNode containsNode) + YamlConfigSchemaNode containsNode, + ICollection? references) { + // contains 的“试匹配”不能把失败元素的引用泄漏给外层,但匹配成功的元素仍需要参与 + // 跨表引用收集,否则仅声明在 contains 子 schema 里的 ref-table 会被运行时遗漏。 + List? matchedReferences = references is null ? null : new(); + try { - ValidateNode(tableName, yamlPath, displayPath, itemNode, containsNode, references: null); + ValidateNode(tableName, yamlPath, displayPath, itemNode, containsNode, matchedReferences); + + if (references is not null && + matchedReferences is not null) + { + foreach (var referenceUsage in matchedReferences) + { + references.Add(referenceUsage); + } + } + return true; } catch (ConfigLoadException exception) when (exception.Diagnostic.FailureKind != ConfigLoadFailureKind.UnexpectedFailure) @@ -2627,6 +2649,12 @@ internal static class YamlConfigSchemaValidator { CollectReferencedTableNames(node.ItemNode, referencedTableNames); } + + var containsNode = node.ArrayConstraints?.ContainsConstraints?.ContainsNode; + if (containsNode is not null) + { + CollectReferencedTableNames(containsNode, referencedTableNames); + } } /// diff --git a/tools/gframework-config-tool/src/containsSummary.js b/tools/gframework-config-tool/src/containsSummary.js new file mode 100644 index 00000000..7b28c193 --- /dev/null +++ b/tools/gframework-config-tool/src/containsSummary.js @@ -0,0 +1,41 @@ +/** + * Build a compact contains-schema summary for array field hints. + * The summary reuses existing localized hint strings so Chinese UI surfaces + * do not fall back to mixed English tokens such as const/enum/pattern/ref. + * + * @param {{type?: string, enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, refTable?: string}} containsSchema Parsed contains schema metadata. + * @param {{t: (key: string, params?: Record) => string}} localizer Runtime localizer. + * @returns {string} Human-facing summary. + */ +function describeContainsSchema(containsSchema, localizer) { + const parts = []; + if (containsSchema.type) { + parts.push(containsSchema.type); + } + + if (containsSchema.constValue !== undefined) { + parts.push(localizer.t("webview.hint.const", { + value: containsSchema.constDisplayValue ?? containsSchema.constValue + })); + } else if (Array.isArray(containsSchema.enumValues) && containsSchema.enumValues.length > 0) { + parts.push(localizer.t("webview.hint.allowed", { + values: containsSchema.enumValues.join(", ") + })); + } else if (containsSchema.pattern) { + parts.push(localizer.t("webview.hint.pattern", { + value: containsSchema.pattern + })); + } + + if (containsSchema.refTable) { + parts.push(localizer.t("webview.hint.refTable", { + refTable: containsSchema.refTable + })); + } + + return parts.join(", ") || localizer.t("webview.objectArray.item"); +} + +module.exports = { + describeContainsSchema +}; diff --git a/tools/gframework-config-tool/src/extension.js b/tools/gframework-config-tool/src/extension.js index 75c758ef..f07b6c3f 100644 --- a/tools/gframework-config-tool/src/extension.js +++ b/tools/gframework-config-tool/src/extension.js @@ -18,6 +18,7 @@ const { joinArrayTemplatePath, joinPropertyPath } = require("./configPath"); +const {describeContainsSchema} = require("./containsSummary"); const {createLocalizer} = require("./localization"); const localizer = createLocalizer(vscode.env.language); @@ -1658,7 +1659,7 @@ function renderFieldHint(propertySchema, isArrayField, includeDescription = true if (isArrayField && propertySchema.contains) { hints.push(escapeHtml(localizer.t("webview.hint.contains", { - summary: describeContainsSchema(propertySchema.contains) + summary: describeContainsSchema(propertySchema.contains, localizer) }))); } @@ -1723,35 +1724,6 @@ function renderFieldHint(propertySchema, isArrayField, includeDescription = true return `${hints.join(" · ")}`; } -/** - * Build a compact contains-schema summary for array field hints. - * The hint intentionally stays short so the form preview can expose the rule - * without inlining a second full schema tree beside the field controls. - * - * @param {{type?: string, enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, refTable?: string}} containsSchema Parsed contains schema metadata. - * @returns {string} Human-facing summary. - */ -function describeContainsSchema(containsSchema) { - const parts = []; - if (containsSchema.type) { - parts.push(containsSchema.type); - } - - if (containsSchema.constValue !== undefined) { - parts.push(`const = ${containsSchema.constDisplayValue ?? containsSchema.constValue}`); - } else if (Array.isArray(containsSchema.enumValues) && containsSchema.enumValues.length > 0) { - parts.push(`enum = ${containsSchema.enumValues.join(", ")}`); - } else if (containsSchema.pattern) { - parts.push(`pattern = ${containsSchema.pattern}`); - } - - if (containsSchema.refTable) { - parts.push(`ref = ${containsSchema.refTable}`); - } - - return parts.join(", ") || "item"; -} - /** * Prompt for one batch-edit field value. * diff --git a/tools/gframework-config-tool/test/containsSummary.test.js b/tools/gframework-config-tool/test/containsSummary.test.js new file mode 100644 index 00000000..4ad19c24 --- /dev/null +++ b/tools/gframework-config-tool/test/containsSummary.test.js @@ -0,0 +1,27 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const {describeContainsSchema} = require("../src/containsSummary"); +const {createLocalizer} = require("../src/localization"); + +test("describeContainsSchema should reuse localized Chinese hint strings", () => { + const localizer = createLocalizer("zh-cn"); + + const summary = describeContainsSchema( + { + type: "string", + constValue: "\"potion\"", + constDisplayValue: "\"potion\"", + refTable: "item" + }, + localizer); + + assert.equal(summary, "string, 固定值:\"potion\", 引用表:item"); +}); + +test("describeContainsSchema should fall back to localized item label", () => { + const localizer = createLocalizer("en"); + + const summary = describeContainsSchema({}, localizer); + + assert.equal(summary, "Item"); +});