/* @vitest-environment jsdom */ import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiClientError } from '../../services/apiClient'; import { ImageCanvasEditorView } from './ImageCanvasEditorView'; const generateEditorImageMock = 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 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 saveEditorProjectLayoutMock = 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, generateEditorImage: generateEditorImageMock, loadEditorAssetLibrary: loadEditorAssetLibraryMock, loadEditorProject: loadEditorProjectMock, loadOrCreateRecentEditorProject: loadOrCreateRecentEditorProjectMock, saveEditorProjectLayout: saveEditorProjectLayoutMock, updateEditorAssetFolder: updateEditorAssetFolderMock, }; }); function dispatchPointerEvent( target: Element, type: string, init: MouseEventInit & { pointerId: number }, ) { const event = new MouseEvent(type, { bubbles: true, cancelable: true, ...init, }); Object.defineProperty(event, 'pointerId', { value: init.pointerId }); fireEvent(target, event); } describe('ImageCanvasEditorView', () => { beforeEach(() => { loadOrCreateRecentEditorProjectMock.mockResolvedValue({ projectId: 'editor-project-default', title: '默认项目', viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], updatedAt: '2026-06-12T00:00:00.000Z', }); loadEditorAssetLibraryMock.mockResolvedValue({ folders: [ { folderId: 'project', label: '项目素材', sortOrder: 0, collapsed: false, systemDefault: true, }, ], assets: [], }); 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, })); createEditorAssetFolderMock.mockResolvedValue({ folderId: 'folder-role-persisted', label: '角色上传', collapsed: false, systemDefault: false, }); updateEditorAssetFolderMock.mockImplementation(async (folderId, input) => ({ folderId, label: input.label ?? '角色上传', collapsed: input.collapsed ?? false, systemDefault: false, })); deleteEditorAssetFolderMock.mockResolvedValue({ folders: [ { folderId: 'project', label: '项目素材', sortOrder: 0, collapsed: false, systemDefault: true, }, ], assets: [], }); deleteEditorAssetMock.mockResolvedValue({}); createEditorProjectResourceMock.mockImplementation(async (projectId, input) => ({ resourceId: `resource-${projectId}-${input.width}`, projectId, imageSrc: input.imageSrc, width: input.width, height: input.height, sourceType: input.sourceType, })); saveEditorProjectLayoutMock.mockResolvedValue({}); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); generateEditorImageMock.mockReset(); editEditorImageMock.mockReset(); createEditorAssetMock.mockReset(); createEditorProjectResourceMock.mockReset(); createEditorAssetFolderMock.mockReset(); updateEditorAssetFolderMock.mockReset(); deleteEditorAssetFolderMock.mockReset(); deleteEditorAssetMock.mockReset(); loadEditorAssetLibraryMock.mockReset(); loadEditorProjectMock.mockReset(); loadOrCreateRecentEditorProjectMock.mockReset(); saveEditorProjectLayoutMock.mockReset(); window.history.replaceState(null, '', '/editor/canvas'); }); it('loads the project from projectid query before falling back to recent project', async () => { loadEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-query', title: '查询项目', viewport: { x: 12, y: 16, scale: 0.8 }, layers: [], resources: [], updatedAt: '2026-06-12T00:00:00.000Z', }); window.history.replaceState( null, '', '/editor/canvas?projectid=editor-project-query', ); render(); await waitFor(() => { expect(loadEditorProjectMock).toHaveBeenCalledWith('editor-project-query'); }); expect(loadOrCreateRecentEditorProjectMock).not.toHaveBeenCalled(); }); 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).getByRole('region', { name: '参考素材' })).toBeTruthy(); await user.click(screen.getByRole('button', { name: '重命名素材拼图素材' })); const renameInput = screen.getByLabelText('重命名素材拼图素材'); expect(renameInput.className).toContain('platform-text-field'); expect(renameInput.className).toContain( 'image-canvas-editor__asset-rename-input', ); 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('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('素材文件夹名称'); expect(folderNameInput.className).toContain('platform-text-field'); expect(folderNameInput.className).toContain( 'image-canvas-editor__folder-create-input', ); 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' }), ); await user.click(screen.getByRole('button', { name: '打开素材' })); const customFolder = screen.getByRole('region', { name: '角色上传' }); expect(within(customFolder).getByRole('button', { name: '添加角色草图.png' })).toBeTruthy(); expect(within(customFolder).getByRole('button', { name: '删除素材角色草图.png' })).toBeTruthy(); await user.click(within(customFolder).getByRole('button', { name: '删除素材角色草图.png' })); expect(screen.queryByRole('button', { name: '添加角色草图.png' })).toBeNull(); expect(screen.getByAltText('画布图片:角色草图.png')).toBeTruthy(); }); 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('重命名文件夹角色'); expect(folderRenameInput.className).toContain('platform-text-field'); expect(folderRenameInput.className).toContain( 'image-canvas-editor__folder-rename-input', ); 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('uploads multiple files and persists them as account-level assets', async () => { render(); const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' }); fireEvent.click(within(bottomToolbar).getByRole('button', { name: '上传工具' })); 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.getByAltText('画布图片:第一张.png')).toBeTruthy(); expect(screen.getByAltText('画布图片:第二张.png')).toBeTruthy(); }); expect(createEditorAssetMock).toHaveBeenCalledTimes(2); }); 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: [ { assetId: 'asset-a', folderId: 'project', label: '账号素材A', imageSrc: 'data:image/png;base64,YQ==', width: 320, height: 240, sourceType: 'uploaded', }, { assetId: 'asset-b', folderId: 'project', label: '账号素材B', imageSrc: 'data:image/png;base64,Yg==', width: 320, height: 240, sourceType: 'uploaded', }, ], }); 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('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: [ { assetId: 'asset-a', folderId: 'project', label: '账号素材A', imageSrc: 'data:image/png;base64,YQ==', width: 320, height: 240, sourceType: 'uploaded', }, { assetId: 'asset-b', folderId: 'project', label: '账号素材B', imageSrc: 'data:image/png;base64,Yg==', width: 320, height: 240, sourceType: 'uploaded', }, ], }); 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('shows image size on hover and placeholder toolbar after selecting a layer', () => { const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); render(); const canvasImage = screen.getByAltText('画布图片:拼图素材'); fireEvent.mouseEnter(canvasImage.closest('button')!); const sizeBadge = screen.getByText('420 x 420 px'); expect(sizeBadge.className).toContain('rounded-full'); expect(sizeBadge.className).toContain('image-canvas-editor__size-badge'); fireEvent.pointerDown(canvasImage.closest('button')!, { button: 0, pointerId: 1, clientX: 120, clientY: 120, }); const cropButton = screen.getByRole('button', { name: '裁剪占位' }); fireEvent.pointerDown(cropButton, { button: 0, pointerId: 2, clientX: 120, clientY: 96, }); fireEvent.click(cropButton); expect(alertSpy).toHaveBeenCalledWith('裁剪功能建设中'); expect(screen.getByRole('toolbar', { name: '图片工具栏' })).toBeTruthy(); }); it('treats puzzle material as a normal asset without generated metadata tools', () => { render(); fireEvent.pointerDown(screen.getByAltText('画布图片:拼图素材').closest('button')!, { button: 0, pointerId: 61, clientX: 120, clientY: 120, }); expect(screen.queryByRole('button', { name: '查看拼图素材元数据' })).toBeNull(); expect(screen.queryByRole('button', { name: '修改图片' })).toBeNull(); }); it('deletes the selected layer from the floating toolbar', () => { render(); expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy(); fireEvent.pointerDown(screen.getByAltText('画布图片:拼图素材').closest('button')!, { button: 0, pointerId: 51, clientX: 120, clientY: 120, }); fireEvent.click(screen.getByRole('button', { name: '删除图片' })); expect(screen.queryByAltText('画布图片:拼图素材')).toBeNull(); expect(screen.getByAltText('画布图片:大鱼素材')).toBeTruthy(); }); it('uploads an image file as a new canvas layer', async () => { render(); await waitFor(() => { expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled(); }); const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' }); expect(within(bottomToolbar).queryByRole('button', { name: '局部修改工具' })).toBeNull(); fireEvent.click(within(bottomToolbar).getByRole('button', { name: '上传工具' })); await userEvent.upload( screen.getByLabelText('上传图片文件'), new File(['image'], '测试上传.png', { type: 'image/png' }), ); await waitFor(() => { expect(screen.getByAltText('画布图片:测试上传.png')).toBeTruthy(); }); expect(createEditorAssetMock).toHaveBeenCalledWith( expect.objectContaining({ label: '测试上传.png', imageSrc: expect.stringMatching(/^data:image\/png;base64,/u), }), ); expect(screen.getByRole('button', { name: '选择图层测试上传.png' })).toBeTruthy(); }); it('blocks the browser context menu inside the editor workspace', () => { render(); const editor = screen.getByRole('region', { name: '图片画布编辑器' }); const contextMenuEvent = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, }); const wasNotCanceled = editor.dispatchEvent(contextMenuEvent); expect(wasNotCanceled).toBe(false); expect(contextMenuEvent.defaultPrevented).toBe(true); }); it('switches the shared sidebar between assets and layers', () => { render(); const sidebar = screen.getByRole('complementary', { name: '图片资源栏' }); expect(within(sidebar).getByText('素材')).toBeTruthy(); expect(within(sidebar).queryByText('已生成文件')).toBeNull(); expect(within(sidebar).queryByText('图层')).toBeNull(); expect(screen.queryByRole('toolbar', { name: '画布主工具栏' })).toBeNull(); expect(screen.queryByRole('complementary', { name: '图层面板' })).toBeNull(); expect(screen.queryByRole('dialog', { name: '已生成文件' })).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '打开图层' })); const layersPanel = screen.getByRole('complementary', { name: '图片资源栏' }); expect( within(layersPanel).getByRole('button', { name: '选择图层拼图素材' }), ).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '选择图层大鱼素材' })); expect(screen.getByRole('toolbar', { name: '图片工具栏' })).toBeTruthy(); expect(screen.queryByRole('button', { name: '查看大鱼素材元数据' })).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '打开素材' })); expect(screen.getByRole('button', { name: '添加拼图素材' })).toBeTruthy(); }); it('adds assets from the sidebar and supports zoom buttons', () => { render(); expect( screen.getByRole('button', { name: '当前缩放比例 82%' }).className, ).toContain('platform-inline-option-button'); fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 82%' })); fireEvent.click(screen.getByRole('menuitem', { name: '放大' })); expect(screen.getByRole('button', { name: '当前缩放比例 95%' })).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' })); expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy(); expect(screen.getByRole('complementary', { name: '图片资源栏' })).toBeTruthy(); }); it('offers Lovart-style zoom menu commands', async () => { render(); fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 82%' })); expect(screen.getByRole('menu', { name: '缩放菜单' })).toBeTruthy(); expect(screen.getByRole('menuitem', { name: '显示画布所有元素' })).toBeTruthy(); fireEvent.click(screen.getByRole('menuitem', { name: '缩放至100%' })); expect(screen.getByRole('button', { name: '当前缩放比例 100%' })).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 100%' })); fireEvent.click(screen.getByRole('menuitem', { name: '缩放至50%' })); expect(screen.getByRole('button', { name: '当前缩放比例 50%' })).toBeTruthy(); }); it('shows the Lovart-style minimap and canvas background controls', () => { render(); const viewport = screen.getByLabelText('画布工作区'); const panelToolbar = screen.getByRole('toolbar', { name: '画布面板入口' }); expect(screen.getByRole('button', { name: '画布小地图' })).toBeTruthy(); expect( within(panelToolbar).getByRole('button', { name: '画布背景色' }).className, ).toContain('platform-icon-button'); expect(within(panelToolbar).getByRole('button', { name: '切换小地图' })).toBeTruthy(); fireEvent.click(within(panelToolbar).getByRole('button', { name: '画布背景色' })); expect(screen.getByRole('menu', { name: '画布背景色菜单' })).toBeTruthy(); fireEvent.click(screen.getByRole('menuitem', { name: '切换画布背景色为暖灰' })); expect((viewport as HTMLElement).style.backgroundColor).toBe('rgb(243, 240, 234)'); fireEvent.click(within(panelToolbar).getByRole('button', { name: '切换小地图' })); expect(screen.queryByRole('button', { name: '画布小地图' })).toBeNull(); }); it('uses normal wheel for vertical canvas scroll and ctrl wheel for zoom', () => { render(); const viewport = screen.getByLabelText('画布工作区'); expect(screen.getByRole('button', { name: '当前缩放比例 82%' })).toBeTruthy(); fireEvent.wheel(viewport, { deltaY: 120, clientX: 400, clientY: 280 }); expect(screen.getByRole('button', { name: '当前缩放比例 82%' })).toBeTruthy(); fireEvent.wheel(viewport, { deltaY: -120, ctrlKey: true, clientX: 400, clientY: 280, }); expect(screen.getByRole('button', { name: '当前缩放比例 90%' })).toBeTruthy(); }); it('drags the minimap to move the canvas viewport', () => { render(); const minimap = screen.getByRole('button', { name: '画布小地图' }); vi.spyOn(minimap, 'getBoundingClientRect').mockReturnValue({ x: 0, y: 0, left: 0, top: 0, right: 132, bottom: 84, width: 132, height: 84, toJSON: () => ({}), }); dispatchPointerEvent(minimap, 'pointerdown', { button: 0, pointerId: 71, clientX: 120, clientY: 72, }); const firstLayer = screen.getByAltText('画布图片:拼图素材').closest('button')!; expect(Number.parseFloat((firstLayer as HTMLElement).style.left)).toBe(470); expect(screen.getByRole('button', { name: '画布小地图' })).toBeTruthy(); }); it('persists layer groups in the canvas layer snapshot', async () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开图层' })); fireEvent.click(screen.getByRole('button', { name: '图层打组' })); await waitFor(() => { expect(screen.getByText(/已打组/u)).toBeTruthy(); }); await waitFor(() => { expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( 'editor-project-default', expect.objectContaining({ layers: expect.arrayContaining([ expect.objectContaining({ title: '拼图素材', groupId: expect.stringMatching(/^layer-group-/u), }), ]), }), ); }); }); it('opens a canvas generation frame and composer before creating a generated layer', async () => { generateEditorImageMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==', width: 1024, height: 1024, sourceType: 'generated', prompt: '一张明亮的拼图主视觉', actualPrompt: '一张明亮的拼图主视觉', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'editor-real-task-1', }); render(); const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' }); fireEvent.click(within(bottomToolbar).getByRole('button', { name: '生成工具' })); const generateDialog = screen.getByRole('dialog', { name: '生成图片' }); const initialComposerTop = Number.parseFloat( (generateDialog as HTMLElement).style.top, ); expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); expect(within(generateDialog).getByText('参考图')).toBeTruthy(); expect( within(generateDialog).getByRole('button', { name: '添加参考图' }).className, ).toContain('bg-white/94'); expect( within(generateDialog).getByRole('button', { name: '添加参考图' }).className, ).toContain('image-canvas-editor__generation-ref'); const generatePrompt = screen.getByLabelText('生成提示词'); expect(generatePrompt.className).toContain('platform-text-field'); expect(generatePrompt.className).toContain( 'image-canvas-editor__generation-prompt', ); expect( within(generateDialog).getByRole('button', { name: '生成比例 1:1 2k 1张', }).className, ).toContain('platform-inline-option-button'); expect( within(generateDialog).getByRole('button', { name: '生成模型 GPT Image', }).className, ).toContain('platform-inline-option-button'); expect(within(generateDialog).getByRole('button', { name: '生成' }).className).toContain( 'platform-button', ); expect(within(generateDialog).getByRole('button', { name: '生成' }).className).toContain( 'image-canvas-editor__generation-submit', ); expect(screen.queryByRole('toolbar', { name: 'AI画布工具栏' })).toBeNull(); fireEvent.change(screen.getByLabelText('生成提示词'), { target: { value: '一张明亮的拼图主视觉' }, }); fireEvent.click(within(generateDialog).getByRole('button', { name: '生成' })); expect(screen.getByRole('status').textContent).toContain('生成中'); expect(generateEditorImageMock).toHaveBeenCalledWith({ prompt: '一张明亮的拼图主视觉', }); await waitFor(() => { expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy(); }); const generatedLayer = screen.getByAltText(/画布图片:生成图片/).closest('button')!; const anchoredGenerateDialog = screen.getByRole('dialog', { name: '生成图片' }); expect(anchoredGenerateDialog).toBeTruthy(); expect( Number.parseFloat((anchoredGenerateDialog as HTMLElement).style.top), ).toBeGreaterThan(Number.parseFloat((generatedLayer as HTMLElement).style.top)); expect( Number.parseFloat((generatedLayer as HTMLElement).style.top), ).toBeLessThan(initialComposerTop); expect(screen.queryByLabelText('图像生成占位图')).toBeNull(); const metadataButtons = screen.getAllByRole('button', { name: /查看生成图片 .*元数据/, }); expect(metadataButtons[0]).toBeTruthy(); }); it('drags the generation placeholder and places the generated layer there', async () => { generateEditorImageMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,ZHJhZ2dlZC1mcmFtZQ==', width: 1024, height: 1024, sourceType: 'generated', prompt: '拖拽后的生成图', actualPrompt: '拖拽后的生成图', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'editor-drag-frame-1', }); render(); await waitFor(() => { expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled(); }); fireEvent.click(screen.getByRole('button', { name: '生成工具' })); const initialComposerTop = Number.parseFloat( (screen.getByRole('dialog', { name: '生成图片' }) as HTMLElement).style.top, ); const frame = screen.getByLabelText('图像生成占位图'); dispatchPointerEvent(frame, 'pointerdown', { button: 0, pointerId: 61, clientX: 500, clientY: 260, }); dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', { pointerId: 61, clientX: 582, clientY: 342, }); dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointerup', { pointerId: 61, clientX: 582, clientY: 342, }); const draggedComposerTop = Number.parseFloat( (screen.getByRole('dialog', { name: '生成图片' }) as HTMLElement).style.top, ); expect(draggedComposerTop).toBeGreaterThan(initialComposerTop); fireEvent.change(screen.getByLabelText('生成提示词'), { target: { value: '拖拽后的生成图' }, }); fireEvent.click(screen.getByRole('button', { name: '生成' })); await waitFor(() => { expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy(); }); const generatedLayer = screen.getByAltText(/画布图片:生成图片/).closest('button')!; const anchoredGenerateDialog = screen.getByRole('dialog', { name: '生成图片' }); expect(anchoredGenerateDialog).toBeTruthy(); expect( Number.parseFloat((anchoredGenerateDialog as HTMLElement).style.top), ).toBeGreaterThan(Number.parseFloat((generatedLayer as HTMLElement).style.top)); expect(screen.queryByLabelText('图像生成占位图')).toBeNull(); expect(Number.parseFloat((generatedLayer as HTMLElement).style.left)).toBeGreaterThan(300); expect(Number.parseFloat((generatedLayer as HTMLElement).style.top)).toBeGreaterThan(180); }); it('keeps the generation composer when selecting another image', () => { render(); fireEvent.click(screen.getByRole('button', { name: '生成工具' })); expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy(); fireEvent.pointerDown(screen.getByAltText('画布图片:拼图素材').closest('button')!, { button: 0, pointerId: 62, clientX: 120, clientY: 120, }); expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy(); expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); }); it('keeps the generation composer when clicking the canvas outside generation controls', () => { render(); fireEvent.click(screen.getByRole('button', { name: '生成工具' })); expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy(); fireEvent.pointerDown(screen.getByLabelText('画布工作区'), { button: 0, pointerId: 63, clientX: 260, clientY: 180, }); expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy(); expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); }); it('shows generation errors instead of falling back to mock images', async () => { generateEditorImageMock.mockRejectedValueOnce(new Error('VectorEngine 未配置')); render(); fireEvent.click(screen.getByRole('button', { name: '生成工具' })); fireEvent.change(screen.getByLabelText('生成提示词'), { target: { value: '一张真实生成失败的图' }, }); fireEvent.click(screen.getByRole('button', { name: '生成' })); expect(screen.getByRole('status').textContent).toContain('生成中'); await waitFor(() => { expect(screen.getByRole('alert').textContent).toContain('VectorEngine 未配置'); }); expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); expect(screen.queryByAltText(/画布图片:生成图片/)).toBeNull(); }); it('asks the user to log in when real generation is unauthorized', async () => { generateEditorImageMock.mockRejectedValueOnce( new ApiClientError({ message: '未授权访问(requestId: web-login-required)', status: 401, code: 'UNAUTHORIZED', }), ); render(); fireEvent.click(screen.getByRole('button', { name: '生成工具' })); fireEvent.change(screen.getByLabelText('生成提示词'), { target: { value: '一张需要登录生成的图' }, }); fireEvent.click(screen.getByRole('button', { name: '生成' })); await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe( '请先登录后再生成图片', ); }); expect(screen.queryByText(/requestId/u)).toBeNull(); }); it('switches tools and restores the previous tool after holding Space', async () => { const user = userEvent.setup(); render(); const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' }); const selectTool = within(bottomToolbar).getByRole('button', { name: '选择工具' }); const textTool = within(bottomToolbar).getByRole('button', { name: '文字工具' }); const handTool = within(bottomToolbar).getByRole('button', { name: '抓手工具' }); expect(selectTool.getAttribute('aria-pressed')).toBe('true'); await user.click(textTool); expect(textTool.getAttribute('aria-pressed')).toBe('true'); fireEvent.keyDown(window, { code: 'Space', key: ' ' }); expect(handTool.getAttribute('aria-pressed')).toBe('true'); fireEvent.keyUp(window, { code: 'Space', key: ' ' }); expect(textTool.getAttribute('aria-pressed')).toBe('true'); }); it('switches away from hand tool from the bottom toolbar', async () => { const user = userEvent.setup(); render(); const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' }); const handTool = within(bottomToolbar).getByRole('button', { name: '抓手工具' }); const textTool = within(bottomToolbar).getByRole('button', { name: '文字工具' }); await user.click(handTool); expect(handTool.getAttribute('aria-pressed')).toBe('true'); await user.click(textTool); expect(textTool.getAttribute('aria-pressed')).toBe('true'); expect(handTool.getAttribute('aria-pressed')).toBe('false'); }); it('pans with the middle mouse button without leaving select mode', async () => { render(); const viewport = screen.getByLabelText('画布工作区'); const middlePointerDown = new MouseEvent('pointerdown', { bubbles: true, cancelable: true, button: 1, buttons: 4, clientX: 260, clientY: 220, }); Object.defineProperty(middlePointerDown, 'pointerId', { value: 11 }); fireEvent(viewport, middlePointerDown); await waitFor(() => { expect(viewport.className).toContain('image-canvas-editor__viewport--panning'); }); const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' }); expect( within(bottomToolbar) .getByRole('button', { name: '选择工具' }) .getAttribute('aria-pressed'), ).toBe('true'); }); it('shows snap guides when dragging a layer near another layer alignment', async () => { render(); const puzzleLayer = screen.getByAltText('画布图片:拼图素材').closest('button')!; dispatchPointerEvent(puzzleLayer, 'pointerdown', { button: 0, pointerId: 21, clientX: 120, clientY: 120, }); dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', { pointerId: 21, clientX: 499, clientY: 169, }); expect(screen.getByTestId('image-canvas-editor-snap-guide-vertical')).toBeTruthy(); expect(screen.getByTestId('image-canvas-editor-snap-guide-horizontal')).toBeTruthy(); }); it('can switch tools after a layer drag started without pointer release', async () => { const user = userEvent.setup(); render(); fireEvent.pointerDown(screen.getByAltText('画布图片:拼图素材').closest('button')!, { button: 0, pointerId: 41, clientX: 120, clientY: 120, }); fireEvent.pointerMove(screen.getByLabelText('画布工作区'), { pointerId: 41, clientX: 220, clientY: 160, }); const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' }); const textTool = within(bottomToolbar).getByRole('button', { name: '文字工具' }); await user.click(textTool); expect(textTool.getAttribute('aria-pressed')).toBe('true'); expect(screen.queryByTestId('image-canvas-editor-snap-guide-vertical')).toBeNull(); }); it('opens generated image metadata from the corner button and creates a real right-side edit result', async () => { generateEditorImageMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==', width: 1024, height: 1024, sourceType: 'generated', prompt: '一张可修改的生成图', actualPrompt: '一张可修改的生成图', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'editor-real-task-2', }); editEditorImageMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,ZWRpdGVkLWltYWdl', width: 1024, height: 1024, sourceType: 'generated', prompt: '把画面改成黄昏光线', actualPrompt: '把画面改成黄昏光线', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'editor-real-edit-1', }); render(); fireEvent.click(screen.getByRole('button', { name: '生成工具' })); fireEvent.change(screen.getByLabelText('生成提示词'), { target: { value: '一张可修改的生成图' }, }); fireEvent.click(screen.getByRole('button', { name: '生成' })); await waitFor(() => { expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy(); }); expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy(); const metadataCornerButton = screen.getAllByRole('button', { name: /查看生成图片 .*元数据/, })[0]; if (!metadataCornerButton) { throw new Error('metadata corner button should exist'); } expect(metadataCornerButton.className).toContain('bg-black/55'); expect(metadataCornerButton.className).toContain( 'image-canvas-editor__metadata-corner', ); fireEvent.click(metadataCornerButton); const metadataDialog = screen.getByRole('dialog', { name: /生成图片 .*元数据/ }); expect(metadataDialog).toBeTruthy(); expect(within(metadataDialog).getByText('gpt-image-2')).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '修改图片' })); const editDialog = screen.getByRole('dialog', { name: '修改图片' }); expect(editDialog).toBeTruthy(); const editPrompt = screen.getByLabelText('生成提示词'); expect(editPrompt.className).toContain('platform-text-field'); expect(editPrompt.className).toContain('image-canvas-editor__generate-prompt'); fireEvent.change(editPrompt, { target: { value: '把画面改成黄昏光线' }, }); fireEvent.click(screen.getByRole('button', { name: '修改' })); expect(screen.getByRole('status').textContent).toContain('修改中'); expect(editEditorImageMock).toHaveBeenCalledWith({ prompt: '把画面改成黄昏光线', sourceImageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==', }); await waitFor(() => { expect(screen.queryByRole('dialog', { name: '修改图片' })).toBeNull(); }); expect(screen.getByAltText(/画布图片:生成图片 .* 修改结果/)).toBeTruthy(); expect(screen.getByRole('button', { name: '当前缩放比例 100%' })).toBeTruthy(); }); });