// @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(); expect(screen.queryByRole('button', { name: '角色与对象' })).toBeNull(); expect(screen.queryByRole('button', { name: '场景与环境' })).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 }; }); renderPanel({ asset: { ...asset, tags: ['主页'] } }); 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([ '主页', '未回车的尾巴', ]); }); 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/); }); }); describe('ResourceClassificationPanel 不再承载删除素材', () => { /** * 删除素材挪到资源卡选中工具条(破坏性动作与它要改的对象放在一起),面板里**一个入口都不该再有**: * 旧版 footer 那个 `tone="danger"` 的「删除」删的是素材,属于放错地方。 * * 顺序也一并钉住:头部关闭 → 标签 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(['关闭编辑素材标签', '删除标签 主角', '添加']); // 打开面板不读引用、不写盘:删除那条链路在这里已经完全不存在。 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, ); }); });