fix(ai-first-config): 收口 PR 306 审查遗留项

- 新增 Generator 与 Tooling 的 anyOf 和坏形状回归覆盖,补齐组合关键字与未知 type 拒绝

- 修复 VS Code 配置工具的 object-array 直属项收集与 contains 文案一致性问题

- 更新 README、Game 文档与工具说明,明确 additionalProperties 显式 false 边界与最小接入路径

- 补充 ai-plan 跟踪与 trace,记录 PR 306 open threads 收口结果和验证摘要
This commit is contained in:
gewuyou 2026-04-30 15:22:04 +08:00
parent 040bcb99e4
commit e671646a74
19 changed files with 288 additions and 34 deletions

View File

@ -1843,6 +1843,54 @@ public class SchemaConfigGeneratorTests
});
}
/// <summary>
/// 验证生成器会显式拒绝当前共享子集尚未支持的 <c>anyOf</c>。
/// </summary>
[Test]
public void Run_Should_Report_Diagnostic_When_Object_Schema_Declares_Unsupported_AnyOf()
{
const string source = DummySource;
const string schema = """
{
"type": "object",
"required": ["id", "reward"],
"properties": {
"id": { "type": "integer" },
"reward": {
"type": "object",
"properties": {
"itemCount": { "type": "integer" }
},
"anyOf": [
{
"type": "object",
"required": ["itemCount"],
"properties": {
"itemCount": { "type": "integer" }
}
}
]
}
}
}
""";
var result = SchemaGeneratorTestDriver.Run(
source,
("monster.schema.json", schema));
var diagnostic = result.Results.Single().Diagnostics.Single();
Assert.Multiple(() =>
{
Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_015"));
Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error));
Assert.That(diagnostic.GetMessage(), Does.Contain("reward"));
Assert.That(diagnostic.GetMessage(), Does.Contain("anyOf"));
Assert.That(diagnostic.GetMessage(), Does.Contain("does not support combinators that can change generated type shape"));
});
}
/// <summary>
/// 验证生成器接受显式声明的 <c>additionalProperties: false</c>。
/// </summary>

View File

@ -77,7 +77,7 @@
如果你采用 AI-First 配置工作流,建议在进入实现前先确认两条边界:
- 当前共享子集接受闭合对象边界 `additionalProperties: false`
- 当前共享子集接受闭合对象边界 `additionalProperties: false`(需显式设置为 `false`;省略或 `true` 视为不支持)
- `oneOf` / `anyOf` 这类会改变生成类型形状的组合关键字当前会被 Runtime / Generator / Tooling 直接拒绝
更复杂的 schema shape 需要先回到 schema 设计与 raw YAML 维护路径,而不是假定编辑器工具存在隐藏支持。

View File

@ -86,6 +86,7 @@
- `2026-04-17` 之前的详细实现记录与定向验证命令已归档到历史 tracking / trace
- active 跟踪文件只保留当前恢复点、当前状态和下一步,不再重复堆积已完成阶段的完整历史
- 最近验证摘要:`2026-04-30` 已完成 Tooling / Docs reader-facing 收口与工具 parser 边界收紧,详细命令、批次背景与验证结果保留在 trace 的 `2026-04-30` 分阶段记录中
- PR `#306` follow-up 摘要:已按 latest open review threads 补齐 Generator `anyOf` 对称回归、Tooling schema type 白名单、object-array 直系收集边界,以及 reader-facing docs 的显式 `additionalProperties: false` / adoption guidance 说明;细节和验证命令保留在 trace 的 `2026-04-30` 新增阶段记录中
- PR review 跟进指针:当前分支的 latest review follow-up 与后续本地核验结论以 `ai-first-config-system-trace.md` 为准active tracking 不再重复展开逐条命令历史
## 下一步

View File

