Files
Genarrative/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx
T
suzmii 1079f6c91b 资源工作台可变更素材类型:面板加类型选择器、卡片角标改显示资源类型
- ResourceClassificationPanel.tsx:加素材类型(功能分类)选择器,与标签同一次保存、同一条写入路径;选项 = GAME_CREATION_APP_ASSET_CATEGORIES × resourceReferenceCategoryLabel,不新造第二套中文译名
- ResourceClassificationPanel.tsx:选择器读显示口径(与画布栏目 projectResourceAssetCategory 同源,选中项就是卡片所在栏目);写回分两路——没碰过类型控件回传 gameCreationAppAssetPersistedCategory 的落盘原值,主动选过才写用户选的值(categoryChoice ?? persisted),保证「只改标签」不漂移分类
- index.tsx:资源卡右上角角标由「文件/媒体类型」改为「资源类型」,取 categoryLabels[resource.category](栏目与筛选共用的同一份文案),角标恒等于该卡所在栏目;projectResourceTypeLabel 仍服务资源管理面板小字与总览摞分列,不再用于角标
- tests/resourceClassificationPanel.test.tsx:新增「变更素材类型」6 条用例(选择器 6 项与中文口径、选择器显示显示口径自愈值、主动选值写入载荷、只改标签回传落盘原值对照、显式待归类写入 unclassified、连续两次各带当次选择且不关窗),并按新增控件更新既有按钮序与「分类不再由面板编辑」两条断言
- tests/projectResourceLiveIntegration.test.tsx:新增真宿主跟随链用例(角标显示资源类型并与栏目同名、改类型后卡片进目标栏目且角标跟着变、原栏目不再有它),配套状态化宿主 fixture、分类写入与 get_local_game_manifest 两条命令桩、切栏目与读角标助手
- tests/appSurface/project-development.suite.ts:原 data-resource-type 媒体类型断言改为资源类型(角色与对象 / 待归类 / 音频),并说明卡面正文走媒体分支、角标走资源类型这两个独立轴
- docs:PRD §5.3 由「分类没有用户手动设置入口」改为当前两口径分叉(选择器读显示口径、写回按是否主动选过分流)与「显式待归类被读时自愈覆盖」盲区
- docs:AGC 资源工作台 V3 端到端验收 S5 的「前端交互不能改分类」改写为可改分类,补判据(角标读 .game-resource-card-type-badge)与盲区说明;实施计划写入命令一节的面板名与职责同步为「编辑素材标签(标签 + 素材类型)」
- docs:decision-log 新增条目记录本次决策(Rust 无需改动、画布跟随链无需额外迁移代码、待归类盲区、四条变异验证结果与一条不可构造的变异)
- 说明:Rust、manifest 字段构成与 SpacetimeDB 均不改;写入命令 update_local_project_resource_classification 早已接受任意合法 category
2026-09-12 16:54:24 +08:00

