/* @vitest-environment jsdom */ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { useRef, useState } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiClientError } from '../../services/apiClient'; import type { CanvasLayer, CanvasTool, EditorAsset, EditorAssetFolder, GenerateDialogState, SidebarPanel, } from './ImageCanvasEditorTypes'; import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow'; const createEditorAssetMock = vi.hoisted(() => vi.fn()); vi.mock('../../services/image-editor/editorProjectClient', async () => { const actual = await vi.importActual< typeof import('../../services/image-editor/editorProjectClient') >('../../services/image-editor/editorProjectClient'); return { ...actual, createEditorAsset: createEditorAssetMock, }; }); function createDefaultFolder(): EditorAssetFolder { return { id: 'project', label: '项目素材', collapsed: false, systemDefault: true, persisted: true, }; } function createTestFile(name = '上传素材.png') { return new File(['image'], name, { type: 'image/png' }); } function createDeferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((promiseResolve, promiseReject) => { resolve = promiseResolve; reject = promiseReject; }); return { promise, resolve, reject }; } function UploadWorkflowHarness({ canAccessProtectedData = true, openEditorLoginModal = vi.fn(), activeTool = 'select', }: { canAccessProtectedData?: boolean; openEditorLoginModal?: (postLoginAction?: (() => void) | null) => void; activeTool?: CanvasTool; }) { const [assetFolders, setAssetFolders] = useState([ createDefaultFolder(), ]); const [assets, setAssets] = useState([]); const [layers, setLayers] = useState([]); const [generateDialog, setGenerateDialog] = useState(null); const [activeSidebarPanel, setActiveSidebarPanel] = useState('assets'); const [selectedLayerId, setSelectedLayerId] = useState(null); const uploadIndexRef = useRef(0); const workflow = useImageCanvasUploadWorkflow({ canAccessProtectedData, openEditorLoginModal, assetFolders, activeUploadFolderId: 'project', canvasSize: { width: 900, height: 640 }, viewport: { x: 10, y: 20, scale: 2 }, activeTool, allocateUploadIndex: () => { uploadIndexRef.current += 1; return uploadIndexRef.current; }, setAssetFolders, setAssets, setLayers, setGenerateDialog, setActiveSidebarPanel, appendCanvasLayersWithResources: (nextLayers) => { setLayers((currentLayers) => [...currentLayers, ...nextLayers]); }, selectSingleLayer: setSelectedLayerId, }); return (
{assets .map( (asset) => `${asset.id}:${asset.label}:${asset.folderId}:${asset.uploadStatus ?? 'ready'}:${asset.uploadMessage ?? '-'}`, ) .join('|')} {assetFolders .map( (folder) => `${folder.id}:${folder.collapsed ? 'collapsed' : 'open'}`, ) .join('|')} {layers .map( (layer) => `${layer.id}:${layer.title}:${layer.sourceAssetId}:${layer.x}:${layer.y}`, ) .join('|')} {activeSidebarPanel ?? '-'} {selectedLayerId ?? '-'} {generateDialog ? `${generateDialog.mode}:${generateDialog.status}:${generateDialog.characterSpecReference?.label ?? '-'}:${generateDialog.characterReferences?.length ?? 0}:${generateDialog.iconSpecReference?.label ?? '-'}:${generateDialog.specReference?.label ?? '-'}` : '-'}
); } describe('useImageCanvasUploadWorkflow', () => { beforeEach(() => { vi.clearAllMocks(); createEditorAssetMock.mockImplementation(async (input) => ({ assetId: `persisted-${input.label}`, folderId: input.folderId, label: input.label, imageSrc: input.imageSrc, width: input.width, height: input.height, sourceType: input.sourceType, objectKey: 'object-key-uploaded', assetObjectId: 'asset-object-uploaded', })); }); it('opens login before creating placeholders and resumes the same upload after login', async () => { const openEditorLoginModal = vi.fn(); const { rerender } = render( , ); fireEvent.click(screen.getByRole('button', { name: '上传素材' })); expect(openEditorLoginModal).toHaveBeenCalledTimes(1); expect(createEditorAssetMock).not.toHaveBeenCalled(); expect(screen.getByTestId('assets').textContent).toBe(''); const resumeUpload = openEditorLoginModal.mock.calls[0]?.[0]; rerender( , ); act(() => { (resumeUpload as () => void)(); }); await waitFor(() => { expect(createEditorAssetMock).toHaveBeenCalledTimes(1); }); await waitFor(() => { expect(screen.getByTestId('assets').textContent).toContain( 'persisted-上传素材.png:上传素材.png:project:ready:-', ); }); }); it('opens login instead of the asset file picker when protected data is unavailable', () => { const openEditorLoginModal = vi.fn(); render( , ); const uploadInput = screen.getByLabelText('上传图片文件') as HTMLInputElement; const clickUploadInput = vi.spyOn(uploadInput, 'click'); fireEvent.click(screen.getByRole('button', { name: '请求素材上传' })); expect(openEditorLoginModal).toHaveBeenCalledTimes(1); expect(clickUploadInput).not.toHaveBeenCalled(); }); it('keeps generation reference uploads local and opens the file picker without login', () => { const openEditorLoginModal = vi.fn(); render( , ); const uploadInput = screen.getByLabelText('上传图片文件') as HTMLInputElement; const clickUploadInput = vi.spyOn(uploadInput, 'click'); fireEvent.click(screen.getByRole('button', { name: '请求角色规范上传' })); expect(openEditorLoginModal).not.toHaveBeenCalled(); expect(clickUploadInput).toHaveBeenCalledTimes(1); }); it('creates an uploading asset card, adds a canvas layer, and patches the layer with the persisted asset id', async () => { const deferredAsset = createDeferred<{ assetId: string; folderId: string; label: string; imageSrc: string; width: number; height: number; sourceType: 'uploaded'; objectKey: string; assetObjectId: string; }>(); createEditorAssetMock.mockReturnValueOnce(deferredAsset.promise); render(); fireEvent.click(screen.getByRole('button', { name: '上传到画布' })); await waitFor(() => { expect(screen.getByTestId('assets').textContent).toContain( 'upload-1:画布素材.png:project:uploading:上传中', ); expect(screen.getByTestId('layers').textContent).toContain( 'layer-upload-1:画布素材.png:upload-1:-160:-107.5', ); }); expect(screen.getByTestId('sidebar').textContent).toBe('layers'); expect(screen.getByTestId('selected-layer').textContent).toBe( 'layer-upload-1', ); deferredAsset.resolve({ assetId: 'asset-persisted-canvas', folderId: 'project', label: '画布素材.png', imageSrc: 'data:image/png;base64,Y2FudmFz', width: 420, height: 315, sourceType: 'uploaded', objectKey: 'object-key-canvas', assetObjectId: 'asset-object-canvas', }); await waitFor(() => { expect(screen.getByTestId('assets').textContent).toContain( 'asset-persisted-canvas:画布素材.png:project:ready:-', ); expect(screen.getByTestId('layers').textContent).toContain( 'layer-upload-1:画布素材.png:asset-persisted-canvas:-160:-107.5', ); }); }); it('marks upload cards as failed and reopens login on auth errors returned by asset creation', async () => { const openEditorLoginModal = vi.fn(); createEditorAssetMock.mockRejectedValueOnce( new ApiClientError({ message: '未授权访问', status: 401, code: 'UNAUTHORIZED', }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '上传素材' })); await waitFor(() => { expect(openEditorLoginModal).toHaveBeenCalledTimes(1); expect(screen.getByTestId('assets').textContent).toContain( 'upload-1:上传素材.png:project:failed:请先登录', ); }); }); it('dispatches file input uploads to generation references and resets failed state', async () => { render(); fireEvent.click(screen.getByRole('button', { name: '准备角色生成' })); fireEvent.click(screen.getByRole('button', { name: '选择角色规范' })); await waitFor(() => { expect(screen.getByTestId('dialog').textContent).toBe( 'character:failed:-:0:-:-', ); }); fireEvent.change(screen.getByLabelText('上传图片文件'), { target: { files: [createTestFile('角色规范.png')], }, }); await waitFor(() => { expect(screen.getByTestId('dialog').textContent).toContain( 'character:idle:角色规范.png:0:-:-', ); }); expect(createEditorAssetMock).not.toHaveBeenCalled(); }); it('dispatches file input uploads to spec reference images', async () => { render(); fireEvent.click(screen.getByRole('button', { name: '准备规范生成' })); fireEvent.click(screen.getByRole('button', { name: '选择规范参考图' })); fireEvent.change(screen.getByLabelText('上传图片文件'), { target: { files: [createTestFile('UI参考.png')], }, }); await waitFor(() => { expect(screen.getByTestId('dialog').textContent).toContain( 'spec:idle:-:0:-:UI参考.png', ); }); expect(createEditorAssetMock).not.toHaveBeenCalled(); }); });