// @vitest-environment jsdom import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { createElement } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ invoke: vi.fn(), loadEditorAssetLibrary: vi.fn(), openNativeFileDialog: vi.fn(), resolveClientAssetReadUrl: vi.fn(), })); let includeUnregisteredProjectFile = false; vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke })); vi.mock('@tauri-apps/plugin-dialog', () => ({ open: mocks.openNativeFileDialog, })); vi.mock('../src/services/clientApi', () => ({ loadEditorAssetLibrary: mocks.loadEditorAssetLibrary, resolveClientAssetReadUrl: mocks.resolveClientAssetReadUrl, })); vi.mock('@cubone/react-file-manager', () => ({ FileManager: ({ files, onFolderChange, onSelectionChange, }: { files: Array<{ asset?: { id: string }; isDirectory: boolean; localProjectPath?: string; name: string; path: string; }>; onFolderChange: (path: string) => void; onSelectionChange: (files: unknown[]) => void; }) => createElement( 'div', null, createElement( 'button', { onClick: () => onFolderChange('/云端素材库'), type: 'button', }, '打开云端素材库', ), ...files .filter( (file) => !file.isDirectory && (file.asset || file.localProjectPath), ) .map((file) => createElement( 'button', { key: file.path, onClick: () => onSelectionChange([file]), type: 'button', }, `选择 ${file.name}`, ), ), ), })); import { AssetImporter } from '../src/components/AssetImporter'; import { FONT_IMPORTER_SETTINGS, SPRITE_IMPORTER_SETTINGS, } from '../src/components/AssetImporter/settings'; import { buildProjectFiles, buildRootFiles, } from '../src/components/AssetImporter/utils'; beforeEach(() => { includeUnregisteredProjectFile = false; mocks.invoke.mockReset(); mocks.loadEditorAssetLibrary.mockReset(); mocks.openNativeFileDialog.mockReset(); mocks.resolveClientAssetReadUrl.mockReset(); let manifestReadCount = 0; mocks.invoke.mockImplementation(async (command: string) => { if (command === 'get_local_game_manifest') { manifestReadCount += 1; return { assets: manifestReadCount === 1 ? [] : [ { id: 'committed-1', localPath: 'assets/uploads/committed.png', mediaType: 'image/png', }, ], }; } if (command === 'list_local_project_files') { return { files: [ { path: 'assets/uploads/committed.png' }, ...(includeUnregisteredProjectFile ? [{ path: 'assets/uploads/unregistered.png' }] : []), ], }; } if (command === 'import_local_project_image_assets') { return { assets: [ { id: 'registered-from-disk', localPath: 'assets/uploads/unregistered.png', assetKind: 'ui-design', }, ], }; } if (command === 'import_ui_editor_assets') { throw new Error('第二个素材登记失败'); } return undefined; }); mocks.loadEditorAssetLibrary.mockResolvedValue({ folders: [{ folderId: 'folder-1', label: '角色' }], assets: [ { folderId: 'folder-1', assetId: 'remote-1', objectKey: 'remote/hero.png', label: '英雄', size: 1024, }, ], }); mocks.openNativeFileDialog.mockResolvedValue([ '/tmp/first.png', '/tmp/second.png', ]); mocks.resolveClientAssetReadUrl.mockResolvedValue( 'https://assets.example/hero.png', ); }); describe('AssetImporter font mode', () => { it('discovers only registered font candidates and hides the remote root', () => { const assets = [ { id: 'font-1', localPath: 'assets/fonts/body.ttf', mediaType: 'font/ttf', }, { id: 'image-1', localPath: 'assets/hero.png', mediaType: 'image/png', }, { id: 'font-by-extension', localPath: 'assets/fonts/title.otf', mediaType: 'application/octet-stream', }, ]; const files = buildProjectFiles( assets, FONT_IMPORTER_SETTINGS.local.typeFilter, ).filter((entry) => !entry.isDirectory); expect(files.map((entry) => entry.asset?.id)).toEqual([ 'font-1', 'font-by-extension', ]); expect( buildRootFiles(FONT_IMPORTER_SETTINGS).map((entry) => entry.name), ).toEqual(['本地项目']); }); }); describe('AssetImporter incremental import recovery', () => { it('shows an unregistered project image as a candidate and registers it on confirm', async () => { includeUnregisteredProjectFile = true; const onImport = vi.fn(); render( createElement(AssetImporter, { open: true, onClose: vi.fn(), onImport, projectPath: '/tmp/ui-editor', settings: SPRITE_IMPORTER_SETTINGS, }), ); const candidate = await screen.findByRole('button', { name: '选择 unregistered.png(待导入)', }); fireEvent.click(candidate); fireEvent.click(screen.getByRole('button', { name: '导入' })); await waitFor(() => expect(onImport).toHaveBeenCalledTimes(1)); expect( mocks.invoke.mock.calls.some( ([command, args]) => command === 'import_local_project_image_assets' && args?.relativePaths?.[0] === 'assets/uploads/unregistered.png', ), ).toBe(true); expect(onImport).toHaveBeenCalledWith([ { id: 'registered-from-disk', localPath: 'assets/uploads/unregistered.png', assetKind: 'ui-design', }, ]); }); it('reloads the committed manifest after a local import partially fails', async () => { render( createElement(AssetImporter, { open: true, onClose: vi.fn(), onImport: vi.fn(), projectPath: '/tmp/ui-editor', settings: SPRITE_IMPORTER_SETTINGS, }), ); await waitFor(() => expect( mocks.invoke.mock.calls.filter( ([command]) => command === 'get_local_game_manifest', ), ).toHaveLength(1), ); fireEvent.click(screen.getByRole('button', { name: '从电脑导入' })); await waitFor(() => expect( mocks.invoke.mock.calls.filter( ([command]) => command === 'get_local_game_manifest', ), ).toHaveLength(2), ); expect(screen.getByRole('alert').textContent).toContain( '第二个素材登记失败', ); expect( screen.getByRole('button', { name: '选择 committed.png' }), ).toBeTruthy(); }); it('reloads the committed manifest after a remote import partially fails', async () => { render( createElement(AssetImporter, { open: true, onClose: vi.fn(), onImport: vi.fn(), projectPath: '/tmp/ui-editor', settings: SPRITE_IMPORTER_SETTINGS, }), ); await waitFor(() => expect( mocks.invoke.mock.calls.filter( ([command]) => command === 'get_local_game_manifest', ), ).toHaveLength(1), ); fireEvent.click(screen.getByRole('button', { name: '打开云端素材库' })); fireEvent.click(await screen.findByRole('button', { name: '选择 英雄' })); fireEvent.click(screen.getByRole('button', { name: '导入' })); await waitFor(() => expect( mocks.invoke.mock.calls.filter( ([command]) => command === 'get_local_game_manifest', ), ).toHaveLength(2), ); expect(screen.getByRole('alert').textContent).toContain( '第二个素材登记失败', ); expect( screen.getByRole('button', { name: '选择 committed.png' }), ).toBeTruthy(); }); });