// @vitest-environment jsdom import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { cleanup, 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; onDeleted?: (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.getByRole('button', { name: '取消' })).not.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('删除在保存前可撤销:点取消不写盘', 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([ '主舞台', '日夜', ]); }); 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 删除资源', () => { test('deletes the asset registration only after the confirmation panel is submitted', async () => { const user = userEvent.setup(); const onDeleted = vi.fn(); const invoke = installInvoke(async (command) => { if (command === 'read_local_project_asset_references') { return { assetId: 'asset-hero', versions: [] }; } if (command === 'get_local_game_project_revision') { return { revision: 9 }; } if (command === 'delete_local_project_asset') { return { assetId: 'asset-hero', localPath: 'assets/hero.png', committedProjectRevision: 10, fileRetained: true, }; } throw new Error(`unexpected command: ${command}`); }); render( , ); // 删除入口只打开确认面板,不得直接发起删除。 await user.click(screen.getByRole('button', { name: '删除资源' })); await screen.findByRole('dialog', { name: '确认删除资源' }); expect(onDeleted).not.toHaveBeenCalled(); expect(invoke).toHaveBeenCalledWith('read_local_project_asset_references', { input: { projectPath: 'C:/project', assetId: 'asset-hero' }, }); expect( invoke.mock.calls.some( ([command]) => command === 'delete_local_project_asset', ), ).toBe(false); // 未被任何版本引用时不出现连带删除勾选。 expect( screen.queryByRole('checkbox', { name: '把相关游戏版本一并删除' }), ).toBeNull(); await user.click(screen.getByRole('button', { name: '确认删除资源' })); await waitFor(() => { expect(onDeleted).toHaveBeenCalledTimes(1); }); expect(invoke).toHaveBeenCalledWith('delete_local_project_asset', { input: { projectPath: 'C:/project', expectedProjectId: 'project-1', expectedProjectRevision: 9, assetId: 'asset-hero', deleteReferencedVersions: false, }, }); expect(onDeleted.mock.calls[0]?.[0]).toEqual({ assetId: 'asset-hero', localPath: 'assets/hero.png', committedProjectRevision: 10, fileRetained: true, }); }); test('lists the versions using the asset and keeps the cascade option unchecked', async () => { const user = userEvent.setup(); const onDeleted = vi.fn(); const invoke = installInvoke(async (command) => { if (command === 'read_local_project_asset_references') { return { assetId: 'asset-hero', versions: [ { versionId: 'initial-1', projectRevision: 1, createdAt: 1_760_000_000, }, { versionId: 'agent-4', projectRevision: 4, createdAt: 1_760_003_600, }, ], }; } if (command === 'get_local_game_project_revision') { return { revision: 9 }; } if (command === 'delete_local_project_asset') { return { assetId: 'asset-hero', localPath: 'assets/hero.png', committedProjectRevision: 10, fileRetained: true, }; } throw new Error(`unexpected command: ${command}`); }); render( , ); await user.click(screen.getByRole('button', { name: '删除资源' })); await screen.findByRole('dialog', { name: '确认删除资源' }); expect(screen.getByText('被 2 个游戏版本使用')).toBeTruthy(); expect(screen.getByText('initial-1')).toBeTruthy(); expect(screen.getByText('agent-4')).toBeTruthy(); const cascade = screen.getByRole('checkbox', { name: '把相关游戏版本一并删除', }); expect((cascade as HTMLInputElement).checked).toBe(false); await user.click(screen.getByRole('button', { name: '确认删除资源' })); await waitFor(() => { expect(onDeleted).toHaveBeenCalledTimes(1); }); expect(invoke).toHaveBeenCalledWith( 'delete_local_project_asset', expect.objectContaining({ input: expect.objectContaining({ deleteReferencedVersions: false }), }), ); }); test('requests the cascade delete only when the user checks the option', async () => { const user = userEvent.setup(); const onDeleted = vi.fn(); const invoke = installInvoke(async (command) => { if (command === 'read_local_project_asset_references') { return { assetId: 'asset-hero', versions: [ { versionId: 'initial-1', projectRevision: 1, createdAt: 1_760_000_000, }, ], }; } if (command === 'get_local_game_project_revision') { return { revision: 9 }; } if (command === 'delete_local_project_asset') { return { assetId: 'asset-hero', localPath: 'assets/hero.png', committedProjectRevision: 10, fileRetained: true, }; } throw new Error(`unexpected command: ${command}`); }); render( , ); await user.click(screen.getByRole('button', { name: '删除资源' })); await screen.findByRole('dialog', { name: '确认删除资源' }); await user.click( screen.getByRole('checkbox', { name: '把相关游戏版本一并删除' }), ); await user.click(screen.getByRole('button', { name: '确认删除资源' })); await waitFor(() => { expect(onDeleted).toHaveBeenCalledTimes(1); }); expect(invoke).toHaveBeenCalledWith( 'delete_local_project_asset', expect.objectContaining({ input: expect.objectContaining({ deleteReferencedVersions: true }), }), ); }); test('surfaces the native rejection without reporting a delete', async () => { const user = userEvent.setup(); const onDeleted = vi.fn(); installInvoke(async (command) => { if (command === 'read_local_project_asset_references') { return { assetId: 'asset-hero', versions: [] }; } if (command === 'get_local_game_project_revision') { return { revision: 9 }; } throw 'project-revision-conflict'; }); render( , ); await user.click(screen.getByRole('button', { name: '删除资源' })); await screen.findByRole('dialog', { name: '确认删除资源' }); await user.click(screen.getByRole('button', { name: '确认删除资源' })); await screen.findByRole('alert'); expect(screen.getByRole('alert').textContent).toContain( 'project-revision-conflict', ); expect(onDeleted).not.toHaveBeenCalled(); }); });