// @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, ) { 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( , ); } /** 已有标签按 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) { 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('编辑标签'); 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, ); }); });