编辑标签面板回传落盘原值:堵住「只改标签」静默改分类
- 新增 `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:
+9
-3
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user