@ -205,3 +205,29 @@
1. 继续盘点 Runtime / Generator / Tooling 三端是否还有类似“工具宽松吞掉、主线不支持”的 schema 形状
2. 若继续做 Tooling lane优先补 reader-facing 示例或采用路径,而不是继续堆积边界清单
### 阶段PR #306 open threads 收口AI-FIRST-CONFIG-RP-003
- 已重新抓取 PR `#306` 的 latest open review threads并按“本地仍成立 / 已被当前分支吸收”重新核验
- 本轮收口重点不是继续扩能力,而是把 open threads 中仍成立的三类问题一次性补齐:
- Generator补齐 `GF_ConfigSchema_015``anyOf` 对称负例,避免组合关键字只覆盖 `oneOf`
- Tooling拒绝未知显式 `type`、收窄 object-array 只遍历当前 editor 直属 items、统一 `contains` hint 文案
- Docs`additionalProperties: false` 的“必须显式设置为 false”写清并为工具补最小接入示例、迁移提示与更准确的 raw YAML 回退条件
- 本轮同时更新了 JS / .NET 回归测试与 active tracking避免只修 review comment 不保留恢复点
### 验证
- 2026-04-30`bun run test``tools/gframework-config-tool`
- 结果通过132 tests
- 备注:新增未知 schema `type` 拒绝、嵌套 object-array 不串层,以及 `contains` hint 文案回归
- 2026-04-30`dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --filter "FullyQualifiedName~SchemaConfigGeneratorTests"`
- 结果通过54 tests
- 备注:补齐 `Run_Should_Report_Diagnostic_When_Object_Schema_Declares_Unsupported_AnyOf`
- 2026-04-30`git diff --check`
- 结果:通过
- 备注:本轮代码与文档改动未引入空白或冲突标记问题
### 下一步
1. 推送本轮修复后,重新抓取 PR `#306` review 状态,确认哪些 open threads 会被 GitHub 自动折叠或仍需人工回复
2. 若还有残留 open threads优先区分“远端未刷新 / 已过时评论 / 仍成立问题”,不要再把 review body 摘要和 latest open threads 混在一起处理

View File

