// @vitest-environment jsdom import { cleanup, render, screen, waitFor } 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__; } afterEach(() => { cleanup(); removeInvoke(); }); describe('ResourceClassificationPanel', () => { test('writes the category and normalized tags through the controlled native command', 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, category: 'scene', tags: ['主舞台', '日夜'] }, committedProjectRevision: 8, }; } throw new Error(`unexpected command: ${command}`); }); render( , ); await user.click(screen.getByRole('button', { name: '场景与环境' })); const tagsField = screen.getByLabelText('资源标签'); await user.clear(tagsField); await user.type(tagsField, ' 主舞台 ,日夜,主舞台、'); await user.click(screen.getByRole('button', { name: '保存' })); await waitFor(() => { expect(onSaved).toHaveBeenCalledTimes(1); }); expect(invoke).toHaveBeenCalledWith( 'update_local_project_resource_classification', expect.objectContaining({ input: expect.objectContaining({ projectPath: 'C:/project', expectedProjectId: 'project-1', expectedProjectRevision: 7, assetId: 'asset-hero', category: 'scene', }), }), ); const [, args] = invoke.mock.calls.find( ([command]) => command === 'update_local_project_resource_classification', )!; expect((args as { input: { tags: string[] } }).input.tags).toEqual([ '主舞台', '日夜', ]); expect(onSaved.mock.calls[0]?.[0]).toEqual({ asset: { ...asset, category: 'scene', tags: ['主舞台', '日夜'] }, committedProjectRevision: 8, }); }); 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'; }); render( , ); 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(); render( , ); await user.click(screen.getByRole('button', { name: '保存' })); await screen.findByRole('alert'); expect(screen.getByRole('alert').textContent).toContain('客户端'); expect(onSaved).not.toHaveBeenCalled(); }); 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(); }); });