diff --git a/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx b/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx index 431fabd9e..74de6842d 100644 --- a/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx +++ b/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx @@ -208,6 +208,12 @@ export function AssetImporter({ void refresh(ROOT_PATH); }, [open, refresh, settings]); + const refreshProjectAfterImportFailure = async (cause: unknown) => { + const importError = cause instanceof Error ? cause.message : String(cause); + await refresh(PROJECT_ASSETS_PATH); + setError(importError); + }; + const pickLocal = async () => { setError(null); setLoading(true); @@ -247,7 +253,7 @@ export function AssetImporter({ ); onClose(); } catch (cause) { - setError(cause instanceof Error ? cause.message : String(cause)); + await refreshProjectAfterImportFailure(cause); } finally { setLoading(false); } @@ -299,7 +305,7 @@ export function AssetImporter({ onImport(imported); onClose(); } catch (cause) { - setError(cause instanceof Error ? cause.message : String(cause)); + await refreshProjectAfterImportFailure(cause); } finally { setLoading(false); } diff --git a/apps/ai-game-creator-shell/tests/assetImporter.test.ts b/apps/ai-game-creator-shell/tests/assetImporter.test.ts index fb59bd4a5..d8a6d2a2d 100644 --- a/apps/ai-game-creator-shell/tests/assetImporter.test.ts +++ b/apps/ai-game-creator-shell/tests/assetImporter.test.ts @@ -1,11 +1,127 @@ -import { describe, expect, it } from 'vitest'; +// @vitest-environment jsdom -import { FONT_IMPORTER_SETTINGS } from '../src/components/AssetImporter/settings'; +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(), +})); + +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; + 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) + .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(() => { + 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' }] }; + } + 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 = [ @@ -39,3 +155,77 @@ describe('AssetImporter font mode', () => { ).toEqual(['本地项目']); }); }); + +describe('AssetImporter incremental import recovery', () => { + 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(); + }); +});