/* @vitest-environment jsdom */ import { act, render, screen, waitFor } from '@testing-library/react'; import { useRef } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiClientError } from '../../services/apiClient'; import type { EditorAssetSnapshot } from '../../services/image-editor/editorProjectClient'; import type { EditorAsset } from './ImageCanvasEditorTypes'; import { useImageCanvasAssetLibrary } from './useImageCanvasAssetLibrary'; const createEditorAssetFolderMock = vi.hoisted(() => vi.fn()); const deleteEditorAssetMock = vi.hoisted(() => vi.fn()); const deleteEditorAssetFolderMock = vi.hoisted(() => vi.fn()); const loadEditorAssetLibraryMock = vi.hoisted(() => vi.fn()); const updateEditorAssetMock = vi.hoisted(() => vi.fn()); const updateEditorAssetFolderMock = vi.hoisted(() => vi.fn()); const defaultOpenEditorLoginModal = () => {}; const defaultOnDeleteAssets = () => {}; vi.mock('../../services/image-editor/editorProjectClient', async () => { const actual = await vi.importActual< typeof import('../../services/image-editor/editorProjectClient') >('../../services/image-editor/editorProjectClient'); return { ...actual, createEditorAssetFolder: createEditorAssetFolderMock, deleteEditorAsset: deleteEditorAssetMock, deleteEditorAssetFolder: deleteEditorAssetFolderMock, loadEditorAssetLibrary: loadEditorAssetLibraryMock, updateEditorAsset: updateEditorAssetMock, updateEditorAssetFolder: updateEditorAssetFolderMock, }; }); function createUploadedAsset(overrides: Partial = {}): EditorAsset { return { id: 'asset-a', label: '素材A', src: 'data:image/png;base64,YQ==', width: 320, height: 240, folderId: 'project', sourceKind: 'uploaded', sourceType: 'uploaded', persisted: true, ...overrides, }; } function createAssetSnapshot( overrides: Partial = {}, ): EditorAssetSnapshot { return { assetId: 'asset-a', label: '素材A', imageSrc: 'data:image/png;base64,YQ==', width: 320, height: 240, folderId: 'project', sourceType: 'uploaded', ...overrides, }; } function AssetLibraryHarness({ canAccessProtectedData = true, openEditorLoginModal = defaultOpenEditorLoginModal, onDeleteAssets = defaultOnDeleteAssets, }: { canAccessProtectedData?: boolean; openEditorLoginModal?: (postLoginAction?: (() => void) | null) => void; onDeleteAssets?: (assets: EditorAsset[]) => void; }) { const assetListRef = useRef(null); const assetLibrary = useImageCanvasAssetLibrary({ assetListRef, canAccessProtectedData, openEditorLoginModal, onDeleteAssets, }); return (
{assetLibrary.groupedAssets.map((folder) => (

{folder.label}

{folder.assets.map((asset, index) => ( ))}
))}
{assetLibrary.assetFolders .map((folder) => `${folder.id}:${folder.label}:${folder.persisted}`) .join('|')} {assetLibrary.assets .map((asset) => `${asset.id}:${asset.label}:${asset.folderId}`) .join('|')} {assetLibrary.activeUploadFolderId} {String(assetLibrary.allSelectableAssetsSelected)} {[...assetLibrary.selectedAssetIds].join('|')} {assetLibrary.newFolderName}
); } describe('useImageCanvasAssetLibrary', () => { beforeEach(() => { vi.clearAllMocks(); loadEditorAssetLibraryMock.mockResolvedValue({ folders: [ { folderId: 'project', label: '项目素材', sortOrder: 0, collapsed: false, systemDefault: true, }, ], assets: [createAssetSnapshot()], }); createEditorAssetFolderMock.mockResolvedValue({ folderId: 'folder-role', label: '角色素材', collapsed: false, systemDefault: false, }); updateEditorAssetMock.mockResolvedValue(createAssetSnapshot()); deleteEditorAssetMock.mockResolvedValue(createAssetSnapshot()); deleteEditorAssetFolderMock.mockResolvedValue({ folders: [ { folderId: 'project', label: '项目素材', sortOrder: 0, collapsed: false, systemDefault: true, }, ], assets: [], }); updateEditorAssetFolderMock.mockResolvedValue({ folderId: 'project', label: '项目素材', collapsed: false, systemDefault: true, }); }); it('loads and normalizes the account asset library', async () => { loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [ { folderId: 'project', label: '项目素材', sortOrder: 0, collapsed: false, systemDefault: true, }, { folderId: 'project-duplicate', label: '重复默认', sortOrder: 1, collapsed: false, systemDefault: true, }, ], assets: [createAssetSnapshot()], }); render(); await waitFor(() => { expect(screen.getByTestId('folders').textContent).toBe( 'project:项目素材:true', ); }); expect(screen.getByTestId('assets').textContent).toBe( 'asset-a:素材A:project', ); expect(screen.getByTestId('active-upload-folder').textContent).toBe( 'project', ); }); it('opens login when loading the asset library is unauthorized', async () => { const openEditorLoginModal = vi.fn(); loadEditorAssetLibraryMock.mockRejectedValueOnce( new ApiClientError({ message: '未授权访问', status: 401, code: 'UNAUTHORIZED', }), ); render( , ); await waitFor(() => { expect(openEditorLoginModal).toHaveBeenCalledTimes(1); }); }); it('does not request the protected asset library before login is available', () => { render(); expect(loadEditorAssetLibraryMock).not.toHaveBeenCalled(); expect(screen.getByTestId('folders').textContent).toBe( 'project:项目素材:false', ); }); it('creates a local folder and replaces it with the persisted folder id', async () => { let resolveCreateFolder: ( folder: Awaited>, ) => void = () => {}; createEditorAssetFolderMock.mockReturnValueOnce( new Promise((resolve) => { resolveCreateFolder = resolve; }), ); render(); await screen.findByText('素材A'); act(() => screen.getByRole('button', { name: 'prepare folder' }).click()); await waitFor(() => { expect(screen.getByTestId('new-folder-name').textContent).toBe( '角色素材', ); }); act(() => screen.getByRole('button', { name: 'commit folder' }).click()); await waitFor(() => { expect(screen.getByTestId('folders').textContent).toContain( 'folder-', ); }); act(() => { resolveCreateFolder({ folderId: 'folder-role', label: '角色素材', collapsed: false, systemDefault: false, }); }); await waitFor(() => { expect(screen.getByTestId('folders').textContent).toContain( 'folder-role:角色素材:true', ); }); expect(createEditorAssetFolderMock).toHaveBeenCalledWith('角色素材', 101); expect(screen.getByTestId('active-upload-folder').textContent).toBe( 'folder-role', ); }); it('moves a persisted asset to another folder', async () => { loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [ { folderId: 'project', label: '项目素材', sortOrder: 0, collapsed: false, systemDefault: true, }, { folderId: 'folder-role', label: '角色素材', sortOrder: 1, collapsed: false, systemDefault: false, }, ], assets: [createAssetSnapshot()], }); render(); await screen.findByText('素材A'); act(() => screen.getByRole('button', { name: 'move asset' }).click()); expect(screen.getByTestId('assets').textContent).toBe( 'asset-a:素材A:folder-role', ); expect(updateEditorAssetMock).toHaveBeenCalledWith('asset-a', { folderId: 'folder-role', }); }); it('deletes uploaded assets and reports them to the canvas cleanup callback', async () => { const onDeleteAssets = vi.fn(); render(); await screen.findByText('素材A'); act(() => screen.getByRole('button', { name: 'delete asset' }).click()); expect(screen.getByTestId('assets').textContent).toBe(''); expect(onDeleteAssets).toHaveBeenCalledWith([ expect.objectContaining({ id: 'asset-a' }), ]); expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-a'); }); it('selects and deletes selected uploaded assets', async () => { const onDeleteAssets = vi.fn(); loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [ { folderId: 'project', label: '项目素材', sortOrder: 0, collapsed: false, systemDefault: true, }, ], assets: [ createAssetSnapshot({ assetId: 'asset-a', label: '素材A' }), createAssetSnapshot({ assetId: 'asset-b', label: '素材B' }), ], }); render(); await screen.findByText('素材A'); act(() => screen.getByRole('button', { name: 'toggle all' }).click()); expect(screen.getByTestId('all-selected').textContent).toBe('true'); act(() => screen.getByRole('button', { name: '素材B' }).click()); await waitFor(() => { expect(screen.getByTestId('selected-assets').textContent).toBe('asset-a'); }); act(() => screen.getByRole('button', { name: 'delete selected' }).click()); expect(screen.getByTestId('assets').textContent).toBe( 'asset-b:素材B:project', ); expect(onDeleteAssets).toHaveBeenCalledWith([ expect.objectContaining({ id: 'asset-a' }), ]); expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-a'); expect(deleteEditorAssetMock).not.toHaveBeenCalledWith('asset-b'); }); });