/* @vitest-environment jsdom */ import { act, fireEvent, render as testingLibraryRender, screen, waitFor, within, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiClientError, AuthUiContext, createAuthValue, createDataTransferStub, createDeferred, dispatchPointerEvent, ImageCanvasEditorView, setupImageCanvasEditorViewTestLifecycle, } from './ImageCanvasEditorView.test-utils'; function render(ui: Parameters[0]) { const result = testingLibraryRender(ui); fireEvent.click(screen.getByRole('button', { name: '打开素材' })); return result; } const generateEditorImageMock = vi.hoisted(() => vi.fn()); const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn()); const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn()); const editEditorImageMock = vi.hoisted(() => vi.fn()); const createEditorAssetMock = vi.hoisted(() => vi.fn()); const createEditorProjectResourceMock = vi.hoisted(() => vi.fn()); const createEditorAssetFolderMock = vi.hoisted(() => vi.fn()); const updateEditorAssetMock = vi.hoisted(() => vi.fn()); const updateEditorAssetFolderMock = vi.hoisted(() => vi.fn()); const deleteEditorAssetFolderMock = vi.hoisted(() => vi.fn()); const deleteEditorAssetMock = vi.hoisted(() => vi.fn()); const loadEditorAssetLibraryMock = vi.hoisted(() => vi.fn()); const loadEditorProjectMock = vi.hoisted(() => vi.fn()); const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn()); const renameEditorProjectMock = vi.hoisted(() => vi.fn()); const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn()); const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn()); const getSignedAssetReadUrlMock = 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, editEditorImage: editEditorImageMock, createEditorAsset: createEditorAssetMock, createEditorAssetFolder: createEditorAssetFolderMock, createEditorProjectResource: createEditorProjectResourceMock, deleteEditorAsset: deleteEditorAssetMock, deleteEditorAssetFolder: deleteEditorAssetFolderMock, generateEditorCharacterAnimation: generateEditorCharacterAnimationMock, generateEditorIconSpritesheet: generateEditorIconSpritesheetMock, generateEditorImage: generateEditorImageMock, loadEditorAssetLibrary: loadEditorAssetLibraryMock, loadEditorProject: loadEditorProjectMock, loadOrCreateRecentEditorProject: loadOrCreateRecentEditorProjectMock, renameEditorProject: renameEditorProjectMock, saveEditorProjectLayout: saveEditorProjectLayoutMock, updateEditorAsset: updateEditorAssetMock, updateEditorAssetFolder: updateEditorAssetFolderMock, }; }); vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({ uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock, })); vi.mock('../../services/assetReadUrlService', async () => { const actual = await vi.importActual< typeof import('../../services/assetReadUrlService') >('../../services/assetReadUrlService'); return { ...actual, getSignedAssetReadUrl: ( ...args: Parameters ) => getSignedAssetReadUrlMock(...args) ?? Promise.resolve( `https://signed.example.com/${args[0]?.objectKey ?? 'asset'}`, ), }; }); function createSizedPngFile(name: string, width: number, height: number) { const bytes = new Uint8Array([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52, (width >> 24) & 0xff, (width >> 16) & 0xff, (width >> 8) & 0xff, width & 0xff, (height >> 24) & 0xff, (height >> 16) & 0xff, (height >> 8) & 0xff, height & 0xff, 8, 6, 0, 0, 0, 0, 0, 0, 0, ]); return new File([bytes], name, { type: 'image/png' }); } function createTestImageAsset({ assetId, label, imageSrc, objectKey, assetObjectId, sourceType = 'uploaded', sourceResourceId, }: { assetId: string; label: string; imageSrc?: string; objectKey?: string | null; assetObjectId?: string | null; sourceType?: 'uploaded' | 'generated' | 'mock_generated'; sourceResourceId?: string | null; }) { const resolvedObjectKey = objectKey === undefined ? `generated-character-drafts/editor/asset-library/image/${assetId}.png` : objectKey; return { assetId, folderId: 'project', label, imageSrc: imageSrc ?? (resolvedObjectKey ? `/${resolvedObjectKey}` : ''), objectKey: resolvedObjectKey, assetObjectId: assetObjectId === undefined ? `assetobj-${assetId}` : assetObjectId, width: 320, height: 240, sourceType, sourceResourceId, }; } describe('ImageCanvasEditorView asset library integration', () => { setupImageCanvasEditorViewTestLifecycle({ generateEditorImageMock, generateEditorIconSpritesheetMock, generateEditorCharacterAnimationMock, editEditorImageMock, createEditorAssetMock, createEditorProjectResourceMock, createEditorAssetFolderMock, updateEditorAssetMock, updateEditorAssetFolderMock, deleteEditorAssetFolderMock, deleteEditorAssetMock, loadEditorAssetLibraryMock, loadEditorProjectMock, loadOrCreateRecentEditorProjectMock, renameEditorProjectMock, saveEditorProjectLayoutMock, }); beforeEach(() => { getSignedAssetReadUrlMock.mockImplementation( async ({ objectKey }: { objectKey?: string }) => `https://signed.example.com/${objectKey ?? 'asset'}`, ); uploadEditorMediaAssetFileMock.mockImplementation( async (file: File, mediaType: 'image' | 'video' | 'audio') => ({ src: `https://signed.example.com/editor/${mediaType}/${file.name}`, legacyPublicPath: `/generated-character-drafts/editor/asset-library/${mediaType}/${file.name}`, objectKey: `generated-character-drafts/editor/asset-library/${mediaType}/${file.name}`, assetObjectId: `assetobj-editor-${mediaType}`, }), ); }); it('keeps only one default asset folder when the persisted library returns duplicated defaults', async () => { loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [ { folderId: 'project', label: '项目素材', sortOrder: 0, collapsed: false, systemDefault: true, }, { folderId: 'legacy-project', label: '旧项目素材', sortOrder: 1, collapsed: false, systemDefault: true, }, ], assets: [], }); render(); expect( await screen.findByRole('region', { name: '项目素材' }), ).toBeTruthy(); expect(screen.queryByRole('region', { name: '旧项目素材' })).toBeNull(); expect(screen.getAllByRole('button', { name: /上传到/u })).toHaveLength(1); }); it('toggles the shared sidebar from canvas panel buttons', () => { render(); const sidebar = screen.getByRole('complementary', { name: '图片资源栏' }); const panelToolbar = screen.getByRole('toolbar', { name: '画布面板入口' }); const assetsButton = within(panelToolbar).getByRole('button', { name: '打开素材', }); const layersButton = within(panelToolbar).getByRole('button', { name: '打开图层', }); expect(within(sidebar).getByText('素材')).toBeTruthy(); expect( within(sidebar).getByRole('button', { name: '添加拼图素材' }), ).toBeTruthy(); expect(assetsButton.getAttribute('aria-pressed')).toBe('true'); expect(screen.queryByRole('button', { name: '打开已生成文件' })).toBeNull(); expect(screen.queryByRole('button', { name: '收起素材栏' })).toBeNull(); expect(screen.queryByRole('button', { name: '展开素材栏' })).toBeNull(); fireEvent.click(layersButton); const layerSidebar = screen.getByRole('complementary', { name: '图片资源栏', }); expect(within(layerSidebar).getByText('图层')).toBeTruthy(); expect( within(layerSidebar).getByRole('button', { name: '选择图层拼图素材' }), ).toBeTruthy(); expect(layersButton.getAttribute('aria-pressed')).toBe('true'); expect(screen.queryByRole('button', { name: '添加拼图素材' })).toBeNull(); fireEvent.click(layersButton); expect( screen.queryByRole('complementary', { name: '图片资源栏' }), ).toBeNull(); expect(layersButton.getAttribute('aria-pressed')).toBe('false'); }); it('groups assets by folder and renames sidebar materials', async () => { const user = userEvent.setup(); render(); const sidebar = screen.getByRole('complementary', { name: '图片资源栏' }); expect( within(sidebar).getByRole('region', { name: '项目素材' }), ).toBeTruthy(); expect( within(sidebar).queryByRole('region', { name: '参考素材' }), ).toBeNull(); await user.click( screen.getByRole('button', { name: '重命名素材拼图素材' }), ); const renameInput = screen.getByLabelText('重命名素材拼图素材'); await user.clear(renameInput); await user.type(renameInput, '主视觉素材'); await user.click( screen.getByRole('button', { name: '保存素材拼图素材名称' }), ); expect(screen.queryByRole('button', { name: '添加拼图素材' })).toBeNull(); await user.click(screen.getByRole('button', { name: '添加主视觉素材' })); expect(screen.getByAltText('画布图片:主视觉素材')).toBeTruthy(); }); it('downloads an asset directly from the asset library', async () => { const originalFetch = globalThis.fetch; globalThis.fetch = vi.fn(async (url: string) => { if (url === '/creation-type-references/puzzle.webp') { return new Response(new Blob(['asset'], { type: 'image/webp' })); } return new Response(null, { status: 404 }); }) as typeof fetch; let downloadName = ''; let downloadHref = ''; Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:asset-library-download'), }); Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn(), }); vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation( function click(this: HTMLAnchorElement) { downloadName = this.download; downloadHref = this.href; }, ); try { render(); fireEvent.click( await screen.findByRole('button', { name: '下载素材拼图素材' }), ); await waitFor(() => { expect(downloadName).toMatch(/^拼图素材-\d{8}-\d{6}\.webp$/u); }); expect(downloadHref).toBe('blob:asset-library-download'); } finally { globalThis.fetch = originalFetch; } }); it('collapses folders, creates upload folders, and deletes uploaded materials', async () => { const user = userEvent.setup(); const createObjectUrlSpy = vi.fn(() => 'blob:folder-uploaded-image'); Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: createObjectUrlSpy, }); render(); await user.click(screen.getByRole('button', { name: '折叠项目素材' })); expect(screen.queryByRole('button', { name: '添加拼图素材' })).toBeNull(); await user.click(screen.getByRole('button', { name: '展开项目素材' })); expect(screen.getByRole('button', { name: '添加拼图素材' })).toBeTruthy(); await user.click(screen.getByRole('button', { name: '新建素材文件夹' })); const folderNameInput = screen.getByLabelText('素材文件夹名称'); await user.type(folderNameInput, '角色上传'); await user.click(screen.getByRole('button', { name: '保存素材文件夹' })); const uploadInput = screen.getByLabelText('上传媒体文件'); await user.click(screen.getByRole('button', { name: '上传到角色上传' })); await userEvent.upload( uploadInput, new File(['image'], '角色草图.png', { type: 'image/png' }), ); const customFolder = screen.getByRole('region', { name: '角色上传' }); await waitFor(() => { expect( within(customFolder).getByRole('button', { name: '添加角色草图.png' }), ).toBeTruthy(); }); const uploadedAssetButton = within(customFolder).getByRole('button', { name: '添加角色草图.png', }); const uploadedAssetRow = uploadedAssetButton.closest( '[data-asset-id="persisted-角色草图.png"]', ); if (!uploadedAssetRow) { throw new Error('uploaded asset row should exist'); } fireEvent.contextMenu(uploadedAssetRow, { clientX: 120, clientY: 180, }); await user.click(screen.getByRole('menuitem', { name: '删除' })); expect( screen.queryByRole('button', { name: '添加角色草图.png' }), ).toBeNull(); expect(screen.queryByAltText('画布图片:角色草图.png')).toBeNull(); }); it('renames and deletes asset folders through the persisted asset library API', async () => { const user = userEvent.setup(); loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [ { folderId: 'project', label: '项目素材', sortOrder: 0, collapsed: false, systemDefault: true, }, { folderId: 'folder-role', label: '角色', sortOrder: 100, collapsed: false, systemDefault: false, }, ], assets: [], }); render(); await screen.findByRole('region', { name: '角色' }); await user.click(screen.getByRole('button', { name: '重命名文件夹角色' })); const folderRenameInput = screen.getByLabelText('重命名文件夹角色'); await user.clear(folderRenameInput); await user.type(folderRenameInput, '角色参考'); await user.click( screen.getByRole('button', { name: '保存文件夹角色名称' }), ); expect(updateEditorAssetFolderMock).toHaveBeenCalledWith('folder-role', { label: '角色参考', }); await user.click( screen.getByRole('button', { name: '删除文件夹角色参考' }), ); expect(deleteEditorAssetFolderMock).toHaveBeenCalledWith('folder-role'); }); it('moves an asset to another folder when dragging inside the asset library', async () => { loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [ { folderId: 'project', label: '项目素材', sortOrder: 0, collapsed: false, systemDefault: true, }, { folderId: 'folder-role', label: '角色', sortOrder: 100, collapsed: false, systemDefault: false, }, ], assets: [ { assetId: 'asset-puzzle', folderId: 'project', label: '拼图素材', imageSrc: '/creation-type-references/puzzle.webp', width: 640, height: 640, sourceType: 'uploaded', }, ], }); render(); const sourceAsset = await screen.findByRole('button', { name: '添加拼图素材', }); const sourceAssetRow = sourceAsset.closest( '.image-canvas-editor__asset-row', ); const projectFolder = screen.getByRole('region', { name: '项目素材' }); const roleFolder = screen.getByRole('region', { name: '角色' }); const dataTransfer = createDataTransferStub(); if (!sourceAssetRow) { throw new Error('asset row should exist'); } fireEvent.dragStart(sourceAssetRow, { dataTransfer }); fireEvent.dragOver(roleFolder, { dataTransfer }); await waitFor(() => { expect(screen.queryByText('添加到素材')).toBeNull(); expect(roleFolder.className).toContain( 'image-canvas-editor__asset-folder--move-target', ); }); fireEvent.drop(roleFolder, { dataTransfer }); expect(updateEditorAssetMock).toHaveBeenCalledWith('asset-puzzle', { folderId: 'folder-role', }); expect( within(projectFolder).queryByRole('button', { name: '添加拼图素材' }), ).toBeNull(); expect( within(roleFolder).getByRole('button', { name: '添加拼图素材' }), ).toBeTruthy(); expect(createEditorAssetMock).not.toHaveBeenCalled(); }); it('uploads multiple files as account-level assets without adding canvas layers', async () => { render(); await userEvent.upload(screen.getByLabelText('上传媒体文件'), [ new File(['image-a'], '第一张.png', { type: 'image/png' }), new File(['image-b'], '第二张.png', { type: 'image/png' }), ]); await waitFor(() => { expect( screen.getByRole('button', { name: '添加第一张.png' }), ).toBeTruthy(); expect( screen.getByRole('button', { name: '添加第二张.png' }), ).toBeTruthy(); }); expect(createEditorAssetMock).toHaveBeenCalledTimes(2); expect(screen.queryByAltText('画布图片:第一张.png')).toBeNull(); expect(screen.queryByAltText('画布图片:第二张.png')).toBeNull(); }); it('adds bottom toolbar uploads to the canvas using the image resolution', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: '上传工具' })); await userEvent.upload( screen.getByLabelText('上传媒体文件'), createSizedPngFile('底栏上传.png', 1536, 1024), ); expect(await screen.findByAltText('画布图片:底栏上传.png')).toBeTruthy(); await waitFor(() => { expect(createEditorAssetMock).toHaveBeenCalledWith( expect.objectContaining({ label: '底栏上传.png', width: 1536, height: 1024, }), ); }); const layerButton = screen.getByRole('button', { name: '选择底栏上传.png', }); expect(layerButton.getAttribute('style')).toContain('width: 1536px'); expect(layerButton.getAttribute('style')).toContain('height: 1024px'); }); it('uploads MP3 and MP4 files through OSS before adding them to the asset library', async () => { const createdMediaElements: HTMLMediaElement[] = []; const originalCreateElement = document.createElement.bind(document); vi.spyOn(document, 'createElement').mockImplementation( (tagName, options) => { const element = originalCreateElement(tagName, options); if (tagName === 'video' || tagName === 'audio') { createdMediaElements.push(element as HTMLMediaElement); } return element; }, ); Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:editor-media'), }); Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn(), }); Object.defineProperty(HTMLMediaElement.prototype, 'duration', { configurable: true, value: 6, }); vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation( () => undefined, ); render(); await userEvent.upload(screen.getByLabelText('上传媒体文件'), [ new File(['audio'], '胜利音效.mp3', { type: 'audio/mpeg' }), new File(['video'], '开场动画.mp4', { type: 'video/mp4' }), ]); act(() => { createdMediaElements.forEach((element) => element.onloadedmetadata?.(new Event('loadedmetadata')), ); }); await waitFor(() => { expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith( expect.objectContaining({ name: '胜利音效.mp3' }), 'audio', ); expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith( expect.objectContaining({ name: '开场动画.mp4' }), 'video', ); }); await waitFor(() => { expect( screen.getByRole('button', { name: '添加胜利音效.mp3' }), ).toBeTruthy(); expect( screen.getByRole('button', { name: '添加开场动画.mp4' }), ).toBeTruthy(); }); expect(createEditorAssetMock).toHaveBeenCalledWith( expect.objectContaining({ label: '胜利音效.mp3', imageSrc: '/generated-character-drafts/editor/asset-library/audio/胜利音效.mp3', objectKey: 'generated-character-drafts/editor/asset-library/audio/胜利音效.mp3', assetObjectId: 'assetobj-editor-audio', }), ); expect(createEditorAssetMock).toHaveBeenCalledWith( expect.objectContaining({ label: '开场动画.mp4', imageSrc: '/generated-character-drafts/editor/asset-library/video/开场动画.mp4', objectKey: 'generated-character-drafts/editor/asset-library/video/开场动画.mp4', assetObjectId: 'assetobj-editor-video', }), ); }); it('opens login before uploading assets while logged out and resumes after login', async () => { const openLoginModal = vi.fn(); const authValue = createAuthValue({ openLoginModal }); const { rerender } = render( , ); await userEvent.upload(screen.getByLabelText('上传媒体文件'), [ new File(['image'], '登录后上传.png', { type: 'image/png' }), ]); expect(openLoginModal).toHaveBeenCalled(); expect(createEditorAssetMock).not.toHaveBeenCalled(); expect( screen.queryByRole('button', { name: '上传失败登录后上传.png' }), ).toBeNull(); const resumeUpload = openLoginModal.mock.calls[openLoginModal.mock.calls.length - 1]?.[0]; expect(typeof resumeUpload).toBe('function'); rerender( , ); act(() => { (resumeUpload as () => void)(); }); await waitFor(() => { expect(createEditorAssetMock).toHaveBeenCalledTimes(1); }); }); it('shows an uploading placeholder card before restoring the normal asset card', async () => { const deferredAsset = createDeferred<{ assetId: string; folderId: string; label: string; imageSrc: string; objectKey?: string | null; assetObjectId?: string | null; width: number; height: number; sourceType: 'uploaded'; }>(); createEditorAssetMock.mockReturnValueOnce(deferredAsset.promise); render(); await userEvent.upload(screen.getByLabelText('上传媒体文件'), [ new File(['image'], '素材上传进度.png', { type: 'image/png' }), ]); expect( await screen.findByLabelText('素材素材上传进度.png上传进度'), ).toBeTruthy(); expect( screen.getByRole('button', { name: '上传中素材上传进度.png' }), ).toBeTruthy(); deferredAsset.resolve({ assetId: 'asset-upload-progress', folderId: 'project', label: '素材上传进度.png', imageSrc: '/generated-character-drafts/editor/asset-library/image/素材上传进度.png', objectKey: 'generated-character-drafts/editor/asset-library/image/素材上传进度.png', assetObjectId: 'assetobj-editor-image', width: 420, height: 315, sourceType: 'uploaded', }); await waitFor(() => { expect( screen.getByRole('button', { name: '添加素材上传进度.png' }), ).toBeTruthy(); }); expect(screen.queryByLabelText('素材素材上传进度.png上传进度')).toBeNull(); }); it('opens login when asset creation returns unauthorized during upload', async () => { const openLoginModal = vi.fn(); createEditorAssetMock.mockRejectedValueOnce( new ApiClientError({ message: '未授权访问', status: 401, code: 'UNAUTHORIZED', }), ); render( , ); await userEvent.upload(screen.getByLabelText('上传媒体文件'), [ new File(['image'], '过期登录.png', { type: 'image/png' }), ]); await waitFor(() => { expect(openLoginModal).toHaveBeenCalledTimes(1); }); expect(screen.getByText('请先登录')).toBeTruthy(); }); it('supports asset selection mode and batch delete with shared toolbar', async () => { const user = userEvent.setup(); loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [ { folderId: 'project', label: '项目素材', sortOrder: 0, collapsed: false, systemDefault: true, }, ], assets: [ createTestImageAsset({ assetId: 'asset-a', label: '账号素材A' }), createTestImageAsset({ assetId: 'asset-b', label: '账号素材B' }), ], }); render(); await screen.findByRole('button', { name: '添加账号素材A' }); await user.click(screen.getByRole('button', { name: '素材选择模式' })); await user.click(screen.getByRole('button', { name: '选择素材账号素材A' })); const batchToolbar = screen.getByRole('toolbar', { name: '素材批量操作' }); expect(within(batchToolbar).getByText(/已选 1/u)).toBeTruthy(); await user.click( within(batchToolbar).getByRole('button', { name: '删除' }), ); expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-a'); expect( screen.queryByRole('button', { name: '选择素材账号素材A' }), ).toBeNull(); }); it('supports visible range and additive selection and clears state on exit', async () => { const user = userEvent.setup(); loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [ { folderId: 'project', label: '项目素材', sortOrder: 0, collapsed: false, systemDefault: true, }, ], assets: [ createTestImageAsset({ assetId: 'asset-a', label: '账号素材A' }), createTestImageAsset({ assetId: 'asset-b', label: '账号素材B' }), createTestImageAsset({ assetId: 'asset-c', label: '账号素材C' }), ], }); render(); await screen.findByRole('button', { name: '添加账号素材A' }); await user.click(screen.getByRole('button', { name: '素材选择模式' })); fireEvent.click(screen.getByText('账号素材A')); fireEvent.click(screen.getByText('账号素材C'), { ctrlKey: true, }); expect( within(screen.getByRole('toolbar', { name: '素材批量操作' })).getByText( /已选 2/u, ), ).toBeTruthy(); fireEvent.click(screen.getByText('账号素材B'), { shiftKey: true, }); expect( within(screen.getByRole('toolbar', { name: '素材批量操作' })).getByText( /已选 2/u, ), ).toBeTruthy(); fireEvent.click(screen.getByText('账号素材A'), { ctrlKey: true, shiftKey: true, }); expect( within(screen.getByRole('toolbar', { name: '素材批量操作' })).getByText( /已选 3/u, ), ).toBeTruthy(); await user.click(screen.getByRole('button', { name: '素材选择模式' })); expect(screen.queryByRole('toolbar', { name: '素材批量操作' })).toBeNull(); expect(screen.getByRole('button', { name: '添加账号素材A' })).toBeTruthy(); await user.click(screen.getByRole('button', { name: '素材选择模式' })); expect( within(screen.getByRole('toolbar', { name: '素材批量操作' })).getByRole( 'button', { name: '删除' }, ), ).toHaveProperty('disabled', true); }); it('removes canvas layers linked to deleted assets', async () => { const user = userEvent.setup(); loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [ { folderId: 'project', label: '项目素材', sortOrder: 0, collapsed: false, systemDefault: true, }, ], assets: [ createTestImageAsset({ assetId: 'asset-a', label: '账号素材A' }), createTestImageAsset({ assetId: 'asset-b', label: '账号素材B' }), ], }); render(); await user.click( await screen.findByRole('button', { name: '添加账号素材A' }), ); expect(await screen.findByAltText('画布图片:账号素材A')).toBeTruthy(); await user.click(screen.getByRole('button', { name: '添加账号素材B' })); expect(await screen.findByAltText('画布图片:账号素材B')).toBeTruthy(); fireEvent.keyDown(window, { key: 'ArrowRight', code: 'ArrowRight' }); expect(screen.getByRole('button', { name: '撤销' })).toHaveProperty( 'disabled', false, ); await user.click(screen.getByRole('button', { name: '素材选择模式' })); await user.click( within(screen.getByRole('toolbar', { name: '素材批量操作' })).getByRole( 'button', { name: '全选' }, ), ); await waitFor(() => { expect( within(screen.getByRole('toolbar', { name: '素材批量操作' })).getByText( /已选 2/u, ), ).toBeTruthy(); }); await user.click( within(screen.getByRole('toolbar', { name: '素材批量操作' })).getByRole( 'button', { name: '删除' }, ), ); await waitFor(() => { expect(screen.queryByAltText('画布图片:账号素材A')).toBeNull(); expect(screen.queryByAltText('画布图片:账号素材B')).toBeNull(); }); expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-a'); expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-b'); fireEvent.keyDown(window, { key: 'z', code: 'KeyZ', ctrlKey: true }); expect(screen.queryByAltText('画布图片:账号素材A')).toBeNull(); expect(screen.queryByAltText('画布图片:账号素材B')).toBeNull(); }); it('saves the canvas layout after deleting an asset linked by source resource id', async () => { const user = userEvent.setup(); loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-default', title: '生成资源项目', viewport: { x: 0, y: 0, scale: 1 }, layers: [ { layerId: 'layer-generated-a', resourceId: 'resource-generated-a', title: '生成资源A', x: 100, y: 120, width: 320, height: 240, originalWidth: 320, originalHeight: 240, zIndex: 1, sourceType: 'generated', }, ], resources: [ { resourceId: 'resource-generated-a', projectId: 'editor-project-default', imageSrc: '/editor-test/project/generated-a.png', width: 320, height: 240, sourceType: 'generated', }, ], updatedAt: '2026-06-12T00:00:00.000Z', }); loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [ { folderId: 'project', label: '项目素材', sortOrder: 0, collapsed: false, systemDefault: true, }, ], assets: [ createTestImageAsset({ assetId: 'asset-generated-a', label: '生成资源A', imageSrc: '/editor-test/asset-library/copied-a.png', objectKey: null, assetObjectId: null, sourceType: 'generated', sourceResourceId: 'resource-generated-a', }), ], }); render(); expect(await screen.findByAltText('画布图片:生成资源A')).toBeTruthy(); const generatedAssetButton = screen.getByRole('button', { name: '添加生成资源A', }); const generatedAssetRow = generatedAssetButton.closest( '[data-asset-id="asset-generated-a"]', ); if (!generatedAssetRow) { throw new Error('generated asset row should exist'); } fireEvent.contextMenu(generatedAssetRow, { clientX: 120, clientY: 180, }); await user.click(screen.getByRole('menuitem', { name: '删除' })); await waitFor(() => { expect(screen.queryByAltText('画布图片:生成资源A')).toBeNull(); }); await waitFor(() => { const savedLayoutWithoutDeletedLayer = saveEditorProjectLayoutMock.mock.calls .map((call) => call[1]) .find((layout) => layout.layers.every( (item: { layerId?: unknown }) => item.layerId !== 'layer-generated-a', ), ); expect(savedLayoutWithoutDeletedLayer).toBeTruthy(); }); expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-generated-a'); }); it('saves a library asset layer right after creating its canvas resource', async () => { const user = userEvent.setup(); createEditorProjectResourceMock.mockResolvedValueOnce({ resourceId: 'resource-added-asset-a', projectId: 'editor-project-default', imageSrc: '/generated-character-drafts/editor/asset-library/image/asset-a.png', objectKey: 'generated-character-drafts/editor/asset-library/image/asset-a.png', assetObjectId: 'assetobj-asset-a', width: 320, height: 240, sourceType: 'uploaded', }); loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-default', title: '空画布项目', viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], updatedAt: '2026-06-12T00:00:00.000Z', }); loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [ { folderId: 'project', label: '项目素材', sortOrder: 0, collapsed: false, systemDefault: true, }, ], assets: [ createTestImageAsset({ assetId: 'asset-a', label: '账号素材A' }), ], }); render(); await user.click( await screen.findByRole('button', { name: '添加账号素材A' }), ); expect(await screen.findByAltText('画布图片:账号素材A')).toBeTruthy(); await waitFor(() => { expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( 'editor-project-default', expect.objectContaining({ layers: expect.arrayContaining([ expect.objectContaining({ title: '账号素材A', resourceId: 'resource-added-asset-a', sourceAssetId: 'asset-a', }), ]), }), ); }); }); it('selects multiple assets with a marquee in asset selection mode', async () => { const user = userEvent.setup(); loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [ { folderId: 'project', label: '项目素材', sortOrder: 0, collapsed: false, systemDefault: true, }, ], assets: [ createTestImageAsset({ assetId: 'asset-a', label: '账号素材A' }), createTestImageAsset({ assetId: 'asset-b', label: '账号素材B' }), ], }); render(); const firstAssetButton = await screen.findByRole('button', { name: '添加账号素材A', }); const secondAssetButton = screen.getByRole('button', { name: '添加账号素材B', }); const assetList = firstAssetButton.closest( '.image-canvas-editor__asset-list', ) as HTMLElement; vi.spyOn(assetList, 'getBoundingClientRect').mockReturnValue({ x: 0, y: 0, left: 0, top: 0, right: 320, bottom: 600, width: 320, height: 600, toJSON: () => ({}), }); vi.spyOn( firstAssetButton.closest('[data-asset-id]') as HTMLElement, 'getBoundingClientRect', ).mockReturnValue({ x: 16, y: 120, left: 16, top: 120, right: 280, bottom: 200, width: 264, height: 80, toJSON: () => ({}), }); vi.spyOn( secondAssetButton.closest('[data-asset-id]') as HTMLElement, 'getBoundingClientRect', ).mockReturnValue({ x: 16, y: 240, left: 16, top: 240, right: 280, bottom: 320, width: 264, height: 80, toJSON: () => ({}), }); await user.click(screen.getByRole('button', { name: '素材选择模式' })); dispatchPointerEvent(assetList, 'pointerdown', { button: 0, pointerId: 88, clientX: 8, clientY: 100, }); dispatchPointerEvent(assetList, 'pointermove', { button: 0, pointerId: 88, clientX: 300, clientY: 330, }); dispatchPointerEvent(assetList, 'pointerup', { button: 0, pointerId: 88, clientX: 300, clientY: 330, }); const batchToolbar = screen.getByRole('toolbar', { name: '素材批量操作' }); expect(within(batchToolbar).getByText(/已选 2/u)).toBeTruthy(); }); it('drops an image file on the canvas as a new canvas layer', async () => { render(); await waitFor(() => { expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled(); }); const viewport = screen.getByLabelText('画布工作区'); fireEvent.drop(viewport, { clientX: 430, clientY: 260, dataTransfer: { files: [new File(['image'], '测试上传.png', { type: 'image/png' })], types: ['Files'], }, }); await waitFor(() => { expect(screen.getByAltText('画布图片:测试上传.png')).toBeTruthy(); }); expect(createEditorAssetMock).toHaveBeenCalledWith( expect.objectContaining({ label: '测试上传.png', imageSrc: '/generated-character-drafts/editor/asset-library/image/测试上传.png', objectKey: 'generated-character-drafts/editor/asset-library/image/测试上传.png', assetObjectId: 'assetobj-editor-image', }), ); expect(screen.getByRole('heading', { name: '素材' })).toBeTruthy(); expect( screen .getByRole('button', { name: '打开素材' }) .getAttribute('aria-pressed'), ).toBe('true'); expect( screen .getByRole('button', { name: '选择测试上传.png' }) .className.includes('image-canvas-editor__layer--selected'), ).toBe(true); }); it('pastes a clipboard image onto the canvas through the upload workflow', async () => { render(); await waitFor(() => { expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled(); }); const imageFile = new File(['image'], '剪贴板素材.png', { type: 'image/png', }); const pasteEvent = new Event('paste', { bubbles: true, cancelable: true, }); Object.defineProperty(pasteEvent, 'clipboardData', { value: { files: [imageFile], items: [], }, }); window.dispatchEvent(pasteEvent); await waitFor(() => { expect(screen.getByAltText('画布图片:剪贴板素材.png')).toBeTruthy(); }); expect(pasteEvent.defaultPrevented).toBe(true); expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith( imageFile, 'image', ); expect(createEditorAssetMock).toHaveBeenCalledWith( expect.objectContaining({ label: '剪贴板素材.png', imageSrc: '/generated-character-drafts/editor/asset-library/image/剪贴板素材.png', objectKey: 'generated-character-drafts/editor/asset-library/image/剪贴板素材.png', assetObjectId: 'assetobj-editor-image', }), ); expect( screen .getByRole('button', { name: '选择剪贴板素材.png' }) .className.includes('image-canvas-editor__layer--selected'), ).toBe(true); }); it('pastes a system clipboard image from the canvas context menu', async () => { const originalClipboard = navigator.clipboard; const clipboardBlob = new Blob(['image'], { type: 'image/png' }); Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { read: vi.fn(async () => [ { types: ['image/png'], getType: vi.fn(async () => clipboardBlob), }, ]), }, }); try { render(); await waitFor(() => { expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled(); }); fireEvent.contextMenu(screen.getByLabelText('画布工作区'), { clientX: 360, clientY: 240, }); fireEvent.click(screen.getByRole('menuitem', { name: '粘贴' })); await waitFor(() => { expect(screen.getByAltText('画布图片:剪贴板图片.png')).toBeTruthy(); }); expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith( expect.objectContaining({ name: '剪贴板图片.png', type: 'image/png', }), 'image', ); } finally { Object.defineProperty(navigator, 'clipboard', { configurable: true, value: originalClipboard, }); } }); it('keeps canvas clipboard paste ahead of system clipboard images', async () => { render(); await waitFor(() => { expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled(); }); fireEvent.contextMenu( screen.getByAltText('画布图片:拼图素材').closest('button')!, { clientX: 510, clientY: 330, }, ); fireEvent.click(screen.getByRole('menuitem', { name: '复制' })); const systemImageFile = new File(['image'], '系统剪贴板素材.png', { type: 'image/png', }); const pasteEvent = new Event('paste', { bubbles: true, cancelable: true, }); Object.defineProperty(pasteEvent, 'clipboardData', { value: { files: [systemImageFile], items: [], }, }); act(() => { window.dispatchEvent(pasteEvent); }); expect(pasteEvent.defaultPrevented).toBe(true); await waitFor(() => { expect(screen.getAllByAltText(/画布图片:拼图素材/u)).toHaveLength(2); }); expect(screen.queryByAltText('画布图片:系统剪贴板素材.png')).toBeNull(); expect(uploadEditorMediaAssetFileMock).not.toHaveBeenCalledWith( systemImageFile, 'image', ); }); it('drops files into the asset panel only once without creating canvas layers', async () => { render(); fireEvent.drop(screen.getByRole('region', { name: '项目素材' }), { dataTransfer: { files: [new File(['image'], '素材拖拽.png', { type: 'image/png' })], types: ['Files'], }, }); await waitFor(() => { expect( screen.getByRole('button', { name: '添加素材拖拽.png' }), ).toBeTruthy(); }); expect(createEditorAssetMock).toHaveBeenCalledTimes(1); expect(screen.queryByAltText('画布图片:素材拖拽.png')).toBeNull(); }); it('adds an asset library image to the canvas by dragging it onto the viewport', async () => { render(); const sourceAsset = await screen.findByRole('button', { name: '添加抓大鹅素材', }); const sourceAssetRow = sourceAsset.closest( '.image-canvas-editor__asset-row', ); const viewport = screen.getByLabelText('画布工作区'); const dataTransfer = createDataTransferStub(); if (!sourceAssetRow) { throw new Error('asset row should exist'); } fireEvent.dragStart(sourceAssetRow, { dataTransfer }); fireEvent.dragOver(viewport, { clientX: 520, clientY: 300, dataTransfer, }); await waitFor(() => { expect(screen.getByText('添加到画布')).toBeTruthy(); }); fireEvent.drop(viewport, { clientX: 520, clientY: 300, dataTransfer, }); await waitFor(() => { expect(screen.queryByText('添加到画布')).toBeNull(); }); expect(screen.getByAltText('画布图片:抓大鹅素材')).toBeTruthy(); expect(screen.getByRole('button', { name: '选择抓大鹅素材' })).toBeTruthy(); expect(createEditorProjectResourceMock).toHaveBeenCalledWith( 'editor-project-default', expect.objectContaining({ imageSrc: '/creation-type-references/match3d.webp', sourceType: 'uploaded', }), ); expect(createEditorAssetMock).not.toHaveBeenCalled(); }); it('keeps the source resource id when dragging a generated asset onto the viewport', async () => { loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [ { folderId: 'project', label: '项目素材', sortOrder: 0, collapsed: false, systemDefault: true, }, ], assets: [ createTestImageAsset({ assetId: 'asset-generated-character', label: '生成角色素材', imageSrc: '/generated/character.png', objectKey: 'generated/character.png', assetObjectId: 'asset-object-character', sourceType: 'generated', sourceResourceId: 'resource-original-character', }), ], }); render(); const sourceAsset = await screen.findByRole('button', { name: '添加生成角色素材', }); const sourceAssetRow = sourceAsset.closest( '.image-canvas-editor__asset-row', ); const viewport = screen.getByLabelText('画布工作区'); const dataTransfer = createDataTransferStub(); if (!sourceAssetRow) { throw new Error('asset row should exist'); } fireEvent.dragStart(sourceAssetRow, { dataTransfer }); fireEvent.drop(viewport, { clientX: 520, clientY: 300, dataTransfer, }); expect(await screen.findByAltText('画布图片:生成角色素材')).toBeTruthy(); await waitFor(() => { expect(createEditorProjectResourceMock).toHaveBeenCalledWith( 'editor-project-default', expect.objectContaining({ imageSrc: '/generated/character.png', sourceType: 'generated', sourceResourceId: 'resource-original-character', }), ); }); expect(createEditorAssetMock).not.toHaveBeenCalled(); }); });