From 576786324ff90850089301f050e83fcd6e825127 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Fri, 11 Sep 2026 18:45:38 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BC=96=E8=BE=91=E6=A0=87=E7=AD=BE=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E5=9B=9E=E4=BC=A0=E8=90=BD=E7=9B=98=E5=8E=9F=E5=80=BC?= =?UTF-8?q?=EF=BC=9A=E5=A0=B5=E4=BD=8F=E3=80=8C=E5=8F=AA=E6=94=B9=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E3=80=8D=E9=9D=99=E9=BB=98=E6=94=B9=E5=88=86=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 `gameCreationAppAssetPersistedCategory`:写回口径,只做「缺失或非法 → 按 kind 派生」兜底,不套用读时自愈;它必然等于 Rust 反序列化后的落盘 `category` - `ResourceClassificationPanel` 的分类初值从 `gameCreationAppAssetCategory`(读显示口径、会把落盘 `unclassified` 自愈成派生值)改为 `gameCreationAppAssetPersistedCategory`,保存时回传落盘原值 - 修掉真机上「同一 `kind:"ui"` 资产同时存在 55 条 `unclassified` 与 2 条 `ui-interaction`」的漂移签名——后者正是自愈值被回写持久化的结果 - 面板注释更正:原注释写「保证『只改标签』不会顺带改动分类」,但那行读的是自愈后的值,这个保证是假的 - `gameCreationAppAssetCategory` 的文档补上「这是读显示口径,回写必须用 `gameCreationAppAssetPersistedCategory`」,避免下次再混用 - 新增面板用例「保存标签不改动落盘 category:自愈值不得被回写」:夹具 `{kind:'ui', category:'unclassified'}`,断言写入命令收到的 `category` 仍是 `unclassified` - 既有面板夹具 `{kind:'character', category:'character'}` 是自愈 no-op,永远测不出漂移,因此保留它作为对照、另加自愈夹具 - 新增契约用例「写回口径保留落盘 unclassified,读显示口径才自愈」,把两个口径的差集钉死 --- .../ResourceClassificationPanel.tsx | 12 +++-- .../resourceClassificationPanel.test.tsx | 53 ++++++++++++++++++- .../src/contracts/gameCreationApp.test.ts | 33 ++++++++++++ .../shared/src/contracts/gameCreationApp.ts | 25 +++++++++ 4 files changed, 119 insertions(+), 4 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx index ada481912..ee2da6936 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx @@ -7,8 +7,8 @@ import { PlatformActionButton } from '../../../../../packages/shared/src/compone import { PlatformPillBadge } from '../../../../../packages/shared/src/components/PlatformPillBadge'; import { PlatformTextField } from '../../../../../packages/shared/src/components/PlatformTextField'; import { - gameCreationAppAssetCategory, type GameCreationAppAssetManifestEntry, + gameCreationAppAssetPersistedCategory, gameCreationAppAssetTags, normalizeGameCreationAppAssetTags, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; @@ -96,10 +96,16 @@ export function ResourceClassificationPanel({ }: ResourceClassificationPanelProps) { /** * 分类取值优先级由落盘 `category` + `kind` 派生决定,本面板不再提供手动设置入口。 - * 写入命令的 `category` 是必填,这里读一次当前权威值并在保存时原样回传, + * 写入命令的 `category` 是必填,这里读一次「落盘原值」并在保存时原样回传, * 保证「只改标签」不会顺带改动分类。 + * + * 必须用 `gameCreationAppAssetPersistedCategory`(写回口径)而不是 + * `gameCreationAppAssetCategory`(读显示口径):后者会把落盘 `unclassified` 自愈成 + * kind 派生值,回传它就等于在一个只改标签的面板里静默改掉分类。 */ - const [category] = useState(() => gameCreationAppAssetCategory(asset)); + const [category] = useState(() => + gameCreationAppAssetPersistedCategory(asset), + ); const [tags, setTags] = useState(() => gameCreationAppAssetTags(asset), ); diff --git a/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx b/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx index 2ed45a429..e2d552573 100644 --- a/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx @@ -208,13 +208,64 @@ describe('ResourceClassificationPanel 编辑素材标签', () => { expectedProjectId: 'project-1', expectedProjectRevision: 7, assetId: 'asset-hero', - // 本面板不编辑分类:读到的权威值原样回传,不因为只改标签而漂移。 + // 本面板不编辑分类:落盘原值原样回传,不因为只改标签而漂移。 category: 'character', tags: ['节日', '新春'], }, }); }); + /** + * 漂移防线:落盘 `unclassified` 而 `kind` 能派生出明确分类时,读显示口径会自愈成 + * `ui-interaction`,但**回写必须是落盘原值** `unclassified`。否则「编辑标签」面板 + * 会在用户只改标签时静默改掉分类——这正是真机上同一条 `kind:"ui"` 资产同时出现 + * `unclassified` 与 `ui-interaction` 两种落盘值的成因。 + * + * 变异验证:把面板改回 `gameCreationAppAssetCategory(asset)`,本用例必须失败。 + */ + test('保存标签不改动落盘 category:自愈值不得被回写', async () => { + const user = userEvent.setup(); + const invoke = installInvoke(async (command) => { + if (command === 'get_local_game_project_revision') { + return { revision: 7 }; + } + return { + asset: { ...asset, kind: 'ui', category: 'unclassified' }, + committedProjectRevision: 8, + }; + }); + renderPanel({ + asset: { + ...asset, + kind: 'ui', + category: 'unclassified', + tags: [], + }, + }); + + await user.type( + screen.getByPlaceholderText('新增标签,多个用逗号分隔'), + '界面', + ); + await user.click(screen.getByRole('button', { name: '保存标签' })); + + await waitFor(() => { + expect(classificationWrites(invoke)).toHaveLength(1); + }); + const [, args] = classificationWrites(invoke)[0]!; + // 读显示口径下该资产是 ui-interaction;回写口径必须是落盘值 unclassified。 + expect(args).toEqual({ + input: { + projectPath: 'C:/project', + expectedProjectId: 'project-1', + expectedProjectRevision: 7, + assetId: 'asset-hero', + category: 'unclassified', + tags: ['界面'], + }, + }); + }); + test('归一化口径不变:重复与空白标签在保存前被收敛', async () => { const user = userEvent.setup(); const invoke = installInvoke(async (command) => { diff --git a/packages/shared/src/contracts/gameCreationApp.test.ts b/packages/shared/src/contracts/gameCreationApp.test.ts index b69a12497..e9164d80e 100644 --- a/packages/shared/src/contracts/gameCreationApp.test.ts +++ b/packages/shared/src/contracts/gameCreationApp.test.ts @@ -18,6 +18,7 @@ import { gameCreationAppAssetCategory, gameCreationAppAssetCategoryForKind, type GameCreationAppAssetManifestEntry, + gameCreationAppAssetPersistedCategory, gameCreationAppAssetTags, type GameCreationAppManifest, normalizeGameCreationAppAssetCategory, @@ -899,4 +900,36 @@ describe('AI 游戏创作 App 共享契约', () => { gameCreationAppAssetCategory({ kind: 'ui', category: 'document' }), ).toBe('document'); }); + + /** + * 「读显示」与「写回」是两个口径,只有写回口径等于落盘值。 + * + * `gameCreationAppAssetCategory` 会把落盘 `unclassified` 自愈成 kind 派生值(读显示要正确), + * `gameCreationAppAssetPersistedCategory` 只做缺失 / 非法兜底(写回必须原样)。 + * 「编辑标签」面板一旦用读显示口径回写,用户只改标签就会静默改分类。 + */ + it('写回口径保留落盘 unclassified,读显示口径才自愈', () => { + const uiAsset = { kind: 'ui', category: 'unclassified' as const }; + expect(gameCreationAppAssetCategory(uiAsset)).toBe('ui-interaction'); + expect(gameCreationAppAssetPersistedCategory(uiAsset)).toBe('unclassified'); + + // 落盘值非法或缺失时,两个口径都按 kind 派生(与 Rust 反序列化兜底一致)。 + expect( + gameCreationAppAssetPersistedCategory({ + kind: 'character', + category: 'future-category' as never, + }), + ).toBe('character'); + expect( + gameCreationAppAssetPersistedCategory({ + kind: 'character', + category: undefined, + }), + ).toBe('character'); + + // 落盘值合法且非 unclassified 时两个口径一致。 + expect( + gameCreationAppAssetPersistedCategory({ kind: 'ui', category: 'audio' }), + ).toBe('audio'); + }); }); diff --git a/packages/shared/src/contracts/gameCreationApp.ts b/packages/shared/src/contracts/gameCreationApp.ts index 0077d4031..af93c67a7 100644 --- a/packages/shared/src/contracts/gameCreationApp.ts +++ b/packages/shared/src/contracts/gameCreationApp.ts @@ -620,6 +620,12 @@ export function gameCreationAppAssetCategoryForKind( * 而反过来漏掉这条规则,所有历史误判都无法自愈。 * kind 派生结果本身就是 `unclassified` 的(如 `image` / `video` / `code`)不受影响, * 仍然信任落盘值。 + * + * **这是「读显示」口径,不是「写回」口径。** 需要回写 manifest 的调用方必须用 + * `gameCreationAppAssetPersistedCategory`,否则会把自愈值写回落盘、把「只改标签」 + * 变成静默改分类。Rust 侧由 `game_creation_app_asset_effective_category` 实现同一口径 + * (Agent 投影走 Rust 那条),两侧不许各写一份,由 + * `tests/assetKindCanonicalMapping.test.ts` 的决策矩阵用例交叉钉住。 */ export function gameCreationAppAssetCategory( asset: Pick, @@ -635,6 +641,25 @@ export function gameCreationAppAssetCategory( return persisted; } +/** + * 回写 manifest 用的落盘分类:**不套用读时自愈**,只做「缺失或非法 → 按 kind 派生」的兜底。 + * + * 与 `gameCreationAppAssetCategory` 的分工是「读显示 / 写回」:显示层可以自愈历史上的误判值, + * 但写入层必须原样保留落盘值——「编辑标签」面板若回传自愈值,就把「只改标签」变成了 + * 静默改分类(真机已发生:同一条 `kind:"ui"` 资产出现 `unclassified` 与 `ui-interaction` 两种落盘值)。 + * + * Rust 侧 manifest 反序列化(`game_creation_app.rs` 的 `GameCreationAppAssetManifestEntry` + * `Deserialize`)用的是同一口径,因此本函数结果必然等于该资产当前的落盘 `category`。 + */ +export function gameCreationAppAssetPersistedCategory( + asset: Pick, +): GameCreationAppAssetCategory { + return ( + normalizeGameCreationAppAssetCategory(asset.category) ?? + gameCreationAppAssetCategoryForKind(asset.kind) + ); +} + export function gameCreationAppAssetTags( asset: Pick, ): string[] {