738 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp';
import { ResourceClassificationPanel } from '../src/view/project-development/ResourceClassificationPanel';
const asset: GameCreationAppAssetManifestEntry = {
id: 'asset-hero',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/hero.png',
source: { kind: 'uploaded' },
category: 'character',
tags: ['主角'],
};
function installInvoke(
implementation: (command: string, args?: unknown) => Promise<unknown>,
) {
const invoke = vi.fn(implementation);
(
window as unknown as {
__TAURI__?: { core?: { invoke?: typeof invoke } };
}
).__TAURI__ = { core: { invoke } };
return invoke;
}
function removeInvoke() {
delete (
window as unknown as {
__TAURI__?: { core?: { invoke?: unknown } };
}
).__TAURI__;
}
function renderPanel(
overrides: {
asset?: GameCreationAppAssetManifestEntry;
onClose?: () => void;
onSaved?: (result: unknown) => void;
} = {},
) {
render(
<ResourceClassificationPanel
projectPath="C:/project"
projectId="project-1"
asset={overrides.asset ?? asset}
onClose={overrides.onClose ?? vi.fn()}
onSaved={overrides.onSaved ?? vi.fn()}
/>,
);
}
/** 已有标签按 pill 文本读出(pill 内的删除按钮文本是 `×`)。 */
function pillLabels() {
const list = screen.queryByRole('list', { name: '已有标签' });
if (!list) return [];
return within(list)
.getAllByRole('listitem')
.map((item) => item.textContent?.replace('×', '').trim());
}
function classificationWrites(invoke: ReturnType<typeof vi.fn>) {
return invoke.mock.calls.filter(
([command]) => command === 'update_local_project_resource_classification',
);
}
afterEach(() => {
cleanup();
removeInvoke();
});
describe('ResourceClassificationPanel 编辑素材标签', () => {
test('对齐用户截图:标题、素材名副标题、标签占位与底部唯一的「添加」', () => {
installInvoke(async () => undefined);
renderPanel({
asset: { ...asset, localPath: 'assets/UI Assets/生成UI设计图.png' },
});
expect(
screen.getByRole('heading', { name: '编辑素材标签' }),
).not.toBeNull();
// 副标题是素材名(localPath 的 basename),不再是资源路径。
expect(screen.getByText('生成UI设计图.png')).not.toBeNull();
expect(screen.queryByText('assets/UI Assets/生成UI设计图.png')).toBeNull();
expect(
screen.getByPlaceholderText('新增标签,多个用逗号分隔'),
).not.toBeNull();
// 底部只留一个「添加」:保存 / 取消 / 删除三个旧按钮都撤了
// (关闭走头部 ×,删除素材在资源卡选中工具条上)。
expect(screen.getByRole('button', { name: '添加' })).not.toBeNull();
expect(screen.queryByRole('button', { name: '保存标签' })).toBeNull();
expect(screen.queryByRole('button', { name: '取消' })).toBeNull();
expect(screen.queryByRole('button', { name: '删除资源' })).toBeNull();
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
// 「管理全部标签」没有实现。
expect(screen.queryByRole('button', { name: '管理全部标签' })).toBeNull();
// 素材类型(功能分类)重新有了用户入口:6 个合法分类各一项。
for (const label of [
'UI 交互',
'角色与对象',
'场景与环境',
'音频',
'文档',
'待归类',
]) {
expect(screen.getByRole('button', { name: label })).not.toBeNull();
}
});
test('每个已有标签渲染成自带删除按钮的 pill,且 aria-label 可区分', () => {
installInvoke(async () => undefined);
renderPanel({ asset: { ...asset, tags: ['主页', '节日'] } });
expect(pillLabels()).toEqual(['主页', '节日']);
// 删除按钮在 pill 内部(不是飘在外面的独立图标)。
const pill = screen.getByRole('button', { name: '删除标签 主页' });
expect(pill.closest('.game-resource-tag-pill')).not.toBeNull();
expect(
screen.getByRole('button', { name: '删除标签 节日' }),
).not.toBeNull();
});
test('点某个 pill 的删除按钮只删掉那一个标签', async () => {
const user = userEvent.setup();
installInvoke(async () => undefined);
renderPanel({ asset: { ...asset, tags: ['主页', '节日'] } });
await user.click(screen.getByRole('button', { name: '删除标签 主页' }));
expect(pillLabels()).toEqual(['节日']);
expect(screen.queryByRole('button', { name: '删除标签 主页' })).toBeNull();
expect(
screen.getByRole('button', { name: '删除标签 节日' }),
).not.toBeNull();
});
test('删掉 pill 之后只关面板不写盘:撤销不需要额外按钮', async () => {
const user = userEvent.setup();
const onClose = vi.fn();
const invoke = installInvoke(async () => undefined);
renderPanel({
asset: { ...asset, tags: ['主页', '节日'] },
onClose,
});
await user.click(screen.getByRole('button', { name: '删除标签 主页' }));
expect(pillLabels()).toEqual(['节日']);
// 关闭只有头部 × 一个入口(底部不再有「取消」)。
await user.click(screen.getByRole('button', { name: '关闭编辑素材标签' }));
expect(onClose).toHaveBeenCalledTimes(1);
expect(classificationWrites(invoke)).toHaveLength(0);
});
test('新增标签沿用同一套提交方式:回车 / 中英文逗号 / 顿号都切分', async () => {
const user = userEvent.setup();
installInvoke(async () => undefined);
renderPanel({ asset: { ...asset, tags: [] } });
const field = screen.getByPlaceholderText('新增标签,多个用逗号分隔');
await user.type(field, '主页,');
expect(pillLabels()).toEqual(['主页']);
await user.type(field, '节日,');
expect(pillLabels()).toEqual(['主页', '节日']);
await user.type(field, '新春{Enter}');
expect(pillLabels()).toEqual(['主页', '节日', '新春']);
// 纯空白草稿不会落成空标签。
await user.type(field, ' {Enter}');
expect(pillLabels()).toEqual(['主页', '节日', '新春']);
});
test('点「添加」把输入框与剩余标签写成数组,并原样回传读到的分类', async () => {
const user = userEvent.setup();
const onSaved = vi.fn();
const invoke = installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 7 };
}
if (command === 'update_local_project_resource_classification') {
return {
asset: { ...asset, tags: ['节日', '新春'] },
committedProjectRevision: 8,
};
}
throw new Error(`unexpected command: ${command}`);
});
renderPanel({ asset: { ...asset, tags: ['主页', '节日'] }, onSaved });
await user.click(screen.getByRole('button', { name: '删除标签 主页' }));
await user.type(
screen.getByPlaceholderText('新增标签,多个用逗号分隔'),
'新春',
);
await user.click(screen.getByRole('button', { name: '添加' }));
await waitFor(() => {
expect(onSaved).toHaveBeenCalledTimes(1);
});
const [, args] = classificationWrites(invoke)[0]!;
expect(args).toEqual({
input: {
projectPath: 'C:/project',
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) => {
if (command === 'get_local_game_project_revision') {
return { revision: 7 };
}
return { asset, committedProjectRevision: 8 };
});
renderPanel({ asset: { ...asset, tags: [] } });
const field = screen.getByPlaceholderText('新增标签,多个用逗号分隔');
await user.type(field, ' 主舞台 ,日夜,主舞台、');
await user.click(screen.getByRole('button', { name: '添加' }));
await waitFor(() => {
expect(classificationWrites(invoke)).toHaveLength(1);
});
const [, args] = classificationWrites(invoke)[0]!;
expect((args as { input: { tags: string[] } }).input.tags).toEqual([
'主舞台',
'日夜',
]);
});
/**
* 「添加」的语义包含**输入框里还没按回车的尾巴**:用户打了字直接点按钮,不能白输入。
*
* 这里刻意用 `fireEvent.click` 而不是 `user.click`:前者不会先让输入框失焦,所以只有
* 「点击处理器自己把草稿并进来」这一条路能让用例通过
* `onBlur` 的 `commitTagDraft` 兜不住它)。
*
* 变异验证:点「添加」只保存 `tags`、不合并 `tagDraft`,本用例必须失败。
*/
test('点「添加」把没按回车的尾巴一起落成 pill 并保存,且不自己关窗', async () => {
const invoke = installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 7 };
}
return { asset, committedProjectRevision: 8 };
});
const onClose = vi.fn();
const onSaved = vi.fn();
renderPanel({
asset: { ...asset, tags: ['主页'] },
onClose,
onSaved,
});
const field = screen.getByPlaceholderText('新增标签,多个用逗号分隔');
fireEvent.change(field, { target: { value: '未回车的尾巴' } });
fireEvent.click(screen.getByRole('button', { name: '添加' }));
// 落 pill 是即时反馈,保存是这一步的语义本身。
await waitFor(() => {
expect(pillLabels()).toEqual(['主页', '未回车的尾巴']);
});
await waitFor(() => {
expect(classificationWrites(invoke)).toHaveLength(1);
});
const [, args] = classificationWrites(invoke)[0]!;
expect((args as { input: { tags: string[] } }).input.tags).toEqual([
'主页',
'未回车的尾巴',
]);
// 保存成功后面板自己不能关窗(关窗是头部 × 与宿主的职责),输入框清空便于接着加。
expect(onSaved).toHaveBeenCalledTimes(1);
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByRole('dialog', { name: '编辑素材标签' })).not.toBeNull();
expect((field as HTMLInputElement).value).toBe('');
});
/**
* 「添加」是可连续执行的动作:点第二次必须真的再写一次盘(各自保存),
* 第二次的载荷要带上累计标签,分类仍原样回传落盘原值。
*
* 变异验证:让「添加」只在第一次写盘(例如保存后把按钮禁用不再恢复),本用例必须失败。
*/
test('连续两次「添加」各自保存,第二次带上累计标签', async () => {
const invoke = installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 7 };
}
return { asset, committedProjectRevision: 8 };
});
const onClose = vi.fn();
renderPanel({ asset: { ...asset, tags: [] }, onClose });
const field = screen.getByPlaceholderText('新增标签,多个用逗号分隔');
const add = () => screen.getByRole('button', { name: '添加' });
fireEvent.change(field, { target: { value: '主角' } });
fireEvent.click(add());
await waitFor(() => expect(classificationWrites(invoke)).toHaveLength(1));
fireEvent.change(field, { target: { value: '待定稿' } });
fireEvent.click(add());
await waitFor(() => expect(classificationWrites(invoke)).toHaveLength(2));
const secondInput = (
classificationWrites(invoke)[1]?.[1] as {
input: { tags: string[]; category: string };
}
).input;
expect(secondInput.tags).toEqual(['主角', '待定稿']);
// 分类不是这个面板的编辑对象:两次写入都回传同一条落盘原值。
expect(secondInput.category).toBe('character');
expect(pillLabels()).toEqual(['主角', '待定稿']);
expect((field as HTMLInputElement).value).toBe('');
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByRole('dialog', { name: '编辑素材标签' })).not.toBeNull();
});
test('surfaces the native rejection without reporting a save', async () => {
const user = userEvent.setup();
const onSaved = vi.fn();
installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 7 };
}
throw '非法资源分类:future-category';
});
renderPanel({ onSaved });
await user.click(screen.getByRole('button', { name: '添加' }));
await screen.findByRole('alert');
expect(screen.getByRole('alert').textContent).toContain('非法资源分类');
expect(onSaved).not.toHaveBeenCalled();
});
test('does not attempt a write outside the native client', async () => {
const user = userEvent.setup();
const onSaved = vi.fn();
renderPanel({ onSaved });
await user.click(screen.getByRole('button', { name: '添加' }));
await screen.findByRole('alert');
expect(screen.getByRole('alert').textContent).toContain('客户端');
expect(onSaved).not.toHaveBeenCalled();
});
test('资源卡工具条入口 label 与面板标题一致,不再是「分类与标签」', () => {
const viewSource = readFileSync(
resolve(
process.cwd(),
'apps/ai-game-creator-shell/src/view/project-development/index.tsx',
),
'utf8',
);
// 工具条写着「分类与标签」却打开纯标签面板会误导用户,入口必须与面板同名。
expect(viewSource).toContain('label="编辑标签"');
expect(viewSource).toContain('title="编辑标签"');
expect(viewSource).toContain('<span>编辑标签</span>');
expect(viewSource).not.toContain('label="分类与标签"');
});
test('标签 pill 的删除按钮热区不小于 32px', () => {
const panelStyles = readFileSync(
resolve(
process.cwd(),
'apps/ai-game-creator-shell/src/features/project-workspace/resourceClassificationTagPanel.css',
),
'utf8',
);
const rule = panelStyles.match(
/\.game-resource-tag-remove\s*\{([^}]*)\}/s,
)?.[1];
expect(rule).toBeDefined();
// 视觉图标可以小,热区不能小:这是"看得到却点不中"的防线。
expect(rule).toMatch(/width:\s*32px/);
expect(rule).toMatch(/height:\s*32px/);
expect(rule).toMatch(/flex:\s*0 0 32px/);
});
});
/**
* 素材类型(功能分类)的用户入口。这一组钉住本次改动的核心不变量,两个方向互为对照:
*
* - 用户**主动选过**类型 → 载荷 `category` 必须是用户选的那个值;
* - 用户**没碰过**类型控件(只改标签)→ 载荷 `category` 必须仍是**落盘原值**
* 不能等于选择器显示的读显示值。
*
* 任何一边被改成另一边的口径,都会有一条例用变红,见各用例上的「变异验证」。
*/
describe('ResourceClassificationPanel 变更素材类型', () => {
/**
* 落盘 `unclassified` 而 `kind:"ui"` 能派生出 `ui-interaction`:这是读时自愈的触发条件,
* 也正是"选择器显示值 ≠ 落盘值"的现场。真机 57 条 `kind:"ui"` 资产就是这个形状。
*/
const selfHealingAsset: GameCreationAppAssetManifestEntry = {
...asset,
id: 'asset-ui',
kind: 'ui',
category: 'unclassified',
tags: [],
};
function installClassificationInvoke() {
return installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 7 };
}
if (command === 'update_local_project_resource_classification') {
return { asset, committedProjectRevision: 8 };
}
throw new Error(`unexpected command: ${command}`);
});
}
function segmentedTabItem(label: string) {
return screen
.getByRole('button', { name: label })
.closest('.platform-segmented-tabs');
}
test('选择器给出 6 个合法分类,中文名与画布栏目同一份口径', () => {
installInvoke(async () => undefined);
renderPanel({ asset: selfHealingAsset });
for (const label of [
'UI 交互',
'角色与对象',
'场景与环境',
'音频',
'文档',
'待归类',
]) {
// 6 项都在同一个分段页签容器里,用的是与资源筛选同一份中文展示名。
expect(segmentedTabItem(label)).not.toBeNull();
}
});
/**
* 选择器的选中项是**读显示口径**(与资源卡栏目同源):该资产落盘是 `unclassified`
* 但画布把它放在「UI 交互」栏,选择器必须也显示「UI 交互」。
*
* 变异验证:把选择器读数换成 `gameCreationAppAssetPersistedCategory(asset)`(落盘口径),
* 选中项会变成「待归类」,本用例必须失败 —— 那正是"面板说待归类、卡片在 UI 交互"的错位。
*/
test('选择器显示的是显示口径:落盘 unclassified + kind ui 的资产选中「UI 交互」', () => {
installInvoke(async () => undefined);
renderPanel({ asset: selfHealingAsset });
expect(
screen
.getByRole('button', { name: 'UI 交互' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(
screen
.getByRole('button', { name: '待归类' })
.getAttribute('aria-pressed'),
).toBe('false');
});
/**
* 主动改类型:载荷 `category` 是用户选的那个值,而不是落盘原值。
*
* 变异验证:把写入改回"永远回传落盘原值"
* `category: gameCreationAppAssetPersistedCategory(asset)`),载荷会变成 `unclassified`
* 本用例必须失败。
*/
test('主动选中某个素材类型后保存:载荷 category 是用户选的那个值', async () => {
const user = userEvent.setup();
const invoke = installClassificationInvoke();
renderPanel({ asset: selfHealingAsset, onSaved: vi.fn() });
await user.click(screen.getByRole('button', { name: '场景与环境' }));
await user.click(screen.getByRole('button', { name: '添加' }));
await waitFor(() => {
expect(classificationWrites(invoke)).toHaveLength(1);
});
expect(classificationWrites(invoke)[0]?.[1]).toEqual({
input: {
projectPath: 'C:/project',
expectedProjectId: 'project-1',
expectedProjectRevision: 7,
assetId: 'asset-ui',
category: 'scene',
tags: [],
},
});
});
/**
* 只改标签(**没碰过**类型控件):载荷 `category` 必须仍是落盘原值 `unclassified`
* 即使选择器上显示的是自愈出来的 `ui-interaction`。
*
* 这是与上一条互为对照的关键用例:没有它,任何"顺手把显示值写回去"的实现都会绿。
*
* 变异验证:把写入改成"永远回传读显示口径"`gameCreationAppAssetCategory(asset)`),
* 载荷会变成 `ui-interaction` —— 也就是把自愈值写回落盘、让"只改标签"静默改分类,
* 本用例必须失败。
*/
test('只改标签时载荷 category 仍是落盘原值,不把选择器上的自愈值写回去', async () => {
const user = userEvent.setup();
const invoke = installClassificationInvoke();
renderPanel({ asset: selfHealingAsset });
expect(
screen
.getByRole('button', { name: 'UI 交互' })
.getAttribute('aria-pressed'),
).toBe('true');
await user.type(
screen.getByPlaceholderText('新增标签,多个用逗号分隔'),
'界面',
);
await user.click(screen.getByRole('button', { name: '添加' }));
await waitFor(() => {
expect(classificationWrites(invoke)).toHaveLength(1);
});
expect(classificationWrites(invoke)[0]?.[1]).toEqual({
input: {
projectPath: 'C:/project',
expectedProjectId: 'project-1',
expectedProjectRevision: 7,
assetId: 'asset-ui',
category: 'unclassified',
tags: ['界面'],
},
});
});
/**
* 显式选「待归类」写下去的就是 `unclassified`。
*
* 注意这里只钉**写入值**,不给"卡片会挪到待归类栏目"的承诺:读显示口径会把
* `kind` 已能明确分类的资产自愈回派生栏目(`unclassified` 无法区分"没有明确分类"
* 与"用户显式选了待归类",这是 `gameCreationApp.ts` 记录的有意取舍)。
* 选一个**能**被派生值承认的分类(上一条的 `scene`)不受该取舍影响。
*/
test('显式选中「待归类」:载荷 category 是 unclassified', async () => {
const user = userEvent.setup();
const invoke = installClassificationInvoke();
renderPanel({ asset: { ...asset, category: 'character' } });
await user.click(screen.getByRole('button', { name: '待归类' }));
await user.click(screen.getByRole('button', { name: '添加' }));
await waitFor(() => {
expect(classificationWrites(invoke)).toHaveLength(1);
});
const input = (
classificationWrites(invoke)[0]?.[1] as {
input: { category: string };
}
).input;
expect(input.category).toBe('unclassified');
});
/**
* 改类型与「点添加不关窗、可连续添加」不互斥:第一次改类型保存后,第二次接着改回另一类型
* 仍然各写一次盘,两次的 `category` 分别是用户当次选的值。
*
* 变异验证:把 `categoryChoice` 只在首次生效(保存后重置为落盘值),第二次写入的
* `category` 会退回落盘值,本用例必须失败。
*/
test('连续两次「添加」各带当次选择的类型,且面板不自己关窗', async () => {
const invoke = installClassificationInvoke();
const onClose = vi.fn();
renderPanel({ asset: selfHealingAsset, onClose });
const add = () => screen.getByRole('button', { name: '添加' });
fireEvent.click(screen.getByRole('button', { name: '角色与对象' }));
fireEvent.click(add());
await waitFor(() => expect(classificationWrites(invoke)).toHaveLength(1));
fireEvent.click(screen.getByRole('button', { name: '音频' }));
fireEvent.click(add());
await waitFor(() => expect(classificationWrites(invoke)).toHaveLength(2));
expect(
classificationWrites(invoke).map(
([, args]) => (args as { input: { category: string } }).input.category,
),
).toEqual(['character', 'audio']);
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByRole('dialog', { name: '编辑素材标签' })).not.toBeNull();
});
});
describe('ResourceClassificationPanel 不再承载删除素材', () => {
/**
* 删除素材挪到资源卡选中工具条(破坏性动作与它要改的对象放在一起),面板里**一个入口都不该再有**:
* 旧版 footer 那个 `tone="danger"` 的「删除」删的是素材,属于放错地方。
*
* 顺序也一并钉住:头部关闭 → 素材类型选择器 6 项 → 标签 pill 内的删除标签 → 底部「添加」,
* 多出任何一个按钮都会红。
* 变异验证:把 footer 的删除素材按钮加回来,本用例必须失败。
*/
test('面板里没有任何删除素材入口,打开面板不读写删除相关命令', async () => {
const invoke = installInvoke(async (command) => {
throw new Error(`unexpected command: ${command}`);
});
renderPanel();
expect(
screen
.getAllByRole('button')
.map(
(button) => button.getAttribute('aria-label') ?? button.textContent,
),
).toEqual([
'关闭编辑素材标签',
// 素材类型选择器:6 个合法分类,顺序就是画布栏目顺序。
'UI 交互',
'角色与对象',
'场景与环境',
'音频',
'文档',
'待归类',
'删除标签 主角',
'添加',
]);
// 打开面板不读引用、不写盘:删除那条链路在这里已经完全不存在。
expect(invoke).not.toHaveBeenCalled();
});
/**
* 底部只有一个「添加」且靠右下角。
*
* 变异验证:把 `justify-content` 改回默认(或改 `flex-start`),本用例必须失败。
*/
test('底部只有一个「添加」并靠右下角', () => {
const styles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
const rule = styles.match(
/\.game-resource-classification-footer\s*\{([^}]*)\}/s,
)?.[1];
expect(rule).toBeDefined();
expect(rule).toMatch(/display:\s*flex/);
expect(rule).toMatch(/justify-content:\s*flex-end/);
installInvoke(async () => undefined);
renderPanel();
const footer = document.querySelector(
'.game-resource-classification-footer',
);
expect(footer).not.toBeNull();
expect(within(footer as HTMLElement).getAllByRole('button')).toHaveLength(
1,
);
});
});