编辑标签面板回传落盘原值:堵住「只改标签」静默改分类

- 新增 `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,读显示口径才自愈」,把两个口径的差集钉死
This commit is contained in:
2026-09-11 18:45:38 +08:00
parent 458a65cb5e
commit 576786324f
4 changed files with 119 additions and 4 deletions
@@ -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<string[]>(() =>
gameCreationAppAssetTags(asset),
);
@@ -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) => {
@@ -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');
});
});
@@ -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<GameCreationAppAssetManifestEntry, 'kind' | 'category'>,
@@ -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<GameCreationAppAssetManifestEntry, 'kind' | 'category'>,
): GameCreationAppAssetCategory {
return (
normalizeGameCreationAppAssetCategory(asset.category) ??
gameCreationAppAssetCategoryForKind(asset.kind)
);
}
export function gameCreationAppAssetTags(
asset: Pick<GameCreationAppAssetManifestEntry, 'tags'>,
): string[] {