// @vitest-environment jsdom 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 { ResourceAssetDeleteDialog } from '../src/view/project-development/ResourceAssetDeleteDialog'; import type { DeleteLocalProjectAssetResult } from '../src/view/project-development/useResourceAssetDeleteFlow'; import { useResourceAssetDeleteFlow } from '../src/view/project-development/useResourceAssetDeleteFlow'; /** * 素材删除流程的定向用例。 * * 这条流程从「编辑素材标签」面板抽成了 `useResourceAssetDeleteFlow`,改由资源卡选中工具条 * 的「删除素材」触发;判据与实现在抽前后必须逐字不变 —— 所以这里钉住的是三件事: * 1. 点入口只读引用、只开二次确认面板,不直接删; * 2. `deleteReferencedVersions` 的三分支(不勾 / 勾 / 未被引用); * 3. 失败时既不改状态也不静默:原因经宿主的 `onError` 出口出去。 * * 工具条上的入口渲染判据(只有 `manifestAssetId` 存在才渲染)与真链路 IPC 载荷由 * `resourceVersionReplacement.test.tsx` 的工台用例覆盖,这里只测流程本身。 */ const TARGET = { assetId: 'asset-hero', localPath: 'assets/hero.png' }; type InvokeMock = ReturnType; 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 DeleteFlowHarness({ onDeleted, onError, }: { onDeleted: (result: DeleteLocalProjectAssetResult) => void; onError: (message: string) => void; }) { const flow = useResourceAssetDeleteFlow({ projectPath: 'C:/project', projectId: 'project-1', onError, onDeleted, }); return (
{flow.deleteDialogProps ? ( ) : null}
); } function renderDeleteFlow() { const onDeleted = vi.fn(); const onError = vi.fn(); render(); return { onDeleted, onError }; } function deleteCalls(invoke: InvokeMock) { return invoke.mock.calls.filter( ([command]) => command === 'delete_local_project_asset', ); } afterEach(() => { cleanup(); removeInvoke(); }); describe('useResourceAssetDeleteFlow 删除素材', () => { test('入口只开确认面板不直接删;未被引用时不出现连带删除勾选', async () => { const user = userEvent.setup(); 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}`); }); const { onDeleted, onError } = renderDeleteFlow(); 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(deleteCalls(invoke)).toHaveLength(0); expect( screen.queryByRole('checkbox', { name: '把相关游戏版本一并删除' }), ).toBeNull(); // 面板副标题用的是落盘 `localPath`,不是素材 id。 expect(screen.getByText('assets/hero.png')).not.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, }); expect(onError).not.toHaveBeenCalled(); // 删完面板收起,不留一个"点不动"的确认面板。 await waitFor(() => expect(screen.queryByRole('dialog', { name: '确认删除资源' })).toBeNull(), ); }); test('列出引用该素材的版本,并默认不连带删除', async () => { const user = userEvent.setup(); 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 }; } return { assetId: 'asset-hero', localPath: 'assets/hero.png', committedProjectRevision: 10, fileRetained: true, }; }); renderDeleteFlow(); await user.click(screen.getByRole('button', { name: '删除素材' })); const dialog = await screen.findByRole('dialog', { name: '确认删除资源', }); expect(within(dialog).getByText('被 2 个游戏版本使用')).toBeTruthy(); expect(within(dialog).getByText('initial-1')).toBeTruthy(); expect(within(dialog).getByText('agent-4')).toBeTruthy(); expect( ( within(dialog).getByRole('checkbox', { name: '把相关游戏版本一并删除', }) as HTMLInputElement ).checked, ).toBe(false); await user.click( within(dialog).getByRole('button', { name: '确认删除资源' }), ); await waitFor(() => expect(deleteCalls(invoke)).toHaveLength(1)); expect(invoke).toHaveBeenCalledWith( 'delete_local_project_asset', expect.objectContaining({ input: expect.objectContaining({ deleteReferencedVersions: false }), }), ); }); test('只有勾上连带删除才按 true 走同一分支', async () => { const user = userEvent.setup(); 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 }; } return { assetId: 'asset-hero', localPath: 'assets/hero.png', committedProjectRevision: 10, fileRetained: true, }; }); renderDeleteFlow(); await user.click(screen.getByRole('button', { name: '删除素材' })); const dialog = await screen.findByRole('dialog', { name: '确认删除资源', }); await user.click( within(dialog).getByRole('checkbox', { name: '把相关游戏版本一并删除', }), ); await user.click( within(dialog).getByRole('button', { name: '确认删除资源' }), ); await waitFor(() => expect(deleteCalls(invoke)).toHaveLength(1)); expect(invoke).toHaveBeenCalledWith( 'delete_local_project_asset', expect.objectContaining({ input: expect.objectContaining({ deleteReferencedVersions: true }), }), ); }); test('读引用失败时不弹空壳确认面板,原因经宿主出口说明白', async () => { const user = userEvent.setup(); const invoke = installInvoke(async (command) => { if (command === 'read_local_project_asset_references') { throw '素材已被删除'; } throw new Error(`unexpected command: ${command}`); }); const { onDeleted, onError } = renderDeleteFlow(); await user.click(screen.getByRole('button', { name: '删除素材' })); await waitFor(() => expect(onError).toHaveBeenCalledWith('素材已被删除')); expect(screen.queryByRole('dialog', { name: '确认删除资源' })).toBeNull(); expect(deleteCalls(invoke)).toHaveLength(0); expect(onDeleted).not.toHaveBeenCalled(); }); test('删除失败时保留确认面板、透出原生拒绝,且不报删除成功', async () => { const user = userEvent.setup(); 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'; }); const { onDeleted, onError } = renderDeleteFlow(); await user.click(screen.getByRole('button', { name: '删除素材' })); await screen.findByRole('dialog', { name: '确认删除资源' }); await user.click(screen.getByRole('button', { name: '确认删除资源' })); await waitFor(() => // 结构化 CAS 错误码翻成用户可读中文:与重命名、分类保存同一份映射。 expect(onError).toHaveBeenCalledWith( '项目已被其它操作改动,请刷新后重试', ), ); expect(screen.getByRole('dialog', { name: '确认删除资源' })).not.toBeNull(); expect(onDeleted).not.toHaveBeenCalled(); }); test('不在客户端内时不发任何 IPC,直接说明原因', async () => { const user = userEvent.setup(); const { onDeleted, onError } = renderDeleteFlow(); await user.click(screen.getByRole('button', { name: '删除素材' })); await waitFor(() => expect(onError).toHaveBeenCalledWith('删除资源需要在客户端内执行'), ); expect(screen.queryByRole('dialog', { name: '确认删除资源' })).toBeNull(); expect(onDeleted).not.toHaveBeenCalled(); }); });