@ -13,7 +13,7 @@ description: GFramework 的 API 阅读入口,按模块映射 README、专题
2. 再进专题页确认安装、生命周期和推荐接线方式
3. 最后回到源码中的 XML 文档核对具体契约
如果你在阅读 AI-First 配置工作流相关 API先把 `GFramework.Game` Runtime 与 `GFramework.Game.SourceGenerators` 视为正式契约入口,再把 `VS Code` 配置工具视为辅助层。当前默认采用路径围绕共享 schema 子集展开,其中 `additionalProperties: false` 表示闭合对象边界`oneOf` / `anyOf` 不在默认入口范围内;更复杂的 shape 应回到 raw YAML 与 schema 设计本体处理。
如果你在阅读 AI-First 配置工作流相关 API先把 `GFramework.Game` Runtime 与 `GFramework.Game.SourceGenerators` 视为正式契约入口,再把 `VS Code` 配置工具视为辅助层。当前默认采用路径围绕共享 schema 子集展开,其中 `additionalProperties: false` 表示闭合对象边界(需显式设置为 `false``oneOf` / `anyOf` 在 Runtime / Generator / Tooling 层面会被直接拒绝。更复杂的 shape 应回到 raw YAML 与 schema 设计本体处理。
## 阅读顺序

View File

@ -109,6 +109,97 @@ Explorer + 表单预览。
日常采用时,建议把它理解为“优先用工具加速已支持子集的维护;遇到边界时立刻回到 schema + raw YAML 本体确认”。
### 最小接入示例与兼容 / 迁移说明
项目里至少需要准备三类内容:
- `config/<domain>/*.yaml`:实际配置文件
- `schemas/<domain>.schema.json`:与该配置域对应的 schema
- VS Code 工作区里的 `GFramework Config Tool` 扩展,以及与 schema 保持一致的 `x-gframework-ref-table` 引用约定
最小目录可以从下面这个形态起步:
```text
GameProject/
├─ config/
│ └─ monster/
│ └─ slime.yaml
└─ schemas/
└─ monster.schema.json
```
最小 schema 示例:
```json
{
"type": "object",
"additionalProperties": false,
"required": ["id", "name", "rarity", "dropItems"],
"properties": {
"id": {
"type": "integer",
"title": "Monster Id",
"description": "Primary monster key.",
"default": 1
},
"name": {
"type": "string",
"title": "Display Name",
"minLength": 1
},
"rarity": {
"type": "string",
"enum": ["common", "elite", "boss"],
"default": "common"
},
"spawnTime": {
"type": "string",
"format": "time"
},
"dropItems": {
"type": "array",
"uniqueItems": true,
"items": {
"type": "string"
}
},
"rewardTableId": {
"type": "string",
"x-gframework-ref-table": "reward-table"
}
}
}
```
对应的 YAML 初始文件可以保持很小:
```yaml
id: 1
name: Slime
rarity: common
spawnTime: 08:30:00Z
dropItems:
- potion
rewardTableId: starter-reward
```
推荐接入顺序:
1. 在 VS Code 中打开包含 `config/``schemas/` 的工作区
2. 如果目录不是默认值,先设置 `gframeworkConfig.configPath``gframeworkConfig.schemasPath`
3. 通过 Explorer 打开目标 YAML 或 schema先跑一次全量校验
4. 对空 YAML 使用“基于 schema 的示例 YAML 初始化”,或直接从 raw YAML 开始录入
5. 需要统一改同域顶层标量字段时,再进入批量编辑
迁移自纯 raw YAML 工作流时,至少先检查下面几件事:
- `additionalProperties` 是否显式设置为 `false`;省略或 `true` 不属于当前共享支持子集
- schema 是否依赖 `oneOf` / `anyOf`;这些组合关键字会被 Runtime / Generator / Tooling 直接拒绝
- 对象数组里是否混入标量项,或是否存在更深、更异构的数组结构
- Runtime / Source Generator 是否已经接受这份 schema而不是只有编辑器里“暂时看起来能写”
当 schema 仍在共享支持子集内,但某段编辑路径已经超出轻量表单可视化边界时,优先回到 raw YAML不要把“工具暂时没有表单入口”误判成“运行时契约已放宽”。
## 推荐工作流
### 1. 浏览配置与 schema

View File

@ -24,7 +24,7 @@ description: 以当前 GFramework.Game 源码与 PersistenceTests 为准,说
- 配置系统的 schema 支持边界,仍以 `GFramework.Game` Runtime 和 `GFramework.Game.SourceGenerators` 当前共享的 schema 子集为准
- `DataRepository``UnifiedSettingsDataRepository``SaveRepository<TSaveData>` 负责的是数据怎么落盘、怎么回读、怎么组织槽位,而不是放宽配置契约
- 如果配置设计依赖 `oneOf``anyOf`、非 `false``additionalProperties`,或其他更复杂的 schema shape应直接回到 [配置系统](./config-system.md) 与 raw YAML / schema 本体继续处理,而不是期待 repository 层自动接管这些边界
- 如果配置设计依赖 `oneOf``anyOf`、非 `false``additionalProperties`(例如省略或 `true`,或其他更复杂的 schema shape应直接回到 [配置系统](./config-system.md) 与 raw YAML / schema 本体继续处理,而不是期待 repository 层自动接管这些边界
## 什么时候用哪个仓库

View File

@ -86,7 +86,7 @@ IStorage storage = new FileStorage("GameData", serializer);
这条工作流的正式契约,以 `GFramework.Game` Runtime 和 `GFramework.Game.SourceGenerators` 当前共享支持的 schema
子集为准。`VS Code` 配置工具主要负责编辑期提示和表单辅助,不单独扩展运行时可接受的 schema 形状。
开始接入时,建议先把 schema 约束控制在共享子集内,并尽早确认像 `additionalProperties: false` 这类已收口的对象边界,以及
开始接入时,建议先把 schema 约束控制在共享子集内,并尽早确认像 `additionalProperties: false`(需显式设置为 `false`;省略或 `true` 视为非 `false`这类已收口的对象边界,以及
`oneOf` / `anyOf` 当前会被直接拒绝,而不是在工具里看起来“可以先写”。如果你的配置模型需要更深层的嵌套数组、联合分支或其他超出共享子集的复杂
shape优先回到 raw YAML 和 schema 设计本体处理,再决定是否拆分结构或调整约束方式。

View File

@ -148,14 +148,14 @@ var restored = serializer.Deserialize(json, data.GetType());
如果你的目标是静态内容配置表,而不是运行时持久化对象,请改看 [配置系统](./config-system.md)。
如果你在配置系统里进一步碰到更复杂的 schema shape也要尽快回到配置系统主文档和 raw YAML / schema 本体继续设计。当前默认采用路径面向的是与 `GFramework.Game` Runtime 和 `Game.SourceGenerators` 对齐的共享 schema 子集,不是任意 `JSON Schema` 的全量支持。
如果你在配置系统里进一步碰到更复杂的 schema shape也要尽快回到配置系统主文档和 raw YAML / schema 本体继续设计。当前默认采用路径面向的是与 `GFramework.Game` Runtime 和 `GFramework.Game.SourceGenerators` 对齐的共享 schema 子集,不是任意 `JSON Schema` 的全量支持。
## 当前边界
- 当前公开默认实现只有 JSON没有内建 MessagePack、Binary 或 ProtoBuf 实现
- `JsonSerializer` 负责序列化,不负责对象版本迁移;版本迁移属于 `SettingsModel<TRepository>``SaveRepository<TSaveData>`
- 序列化器共享后应视为只读配置对象,避免在运行期继续修改 settings / converters
- 如果配置设计依赖 `oneOf``anyOf`、非 `false``additionalProperties`,或其他需要开放对象形状与联合分支的复杂约束,请直接按配置系统主文档回到 raw YAML / schema 方案处理,而不是把这些场景归到序列化层
- 如果配置设计依赖 `oneOf``anyOf`、非 `false``additionalProperties`(例如省略或 `true`,或其他需要开放对象形状与联合分支的复杂约束,请直接按配置系统主文档回到 raw YAML / schema 方案处理,而不是把这些场景归到序列化层
## 继续阅读

View File

@ -23,7 +23,7 @@ description: 以当前 SettingsModel、SettingsSystem 与相关测试为准,
如果你关注的是“配置内容最后怎么变成运行时设置”,这里也需要先分清职责:
- 配置 schema 的正式支持边界,仍以 `GFramework.Game` Runtime 和 `GFramework.Game.SourceGenerators` 当前共享的 schema 子集为准
- `UnifiedSettingsDataRepository``SettingsModel<TRepository>``SettingsSystem` 负责设置数据的加载、迁移、保存与应用,不负责放宽 `oneOf``anyOf`、非 `false``additionalProperties` 等配置边界
- `UnifiedSettingsDataRepository``SettingsModel<TRepository>``SettingsSystem` 负责设置数据的加载、迁移、保存与应用,不负责放宽 `oneOf``anyOf`、非 `false``additionalProperties`(例如省略或 `true`等配置边界
- 一旦配置设计开始依赖更复杂的 schema shape应直接回到 [配置系统](./config-system.md) 与 raw YAML / schema 本体处理,再决定设置层怎么消费这些结果
## 当前公开入口

View File

@ -174,7 +174,7 @@ var cacheStorage = new ScopedStorage(rootStorage, "runtime-cache");
- `ScopedStorage` 只做 key 前缀,不做权限、事务或迁移控制
- 锁粒度是“当前实例内的目标路径”,不是跨进程文件锁
- 原子写入只覆盖单文件替换,不等于多文件事务
- 如果配置建模依赖 `oneOf``anyOf`、非 `false``additionalProperties`,或其他超出当前共享 schema 子集的复杂组合约束,这不是 `IStorage` 层能放宽的限制;应直接回到配置系统主文档与 raw YAML / schema 设计处理
- 如果配置建模依赖 `oneOf``anyOf`、非 `false``additionalProperties`(例如省略或 `true`,或其他超出当前共享 schema 子集的复杂组合约束,这不是 `IStorage` 层能放宽的限制;应直接回到配置系统主文档与 raw YAML / schema 设计处理
## 继续阅读

View File

@ -61,7 +61,7 @@ GFramework 当前发布的生成器包是:
当前不属于默认采用路径的典型情况包括:
- `oneOf``anyOf` 这类会改变生成类型形状的组合关键字
- 非 `false``additionalProperties`
- 非 `false``additionalProperties`(例如省略或 `true`
- 其他需要开放对象形状、联合分支或更自由属性合并的 schema 设计
这些场景当前不应被理解为“文档还没写到的隐藏支持”,而应被理解为:它们不在 `GFramework.Game` 现阶段共享配置契约内。

View File

@ -109,11 +109,21 @@ This extension is an editor-side helper. It does not define the runtime contract
5. Open the lightweight form preview or domain batch editing actions, then fall back to raw YAML for deeper nested edits
when needed.
Minimal adoption checklist:
- Keep one workspace folder that contains both `config/` and `schemas/`
- Place each config domain under `config/<domain>/*.yaml`
- Place the matching schema at `schemas/<domain>.schema.json`
- Use `x-gframework-ref-table` only on fields that should link to another config domain or reference file
- Keep `additionalProperties` explicitly set to `false` when you need closed-object validation; omitting it or setting
it to `true` is outside the supported subset
Use raw YAML directly when you need:
- deeper or more heterogeneous array shapes
- object rules centered on `allOf`, `dependentSchemas`, or object-focused `if` / `then` / `else`
- `contains` / `minContains` / `maxContains` verification on structures that are easier to reason about in source form
- supported object rules such as `allOf`, `dependentSchemas`, or object-focused `if` / `then` / `else` only when they
push the edit path beyond the lightweight form boundary
- `contains` / `minContains` / `maxContains` when the structure is easier to reason about directly in YAML
- schema designs outside the current shared subset, including `oneOf`, `anyOf`, or non-`false` `additionalProperties`
## Documentation

View File

@ -19,6 +19,7 @@ const DurationFormatPattern =
const TimeFormatPattern =
/^(?<hour>\d{2}):(?<minute>\d{2}):(?<second>\d{2})(?<fraction>\.\d+)?(?<offset>Z|[+-]\d{2}:\d{2})$/u;
const SupportedStringFormats = new Set(["date", "date-time", "duration", "email", "time", "uri", "uuid"]);
const SupportedSchemaTypes = new Set(["object", "array", "string", "integer", "number", "boolean"]);
/**
* Compare two strings using the same UTF-16 code-unit ordering as C#'s
@ -1104,7 +1105,7 @@ function parseSchemaNode(rawNode, displayPath) {
validateUnsupportedOpenObjectKeyword(value, displayPath);
const type = typeof value.type === "string" ? value.type : "object";
const type = resolveSupportedSchemaType(value.type, displayPath);
const patternMetadata = normalizeSchemaPattern(value.pattern, displayPath);
const stringFormat = normalizeSchemaStringFormat(value.format, type, displayPath);
const negatedSchemaNode = parseNegatedSchemaNode(value.not, displayPath);
@ -1298,13 +1299,7 @@ function validateUnsupportedOpenObjectKeyword(schemaNode, displayPath) {
* @returns {SchemaNode} Parsed child schema node.
*/
function parseRequiredArrayChildSchema(rawChild, displayPath, keywordName) {
const childNode = parseArrayChildSchema(rawChild, displayPath, keywordName);
if (childNode) {
return childNode;
}
throw new Error(
`Schema property '${displayPath}' must declare '${keywordName}' as an object-valued schema with an explicit 'type'.`);
return parseArrayChildSchema(rawChild, displayPath, keywordName);
}
/**
@ -1316,13 +1311,7 @@ function parseRequiredArrayChildSchema(rawChild, displayPath, keywordName) {
* @returns {SchemaNode | undefined} Parsed child schema node.
*/
function parseOptionalArrayChildSchema(rawChild, displayPath, keywordName) {
const childNode = parseArrayChildSchema(rawChild, displayPath, keywordName);
if (childNode) {
return childNode;
}
throw new Error(
`Schema property '${displayPath}' must declare '${keywordName}' as an object-valued schema with an explicit 'type'.`);
return parseArrayChildSchema(rawChild, displayPath, keywordName);
}
/**
@ -1333,18 +1322,40 @@ function parseOptionalArrayChildSchema(rawChild, displayPath, keywordName) {
* @param {unknown} rawChild Raw child schema node.
* @param {string} displayPath Logical parent array path.
* @param {"items" | "contains"} keywordName Child schema keyword.
* @returns {SchemaNode | undefined} Parsed child schema node.
* @returns {SchemaNode} Parsed child schema node.
*/
function parseArrayChildSchema(rawChild, displayPath, keywordName) {
if (!rawChild || typeof rawChild !== "object" || Array.isArray(rawChild)) {
return undefined;
throw new Error(
`Schema property '${displayPath}' must declare '${keywordName}' as an object-valued schema with an explicit 'type'.`);
}
if (typeof rawChild.type !== "string") {
return undefined;
throw new Error(
`Schema property '${displayPath}' must declare '${keywordName}' as an object-valued schema with an explicit 'type'.`);
}
return parseSchemaNode(rawChild, joinArrayTemplatePath(displayPath, keywordName));
return parseSchemaNode(rawChild, joinArrayTemplatePath(displayPath));
}
/**
* Resolve one schema type while rejecting explicit strings that the shared
* subset does not support.
*
* @param {unknown} rawType Raw schema type value.
* @param {string} displayPath Logical property path.
* @returns {"object" | "array" | "string" | "integer" | "number" | "boolean"} Supported schema type.
*/
function resolveSupportedSchemaType(rawType, displayPath) {
if (typeof rawType !== "string") {
return "object";
}
if (!SupportedSchemaTypes.has(rawType)) {
throw new Error(`Schema property '${displayPath}' declares unsupported type '${rawType}'.`);
}
return rawType;
}
/**

View File

@ -1009,8 +1009,14 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml, options) {
current = current[segment];
}
}
function getDirectObjectArrayItems(editor) {
const itemsHost = editor.querySelector(":scope > [data-object-array-items]");
return itemsHost
? Array.from(itemsHost.querySelectorAll(":scope > [data-object-array-item]"))
: [];
}
function renumberObjectArrayItems(editor) {
const items = editor.querySelectorAll("[data-object-array-items] > [data-object-array-item]");
const items = getDirectObjectArrayItems(editor);
items.forEach((item, index) => {
const title = item.querySelector(".object-array-item-title");
if (title) {
@ -1023,7 +1029,7 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml, options) {
}
function collectObjectArrayEditorItems(editor) {
const items = [];
for (const item of editor.querySelectorAll("[data-object-array-items] > [data-object-array-item]")) {
for (const item of getDirectObjectArrayItems(editor)) {
items.push(collectObjectArrayItemValue(item));
}

View File

@ -247,7 +247,7 @@ const zhCnMessages = {
"webview.hint.format": "格式:{value}",
"webview.hint.minItems": "最少元素数:{value}",
"webview.hint.maxItems": "最多元素数:{value}",
"webview.hint.contains": "Contains 条件:{summary}",
"webview.hint.contains": "contains 条件:{summary}",
"webview.hint.minContains": "最少匹配数:{value}",
"webview.hint.maxContains": "最多匹配数:{value}",
"webview.hint.uniqueItems": "元素必须唯一",

View File

@ -225,6 +225,21 @@ test("parseSchemaContent should reject unsupported additionalProperties forms",
/unsupported 'additionalProperties' metadata/u);
});
test("parseSchemaContent should reject unsupported explicit schema types", () => {
assert.throws(
() => parseSchemaContent(`
{
"type": "object",
"properties": {
"reward": {
"type": "bogus"
}
}
}
`),
/declares unsupported type 'bogus'/u);
});
test("parseSchemaContent should build object const comparable keys with ordinal property ordering", () => {
const schema = parseSchemaContent(`
{
@ -2701,6 +2716,49 @@ test("applyFormUpdates should rewrite nested object arrays from structured form
assert.match(updated, /^ value: true$/mu);
});
test("applyFormUpdates should not mix nested object-array items into the parent array", () => {
const updated = applyFormUpdates(
[
"phases:",
" -",
" wave: 1"
].join("\n"),
{
objectArrays: {
phases: [
{
wave: "1",
spawns: [
{
monsterId: "slime"
},
{
monsterId: "goblin"
}
]
},
{
wave: "2",
spawns: [
{
monsterId: "bat"
}
]
}
]
}
});
assert.equal((updated.match(/^ -$/gmu) || []).length, 2);
assert.equal((updated.match(/^ -$/gmu) || []).length, 3);
assert.doesNotMatch(updated, /^ monsterId: slime$/mu);
assert.doesNotMatch(updated, /^ monsterId: goblin$/mu);
assert.match(updated, /^ -$/mu);
assert.match(updated, /^ monsterId: slime$/mu);
assert.match(updated, /^ monsterId: goblin$/mu);
assert.match(updated, /^ monsterId: bat$/mu);
});
test("applyFormUpdates should clear object arrays when the form removes all items", () => {
const updated = applyFormUpdates(
[

View File

@ -111,7 +111,7 @@ test("buildContainsHintLines should use updated Chinese contains hint wording",
localizer);
assert.deepEqual(lines, [
"Contains 条件string, 允许值potion, elixir",
"contains 条件string, 允许值potion, elixir",
"最少匹配数1",
"最多匹配数2"
]);

View File

@ -68,6 +68,9 @@ test("createLocalizer should expose contains-count validation keys", () => {
assert.equal(
chineseLocalizer.t(ValidationMessageKeys.maxContainsViolation, {displayPath: "dropRates", value: 1}),
"属性“dropRates”最多只能包含 1 个匹配 contains 条件的元素。");
assert.equal(
chineseLocalizer.t("webview.hint.contains", {summary: "object, Required: itemCount"}),
"contains 条件object, Required: itemCount");
});
test("createLocalizer should resolve dependentRequired through the explicit validation key", () => {