feat(extension): 添加GFramework配置工具扩展功能

- 实现配置文件浏览器树视图,支持工作区配置目录导航
- 集成轻量级验证系统,支持YAML配置文件语法检查
- 添加模式感知表单预览功能,支持结构化配置编辑
- 实现批量编辑功能,支持跨多个配置文件统一修改字段值
- 集成国际化支持,提供中英文本地化界面
- 添加实时配置文件保存验证,在文件保存时自动校验
- 实现引用导航功能,支持跳转到关联配置表和文件
- 添加工作区变更响应,支持动态刷新配置树视图
This commit is contained in:
GeWuYou 2026-04-10 18:52:03 +08:00
parent 4ff5189da4
commit 039ef9817a
5 changed files with 454 additions and 38 deletions

View File

@ -1211,6 +1211,170 @@ public class YamlConfigLoaderTests
});
}
/// <summary>
/// 验证匹配数量刚好等于 <c>minContains</c> / <c>maxContains</c> 时会被视为合法边界。
/// </summary>
[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<int, MonsterConfigIntegerArrayStub>("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
await loader.LoadAsync(registry);
var table = registry.GetTable<int, MonsterConfigIntegerArrayStub>("monster");
Assert.Multiple(() =>
{
Assert.That(table.Count, Is.EqualTo(1));
Assert.That(table.Get(1).DropRates, Is.EqualTo(new[] { 5, 7, 5 }));
});
}
/// <summary>
/// 验证数组字段将 <c>contains</c> 声明为非对象 schema 时,会在 schema 解析阶段被拒绝。
/// </summary>
[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<int, MonsterConfigIntegerArrayStub>("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<ConfigLoadException>(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));
});
}
/// <summary>
/// 验证数组字段将 <c>contains</c> 声明为嵌套数组 schema 时,会在 schema 解析阶段被拒绝。
/// </summary>
[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<int, MonsterConfigIntegerArrayStub>("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<ConfigLoadException>(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));
});
}
/// <summary>
/// 验证数组在未声明 <c>contains</c> 时不能单独使用 <c>minContains</c>。
/// </summary>
@ -2345,6 +2509,190 @@ public class YamlConfigLoaderTests
});
}
/// <summary>
/// 验证仅声明在 <c>contains</c> 子 schema 里的跨表引用也会参与整批加载校验。
/// </summary>
[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<string, ItemConfigStub>("item", "item", "schemas/item.schema.json",
static config => config.Id)
.RegisterTable<int, MonsterDropArrayConfigStub>("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<ConfigLoadException>(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));
});
}
/// <summary>
/// 验证依赖关系仅来自 <c>contains</c> 子 schema 时,热重载仍会追踪该依赖并在目标表破坏引用后回滚。
/// </summary>
[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<string, ItemConfigStub>("item", "item", "schemas/item.schema.json",
static config => config.Id)
.RegisterTable<int, MonsterDropArrayConfigStub>("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<string, ItemConfigStub>("item").ContainsKey("potion"), Is.True);
Assert.That(registry.GetTable<int, MonsterDropArrayConfigStub>("monster").Get(1).DropItemIds,
Is.EqualTo(new[] { "potion" }));
});
}
finally
{
hotReload.UnRegister();
}
}
/// <summary>
/// 验证启用热重载后,配置文件内容变更会刷新已注册配置表。
/// </summary>
@ -2779,7 +3127,7 @@ public class YamlConfigLoaderTests
/// <summary>
/// 获取或设置掉落率列表。
/// </summary>
public IReadOnlyList<int> DropRates { get; set; } = Array.Empty<int>();
public List<int> DropRates { get; set; } = new();
}
/// <summary>

View File

@ -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
/// <param name="displayPath">字段路径。</param>
/// <param name="sequenceNode">实际数组节点。</param>
/// <param name="schemaNode">数组 schema 节点。</param>
/// <param name="references">匹配成功的 <c>contains</c> 子树所声明的跨表引用收集器。</param>
private static void ValidateArrayContainsConstraints(
string tableName,
string yamlPath,
string displayPath,
YamlSequenceNode sequenceNode,
YamlConfigSchemaNode schemaNode)
YamlConfigSchemaNode schemaNode,
ICollection<YamlConfigReferenceUsage>? 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
/// <param name="displayPath">数组字段路径。</param>
/// <param name="sequenceNode">实际数组节点。</param>
/// <param name="containsNode">contains 子 schema。</param>
/// <param name="references">匹配成功元素的可选跨表引用收集器。</param>
/// <returns>匹配 <c>contains</c> 子 schema 的元素数量。</returns>
private static int CountMatchingContainsItems(
string tableName,
string yamlPath,
string displayPath,
YamlSequenceNode sequenceNode,
YamlConfigSchemaNode containsNode)
YamlConfigSchemaNode containsNode,
ICollection<YamlConfigReferenceUsage>? 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
/// <param name="displayPath">当前数组元素路径。</param>
/// <param name="itemNode">实际 YAML 元素。</param>
/// <param name="containsNode">contains 子 schema。</param>
/// <param name="references">当前元素匹配成功后要写回的可选跨表引用收集器。</param>
/// <returns>当前元素是否匹配 contains 子 schema。</returns>
private static bool IsArrayItemMatchingContains(
string tableName,
string yamlPath,
string displayPath,
YamlNode itemNode,
YamlConfigSchemaNode containsNode)
YamlConfigSchemaNode containsNode,
ICollection<YamlConfigReferenceUsage>? references)
{
// contains 的“试匹配”不能把失败元素的引用泄漏给外层,但匹配成功的元素仍需要参与
// 跨表引用收集,否则仅声明在 contains 子 schema 里的 ref-table 会被运行时遗漏。
List<YamlConfigReferenceUsage>? 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);
}
}
/// <summary>

View File

@ -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, string | number>) => 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
};

View File

@ -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 `<span class="hint">${hints.join(" · ")}</span>`;
}
/**
* 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.
*

View File

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