/* @vitest-environment jsdom */ import { act, fireEvent, render, screen, waitFor, within, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import JSZip from 'jszip'; import type { ContextType } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiClientError } from '../../services/apiClient'; import { AuthUiContext } from '../auth/AuthUiContext'; import { ImageCanvasEditorView } from './ImageCanvasEditorView'; 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()); type AuthValue = NonNullable>; function createAuthValue(overrides: Partial = {}): AuthValue { return { user: null, canAccessProtectedData: false, openLoginModal: vi.fn(), requireAuth: vi.fn((action: () => void) => action()), openSettingsModal: vi.fn(), openAccountModal: vi.fn(), setCurrentUser: vi.fn(), logout: vi.fn(), musicVolume: 0.5, setMusicVolume: vi.fn(), platformTheme: 'light', setPlatformTheme: vi.fn(), isHydratingSettings: false, isPersistingSettings: false, settingsError: null, ...overrides, }; } const defaultEditorProjectResources = [ { resourceId: 'resource-puzzle', projectId: 'editor-project-default', imageSrc: '/creation-type-references/puzzle.webp', width: 640, height: 640, sourceType: 'uploaded', }, { resourceId: 'resource-big-fish', projectId: 'editor-project-default', imageSrc: '/creation-type-references/big-fish.webp', width: 720, height: 405, sourceType: 'uploaded', }, ]; const defaultEditorProjectLayers = [ { layerId: 'layer-puzzle', resourceId: 'resource-puzzle', title: '拼图素材', x: 470, y: 300, width: 640, height: 640, originalWidth: 640, originalHeight: 640, zIndex: 1, sourceType: 'uploaded', }, { layerId: 'layer-big-fish', resourceId: 'resource-big-fish', title: '大鱼素材', x: 930, y: 360, width: 720, height: 405, originalWidth: 720, originalHeight: 405, zIndex: 2, sourceType: 'uploaded', }, ]; const defaultEditorAssetLibraryAssets = [ { assetId: 'asset-puzzle', folderId: 'project', label: '拼图素材', imageSrc: '/creation-type-references/puzzle.webp', width: 640, height: 640, sourceType: 'uploaded', }, { assetId: 'asset-match3d', folderId: 'project', label: '抓大鹅素材', imageSrc: '/creation-type-references/match3d.webp', width: 640, height: 640, sourceType: 'uploaded', }, { assetId: 'asset-big-fish', folderId: 'project', label: '大鱼素材', imageSrc: '/creation-type-references/big-fish.webp', width: 720, height: 405, sourceType: 'uploaded', }, { assetId: 'asset-bark-battle', folderId: 'project', label: '声浪素材', imageSrc: '/creation-type-references/bark-battle.webp', width: 640, height: 900, sourceType: 'uploaded', }, { assetId: 'asset-visual-novel', folderId: 'project', label: '视觉小说素材', imageSrc: '/creation-type-references/visual-novel.webp', width: 720, height: 405, sourceType: 'uploaded', }, ]; 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, }; }); 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); } function immediateAsync(value: T) { return { then(onFulfilled: (value: T) => unknown) { onFulfilled(value); return { catch() {}, }; }, }; } function createDataTransferStub() { const store = new Map(); return { files: [], types: [] as string[], dropEffect: 'none', effectAllowed: 'all', setData(type: string, value: string) { store.set(type, value); if (!this.types.includes(type)) { this.types.push(type); } }, getData(type: string) { return store.get(type) ?? ''; }, }; } 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 }; } async function readZipText(zip: JSZip, path: string) { const file = zip.file(path); expect(file).toBeTruthy(); return file!.async('string'); } describe('ImageCanvasEditorView', () => { beforeEach(() => { loadOrCreateRecentEditorProjectMock.mockImplementation(() => immediateAsync({ projectId: 'editor-project-default', title: '默认项目', viewport: { x: 0, y: 0, scale: 1 }, layers: defaultEditorProjectLayers, resources: defaultEditorProjectResources, updatedAt: '2026-06-12T00:00:00.000Z', }), ); loadEditorAssetLibraryMock.mockImplementation(() => immediateAsync({ folders: [ { folderId: 'project', label: '项目素材', sortOrder: 0, collapsed: false, systemDefault: true, }, ], assets: defaultEditorAssetLibraryAssets, }), ); 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, }); updateEditorAssetMock.mockImplementation(async (assetId, input) => ({ assetId, folderId: input.folderId ?? 'project', label: input.label ?? '拼图素材', imageSrc: '/creation-type-references/puzzle.webp', width: 640, height: 640, sourceType: 'uploaded', })); renameEditorProjectMock.mockImplementation(async (projectId, title) => ({ projectId, title, viewport: { x: 0, y: 0, scale: 1 }, layers: defaultEditorProjectLayers, resources: defaultEditorProjectResources, updatedAt: '2026-06-12T00:00:00.000Z', })); 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(); generateEditorIconSpritesheetMock.mockReset(); generateEditorCharacterAnimationMock.mockReset(); editEditorImageMock.mockReset(); createEditorAssetMock.mockReset(); createEditorProjectResourceMock.mockReset(); createEditorAssetFolderMock.mockReset(); updateEditorAssetMock.mockReset(); updateEditorAssetFolderMock.mockReset(); deleteEditorAssetFolderMock.mockReset(); deleteEditorAssetMock.mockReset(); loadEditorAssetLibraryMock.mockReset(); loadEditorProjectMock.mockReset(); loadOrCreateRecentEditorProjectMock.mockReset(); renameEditorProjectMock.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('shows the loaded project title and a topbar entry back to projects', async () => { render(); expect( await screen.findByRole('heading', { name: '默认项目' }), ).toBeTruthy(); const projectLink = screen.getByRole('link', { name: '返回项目页面' }); expect(projectLink.getAttribute('href')).toBe('/project'); expect(screen.queryByRole('heading', { name: '图片编辑器' })).toBeNull(); }); it('opens login modal when the asset library is unauthorized', async () => { const openLoginModal = vi.fn(); loadEditorAssetLibraryMock.mockRejectedValueOnce( new ApiClientError({ message: '未授权访问', status: 401, code: 'UNAUTHORIZED', }), ); render( , ); await waitFor(() => { expect(openLoginModal).toHaveBeenCalledTimes(1); }); }); it('opens the login modal immediately when entering the editor while logged out', async () => { const openLoginModal = vi.fn(); render( , ); await waitFor(() => { expect(openLoginModal).toHaveBeenCalledTimes(1); }); expect(typeof openLoginModal.mock.calls[0]?.[0]).toBe('function'); expect(loadEditorAssetLibraryMock).not.toHaveBeenCalled(); expect(loadEditorProjectMock).not.toHaveBeenCalled(); expect(loadOrCreateRecentEditorProjectMock).not.toHaveBeenCalled(); }); it('renames the current project from the canvas topbar', async () => { render(); await screen.findByRole('heading', { name: '默认项目' }); fireEvent.click(screen.getByRole('button', { name: '编辑项目名称' })); fireEvent.change(screen.getByLabelText('项目名称'), { target: { value: '新画布项目' }, }); fireEvent.click(screen.getByRole('button', { name: '保存项目名称' })); await waitFor(() => { expect(renameEditorProjectMock).toHaveBeenCalledWith( 'editor-project-default', '新画布项目', ); }); expect( await screen.findByRole('heading', { name: '新画布项目' }), ).toBeTruthy(); }); it('does not inject built-in mock assets when the persisted library is empty', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-empty', 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: [], }); render(); expect( await screen.findByRole('region', { name: '项目素材' }), ).toBeTruthy(); expect(screen.queryByRole('button', { name: '添加拼图素材' })).toBeNull(); expect(screen.queryByRole('button', { name: '添加大鱼素材' })).toBeNull(); expect(screen.queryByAltText(/画布图片:拼图素材/u)).toBeNull(); }); it('exports valid canvas assets as a zip from the topbar with metadata', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-export', title: '导出项目', viewport: { x: 0, y: 0, scale: 1 }, layers: [ { layerId: 'layer-data-a', resourceId: 'resource-data-a', title: '素材/A', src: 'data:image/png;base64,YQ==', x: 12, y: 24, width: 320, height: 220, originalWidth: 640, originalHeight: 440, zIndex: 1, sourceType: 'uploaded', sourceAssetId: 'asset-data-a', groupId: 'group-a', hidden: true, locked: true, flipX: true, }, { layerId: 'layer-data-a-copy', resourceId: 'resource-data-a-copy', title: '素材/A 副本', src: 'data:image/png;base64,YQ==', x: 42, y: 54, width: 320, height: 220, originalWidth: 640, originalHeight: 440, zIndex: 2, sourceType: 'uploaded', sourceAssetId: 'asset-data-a', }, { layerId: 'layer-generated', resourceId: 'resource-generated', title: '生成图', src: '/generated-ok.png', x: 70, y: 80, width: 360, height: 360, originalWidth: 1024, originalHeight: 1024, zIndex: 3, sourceType: 'generated', prompt: '明亮主视觉', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'task-1', }, { layerId: 'layer-failed', resourceId: 'resource-failed', title: '失败图', src: '/missing.png', x: 90, y: 100, width: 120, height: 120, originalWidth: 120, originalHeight: 120, zIndex: 4, sourceType: 'generated', }, ], resources: [], updatedAt: '2026-06-12T00:00:00.000Z', }); loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [ { folderId: 'project', label: '项目素材', sortOrder: 0, collapsed: false, systemDefault: true, }, ], assets: [ { assetId: 'asset-data-a', folderId: 'project', label: '素材/A', imageSrc: 'data:image/png;base64,YQ==', width: 640, height: 440, sourceType: 'uploaded', }, ], }); const originalFetch = globalThis.fetch; const fetchMock = vi.fn(async (url: string) => { if (url === '/generated-ok.png') { return new Response(new Blob(['generated'], { type: 'image/png' })); } return new Response(null, { status: 404 }); }); globalThis.fetch = fetchMock as typeof fetch; const originalCreateObjectUrl = URL.createObjectURL; const originalRevokeObjectUrl = URL.revokeObjectURL; const originalAnchorClick = HTMLAnchorElement.prototype.click; let exportedBlob: Blob | null = null; let downloadName = ''; URL.createObjectURL = vi.fn((blob: Blob) => { exportedBlob = blob; return 'blob:editor-export'; }); URL.revokeObjectURL = vi.fn(); HTMLAnchorElement.prototype.click = vi.fn(function click( this: HTMLAnchorElement, ) { downloadName = this.download; }); try { render(); await screen.findByRole('heading', { name: '导出项目' }); await waitFor(() => { expect( ( screen.getByRole('button', { name: '下载画布素材', }) as HTMLButtonElement ).disabled, ).toBe(false); }); fireEvent.click(screen.getByRole('button', { name: '下载画布素材' })); await waitFor(() => { expect(exportedBlob).toBeTruthy(); }); expect(downloadName).toMatch(/^导出项目-画布素材-\d{8}\.zip$/u); const zip = await JSZip.loadAsync(exportedBlob!); expect(zip.file('导出项目-画布素材/images/001-素材 A.png')).toBeTruthy(); expect(zip.file('导出项目-画布素材/images/002-生成图.png')).toBeTruthy(); expect(zip.file('导出项目-画布素材/images/003-失败图.png')).toBeNull(); const metadata = JSON.parse( await readZipText(zip, '导出项目-画布素材/metadata.json'), ); expect(metadata.projectId).toBe('editor-project-export'); expect(metadata.layers).toHaveLength(4); expect(metadata.layers[0].file).toBe('images/001-素材 A.png'); expect(metadata.layers[1].file).toBe('images/001-素材 A.png'); expect(metadata.layers[0].canvas.hidden).toBe(true); expect(metadata.layers[0].canvas.locked).toBe(true); expect(metadata.layers[0].canvas.flipX).toBe(true); expect(metadata.layers[0].canvas.groupId).toBe('group-a'); expect(metadata.layers[2].sourceType).toBe('generated'); expect(metadata.layers[2].prompt).toBe('明亮主视觉'); expect(metadata.layers[3].file).toBeNull(); expect(metadata.layers[3].exportError).toContain('404'); expect(metadata.failedImages).toHaveLength(1); expect( await readZipText(zip, '导出项目-画布素材/manifest.txt'), ).toContain('失败素材数量:1'); expect(screen.getByText('部分素材未能导出')).toBeTruthy(); } finally { globalThis.fetch = originalFetch; URL.createObjectURL = originalCreateObjectUrl; URL.revokeObjectURL = originalRevokeObjectUrl; HTMLAnchorElement.prototype.click = originalAnchorClick; } }); it('disables the canvas asset export entry when there are no valid layers', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-empty-export', 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: [], }); render(); await screen.findByRole('heading', { name: '空导出项目' }); expect( ( screen.getByRole('button', { name: '下载画布素材', }) as HTMLButtonElement ).disabled, ).toBe(true); }); 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('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(); 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.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('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; 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: 'data:image/png;base64,cHJvZ3Jlc3M=', 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: [ { 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('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: [ { 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 user.click( await screen.findByRole('button', { name: '添加账号素材A' }), ); await user.click(screen.getByRole('button', { name: '添加账号素材B' })); expect(screen.getByAltText('画布图片:账号素材A')).toBeTruthy(); expect(screen.getByAltText('画布图片:账号素材B')).toBeTruthy(); 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'); }); 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: 'data:image/png;base64,YQ==', 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: [ { assetId: 'asset-a', folderId: 'project', label: '账号素材A', imageSrc: 'data:image/png;base64,YQ==', width: 320, height: 240, sourceType: 'uploaded', }, ], }); 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: [ { 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 resolution 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('640 x 640 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('opens image info for uploaded canvas images without generated edit tools', () => { render(); fireEvent.pointerDown( screen.getByAltText('画布图片:拼图素材').closest('button')!, { button: 0, pointerId: 61, clientX: 120, clientY: 120, }, ); const infoButton = screen.getByRole('button', { name: '查看拼图素材图片信息', }); expect(infoButton.className).toContain( 'image-canvas-editor__metadata-corner', ); fireEvent.click(infoButton); const infoPanel = screen.getByRole('dialog', { name: '拼图素材图片信息' }); expect(within(infoPanel).getByText('图片类型')).toBeTruthy(); expect(within(infoPanel).getByText('上传图片')).toBeTruthy(); expect(within(infoPanel).getByText('生成输入')).toBeTruthy(); expect( infoPanel.querySelector('.image-canvas-editor__metadata-inputs') ?.textContent, ).toBe('-'); expect(within(infoPanel).queryByText('Prompt')).toBeNull(); expect(within(infoPanel).getByText('Model')).toBeTruthy(); expect(within(infoPanel).queryByText('Size')).toBeNull(); expect(within(infoPanel).getByText('Resolution')).toBeTruthy(); expect(within(infoPanel).getByText('640 x 640 px')).toBeTruthy(); expect( within(infoPanel).queryByRole('button', { name: '复制Prompt' }), ).toBeNull(); expect(screen.queryByRole('button', { name: '修改图片' })).toBeNull(); }); it('hydrates canvas images from Resolution instead of saved Size', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-resolution', title: '原分辨率画布', viewport: { x: 0, y: 0, scale: 1 }, layers: [ { layerId: 'layer-resolution', resourceId: 'resource-resolution', title: '旧布局图片', src: 'data:image/png;base64,cmVzb2x1dGlvbg==', x: 120, y: 140, width: 320, height: 240, originalWidth: 1536, originalHeight: 1024, zIndex: 2, sourceType: 'generated', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'resolution-task-1', }, ], resources: [], updatedAt: '2026-06-16T00:00:00.000Z', }); render(); const canvasImage = await screen.findByAltText('画布图片:旧布局图片'); const canvasLayer = canvasImage.closest('button') as HTMLElement; expect(Number.parseFloat(canvasLayer.style.width)).toBe(1536); expect(Number.parseFloat(canvasLayer.style.height)).toBe(1024); fireEvent.mouseEnter(canvasLayer); expect(screen.getByText('1536 x 1024 px')).toBeTruthy(); fireEvent.click( screen.getAllByRole('button', { name: '查看旧布局图片图片信息' })[0]!, ); const infoPanel = screen.getByRole('dialog', { name: '旧布局图片图片信息', }); expect(within(infoPanel).queryByText('Size')).toBeNull(); expect(within(infoPanel).getByText('Resolution')).toBeTruthy(); expect(within(infoPanel).getByText('1536 x 1024 px')).toBeTruthy(); }); 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('deletes the selected layer with Backspace when focus is outside text inputs', async () => { render(); expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy(); fireEvent.pointerDown( screen.getByAltText('画布图片:拼图素材').closest('button')!, { button: 0, pointerId: 52, clientX: 120, clientY: 120, }, ); await act(async () => { fireEvent.keyDown(window, { key: 'Backspace', code: 'Backspace' }); }); expect(screen.queryByAltText('画布图片:拼图素材')).toBeNull(); expect(screen.getByAltText('画布图片:大鱼素材')).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: expect.stringMatching(/^data:image\/png;base64,/u), }), ); 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('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('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('shows the blank canvas context menu with paste disabled, zoom, and fit all', () => { render(); const viewport = screen.getByLabelText('画布工作区'); fireEvent.contextMenu(viewport, { clientX: 320, clientY: 220, }); const menu = screen.getByRole('menu', { name: '画布右键菜单' }); expect( ( within(menu).getByRole('menuitem', { name: '粘贴', }) as HTMLButtonElement ).disabled, ).toBe(true); expect(within(menu).getByRole('menuitem', { name: '放大' })).toBeTruthy(); expect( within(menu).getByRole('menuitem', { name: '显示画布所有元素' }), ).toBeTruthy(); }); it('keeps right-clicking a canvas layer from falling through to blank pan menu handling', () => { render(); const layerButton = screen .getByAltText('画布图片:拼图素材') .closest('button')!; const rightPointerDown = new MouseEvent('pointerdown', { bubbles: true, cancelable: true, button: 2, clientX: 510, clientY: 330, }); const wasNotCanceled = layerButton.dispatchEvent(rightPointerDown); expect(wasNotCanceled).toBe(true); expect(rightPointerDown.defaultPrevented).toBe(false); fireEvent.contextMenu(layerButton, { clientX: 510, clientY: 330, }); expect(screen.getByRole('menu', { name: '图片功能面板' })).toBeTruthy(); expect(screen.getByRole('menuitem', { name: '创建副本' })).toBeTruthy(); }); it('copies, cuts, and pastes layers from the context menus', () => { render(); fireEvent.contextMenu( screen.getByAltText('画布图片:拼图素材').closest('button')!, { clientX: 510, clientY: 330, }, ); fireEvent.click(screen.getByRole('menuitem', { name: '复制' })); fireEvent.contextMenu(screen.getByLabelText('画布工作区'), { clientX: 360, clientY: 240, }); const copyPasteMenu = screen.getByRole('menu', { name: '画布右键菜单' }); expect( ( within(copyPasteMenu).getByRole('menuitem', { name: '粘贴', }) as HTMLButtonElement ).disabled, ).toBe(false); fireEvent.click( within(copyPasteMenu).getByRole('menuitem', { name: '粘贴' }), ); expect(screen.getAllByAltText(/画布图片:拼图素材/u)).toHaveLength(2); fireEvent.contextMenu( screen.getByAltText('画布图片:大鱼素材').closest('button')!, { clientX: 950, clientY: 380, }, ); fireEvent.click(screen.getByRole('menuitem', { name: '剪切' })); expect(screen.queryByAltText('画布图片:大鱼素材')).toBeNull(); fireEvent.contextMenu(screen.getByLabelText('画布工作区'), { clientX: 420, clientY: 260, }); fireEvent.click(screen.getByRole('menuitem', { name: '粘贴' })); expect(screen.getByAltText('画布图片:大鱼素材')).toBeTruthy(); }); it('handles layer context menu duplicate, ordering, hide, lock, flip, group, ungroup, and delete', async () => { render(); const firstLayer = screen .getByAltText('画布图片:拼图素材') .closest('button')!; fireEvent.contextMenu(firstLayer, { clientX: 510, clientY: 330 }); fireEvent.click(screen.getByRole('menuitem', { name: '创建副本' })); expect(screen.getAllByAltText(/画布图片:拼图素材/u)).toHaveLength(2); const copiedLayer = screen .getAllByAltText(/画布图片:拼图素材/u)[1]! .closest('button')!; fireEvent.contextMenu(copiedLayer, { clientX: 540, clientY: 360 }); fireEvent.click(screen.getByRole('menuitem', { name: '水平翻转' })); expect( (screen.getAllByAltText(/画布图片:拼图素材/u)[1] as HTMLElement).style .transform, ).toBe('scale(-1, 1)'); fireEvent.contextMenu(copiedLayer, { clientX: 540, clientY: 360 }); fireEvent.click(screen.getByRole('menuitem', { name: '锁定' })); await waitFor(() => { expect(copiedLayer.className).toContain( 'image-canvas-editor__layer--locked', ); }); fireEvent.contextMenu(copiedLayer, { clientX: 540, clientY: 360 }); fireEvent.click(screen.getByRole('menuitem', { name: '隐藏' })); expect(screen.getAllByAltText(/画布图片:拼图素材/u)).toHaveLength(1); fireEvent.click(screen.getByRole('button', { name: '打开图层' })); expect(screen.getByText(/已隐藏/u)).toBeTruthy(); fireEvent.contextMenu( screen.getByText(/已隐藏/u).closest('.image-canvas-editor__layer-row')!, { clientX: 80, clientY: 220, }, ); fireEvent.click(screen.getByRole('menuitem', { name: '显示' })); expect(screen.getAllByAltText(/画布图片:拼图素材/u)).toHaveLength(2); const bigFishLayer = screen .getByAltText('画布图片:大鱼素材') .closest('button')!; fireEvent.contextMenu(bigFishLayer, { clientX: 950, clientY: 380 }); fireEvent.click(screen.getByRole('menuitem', { name: '置于顶层' })); expect(Number.parseInt(bigFishLayer.style.zIndex, 10)).toBeGreaterThan(2); fireEvent.contextMenu(bigFishLayer, { clientX: 950, clientY: 380 }); fireEvent.click(screen.getByRole('menuitem', { name: '下移一层' })); expect(Number.parseInt(bigFishLayer.style.zIndex, 10)).toBeGreaterThan(0); fireEvent.keyDown(window, { key: 'Shift', code: 'ShiftLeft' }); fireEvent.pointerDown( screen.getByAltText('画布图片:拼图素材').closest('button')!, { button: 0, pointerId: 181, clientX: 520, clientY: 380, shiftKey: true, }, ); fireEvent.pointerUp(screen.getByLabelText('画布工作区'), { pointerId: 181, clientX: 520, clientY: 380, }); fireEvent.keyUp(window, { key: 'Shift', code: 'ShiftLeft' }); fireEvent.contextMenu(bigFishLayer, { clientX: 950, clientY: 380 }); fireEvent.click(screen.getByRole('menuitem', { name: '创建组' })); await waitFor(() => { expect(screen.getAllByText(/已打组/u).length).toBeGreaterThan(0); }); fireEvent.contextMenu(bigFishLayer, { clientX: 950, clientY: 380 }); fireEvent.click(screen.getByRole('menuitem', { name: '解除组' })); await waitFor(() => { expect(screen.queryByText(/已打组/u)).toBeNull(); }); fireEvent.contextMenu(bigFishLayer, { clientX: 950, clientY: 380 }); fireEvent.click(screen.getByRole('menuitem', { name: '删除' })); expect(screen.queryByAltText('画布图片:大鱼素材')).toBeNull(); }); 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.getByRole('button', { 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: '当前缩放比例 100%' }).className, ).toContain('platform-inline-option-button'); fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 100%' })); fireEvent.click(screen.getByRole('menuitem', { name: '放大' })); expect( screen.getByRole('button', { name: '当前缩放比例 116%' }), ).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' })); expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy(); expect( screen.getByRole('complementary', { name: '图片资源栏' }), ).toBeTruthy(); }); it('saves canvas layout without embedding image payloads in layer snapshots', async () => { loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [ { folderId: 'project', label: '项目素材', sortOrder: 0, collapsed: false, systemDefault: true, }, ], assets: [ { assetId: 'asset-data-heavy', folderId: 'project', label: '大图素材', imageSrc: 'data:image/png;base64,'.concat('a'.repeat(4000)), width: 1024, height: 768, sourceType: 'uploaded', }, ], }); render(); await screen.findByRole('button', { name: '添加大图素材' }); fireEvent.click(screen.getByRole('button', { name: '添加大图素材' })); await waitFor(() => { expect(saveEditorProjectLayoutMock).toHaveBeenCalled(); }); const lastLayout = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1]; expect(lastLayout.layers).toEqual( expect.arrayContaining([ expect.not.objectContaining({ src: expect.stringMatching(/^data:image/u), }), ]), ); expect(lastLayout.layers).toEqual( expect.arrayContaining([ expect.objectContaining({ sourceAssetId: 'asset-data-heavy', }), ]), ); }); it('offers Lovart-style zoom menu commands', async () => { render(); fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 100%' })); expect(screen.getByRole('menu', { name: '缩放菜单' })).toBeTruthy(); expect( screen.getByRole('menuitem', { name: '显示画布所有元素' }), ).toBeTruthy(); fireEvent.click(screen.getByRole('menuitem', { name: '缩放至100%' })); expect( screen.getByRole('button', { name: /当前缩放比例 \d+%/u }), ).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 settings panel', () => { render(); const viewport = screen.getByLabelText('画布工作区'); const panelToolbar = screen.getByRole('toolbar', { name: '画布面板入口' }); const backgroundButton = within(panelToolbar).getByRole('button', { name: '画布背景色', }); expect(screen.getByRole('button', { name: '画布小地图' })).toBeTruthy(); expect(backgroundButton.className).toContain('platform-icon-button'); expect( within(panelToolbar).getByRole('button', { name: '切换小地图' }), ).toBeTruthy(); fireEvent.click(backgroundButton); const settingsPanel = screen.getByRole('dialog', { name: '画布背景设置', }); expect(within(settingsPanel).getByText('画布背景')).toBeTruthy(); expect(within(settingsPanel).getByLabelText('画布背景色相')).toBeTruthy(); expect( within(settingsPanel).getByLabelText('画布背景十六进制颜色'), ).toBeTruthy(); fireEvent.click( within(settingsPanel).getByRole('button', { name: '暖灰' }), ); expect((viewport as HTMLElement).style.backgroundColor).toBe( 'rgb(243, 240, 234)', ); fireEvent.change(within(settingsPanel).getByLabelText('自定义画布背景色'), { target: { value: '#ffffff' }, }); expect((viewport as HTMLElement).style.backgroundColor).toBe( 'rgb(255, 255, 255)', ); const hexInput = within(settingsPanel).getByLabelText('画布背景十六进制颜色'); fireEvent.change(hexInput, { target: { value: '#abc' } }); expect((hexInput as HTMLInputElement).value).toBe('#aabbcc'); expect((viewport as HTMLElement).style.backgroundColor).toBe( 'rgb(170, 187, 204)', ); fireEvent.change(hexInput, { target: { value: '#not-a-color' } }); expect((hexInput as HTMLInputElement).value).toBe('#not-a-color'); expect((viewport as HTMLElement).style.backgroundColor).toBe( 'rgb(170, 187, 204)', ); fireEvent.click( within(settingsPanel).getByRole('button', { name: '恢复默认' }), ); expect((viewport as HTMLElement).style.backgroundColor).toBe( 'rgb(248, 250, 252)', ); fireEvent.keyDown(window, { key: 'Escape' }); expect(screen.queryByRole('dialog', { name: '画布背景设置' })).toBeNull(); fireEvent.click( within(panelToolbar).getByRole('button', { name: '切换小地图' }), ); expect(screen.queryByRole('button', { name: '画布小地图' })).toBeNull(); }); it('resets the canvas view without forwarding the click event to fit layers', () => { render(); expect(() => { fireEvent.click(screen.getByRole('button', { name: '重置画布视图' })); }).not.toThrow(); }); it('uses normal wheel for vertical canvas scroll and ctrl wheel for zoom', () => { render(); const viewport = screen.getByLabelText('画布工作区'); expect( screen.getByRole('button', { name: '当前缩放比例 100%' }), ).toBeTruthy(); fireEvent.wheel(viewport, { deltaY: 120, clientX: 400, clientY: 280 }); expect( screen.getByRole('button', { name: '当前缩放比例 100%' }), ).toBeTruthy(); fireEvent.wheel(viewport, { deltaY: -120, ctrlKey: true, clientX: 400, clientY: 280, }); expect( screen.getByRole('button', { name: '当前缩放比例 110%' }), ).toBeTruthy(); const ctrlWheelEvent = new WheelEvent('wheel', { bubbles: true, cancelable: true, ctrlKey: true, deltaY: -120, clientX: 400, clientY: 280, }); viewport.dispatchEvent(ctrlWheelEvent); expect(ctrlWheelEvent.defaultPrevented).toBe(true); }); it('selects multiple canvas layers with shift click', async () => { render(); const firstLayer = screen .getByAltText('画布图片:拼图素材') .closest('button')!; const secondLayer = screen .getByAltText('画布图片:大鱼素材') .closest('button')!; fireEvent.pointerDown(firstLayer, { button: 0, pointerId: 81, clientX: 120, clientY: 120, }); fireEvent.pointerUp(screen.getByLabelText('画布工作区'), { pointerId: 81, clientX: 120, clientY: 120, }); await waitFor(() => { expect( screen.getByAltText('画布图片:拼图素材').closest('button')?.className, ).toContain('image-canvas-editor__layer--selected'); }); fireEvent.keyDown(window, { key: 'Shift', code: 'ShiftLeft' }); fireEvent.pointerDown(secondLayer, { button: 0, pointerId: 82, clientX: 520, clientY: 180, shiftKey: true, }); fireEvent.pointerUp(screen.getByLabelText('画布工作区'), { pointerId: 82, clientX: 520, clientY: 180, }); fireEvent.keyUp(window, { key: 'Shift', code: 'ShiftLeft' }); await waitFor(() => { expect( screen.getByAltText('画布图片:拼图素材').closest('button')?.className, ).toContain('image-canvas-editor__layer--selected'); expect( screen.getByAltText('画布图片:大鱼素材').closest('button')?.className, ).toContain('image-canvas-editor__layer--selected'); }); }); 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('keeps minimap drag direction stable after pausing and reversing', () => { 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: () => ({}), }); const world = screen .getByLabelText('画布工作区') .querySelector('.image-canvas-editor__world') as HTMLElement; const readTranslateX = () => { const match = /translate\(([-\d.]+)px,/u.exec(world.style.transform); return match ? Number(match[1]) : 0; }; dispatchPointerEvent(minimap, 'pointerdown', { button: 0, pointerId: 72, clientX: 60, clientY: 42, }); dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', { button: 0, pointerId: 72, clientX: 120, clientY: 42, }); const translateAfterRightDrag = readTranslateX(); dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', { button: 0, pointerId: 72, clientX: 90, clientY: 42, }); expect(readTranslateX()).toBeGreaterThan(translateAfterRightDrag); }); it('persists layer groups in the canvas layer snapshot', async () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开图层' })); fireEvent.pointerDown( screen.getByAltText('画布图片:拼图素材').closest('button')!, { button: 0, pointerId: 90, clientX: 120, clientY: 120, }, ); fireEvent.pointerUp(screen.getByLabelText('画布工作区'), { pointerId: 90, clientX: 120, clientY: 120, }); await waitFor(() => { expect( screen.getByAltText('画布图片:拼图素材').closest('button')?.className, ).toContain('image-canvas-editor__layer--selected'); }); fireEvent.keyDown(window, { key: 'Shift', code: 'ShiftLeft' }); fireEvent.pointerDown( screen.getByAltText('画布图片:大鱼素材').closest('button')!, { button: 0, pointerId: 91, clientX: 520, clientY: 180, shiftKey: true, }, ); fireEvent.pointerUp(screen.getByLabelText('画布工作区'), { pointerId: 91, clientX: 520, clientY: 180, }); fireEvent.keyUp(window, { key: 'Shift', code: 'ShiftLeft' }); await waitFor(() => { expect( screen.getByAltText('画布图片:拼图素材').closest('button')?.className, ).toContain('image-canvas-editor__layer--selected'); expect( screen.getByAltText('画布图片:大鱼素材').closest('button')?.className, ).toContain('image-canvas-editor__layer--selected'); }); fireEvent.click(screen.getByRole('button', { name: '图层打组' })); await waitFor(() => { expect(screen.getAllByText(/已打组/u)).toHaveLength(2); }); await waitFor(() => { expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( 'editor-project-default', expect.objectContaining({ layers: expect.arrayContaining([ expect.objectContaining({ title: '拼图素材', groupId: expect.stringMatching(/^layer-group-/u), }), 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.getByRole('toolbar', { name: 'AI画布工具栏' })).toBeTruthy(); 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(); fireEvent.click(metadataButtons[0]!); const infoPanel = screen.getByRole('dialog', { name: /生成图片 .*图片信息/, }); expect(within(infoPanel).queryByText('Prompt')).toBeNull(); expect( within(infoPanel).queryByRole('button', { name: '复制Prompt' }), ).toBeNull(); expect(within(infoPanel).getByText('生成输入')).toBeTruthy(); expect(within(infoPanel).getByText('生成提示词')).toBeTruthy(); expect(within(infoPanel).getByText('一张明亮的拼图主视觉')).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); const draggedFrame = screen.getByLabelText('图像生成占位图') as HTMLElement; const draggedFrameCenterX = Number.parseFloat(draggedFrame.style.left) + Number.parseFloat(draggedFrame.style.width) / 2; const draggedFrameCenterY = Number.parseFloat(draggedFrame.style.top) + Number.parseFloat(draggedFrame.style.height) / 2; 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) + Number.parseFloat((generatedLayer as HTMLElement).style.width) / 2, ).toBeCloseTo(draggedFrameCenterX, 1); expect( Number.parseFloat((generatedLayer as HTMLElement).style.top) + Number.parseFloat((generatedLayer as HTMLElement).style.height) / 2, ).toBeCloseTo(draggedFrameCenterY, 1); }); it('keeps the generation placeholder draggable while the image is generating', async () => { let resolveGeneration!: (value: unknown) => void; generateEditorImageMock.mockReturnValueOnce( new Promise((resolve) => { resolveGeneration = resolve; }), ); render(); await waitFor(() => { expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled(); }); fireEvent.click(screen.getByRole('button', { name: '生成工具' })); fireEvent.change(screen.getByLabelText('生成提示词'), { target: { value: '生成中继续拖动的图片' }, }); fireEvent.click(screen.getByRole('button', { name: '生成' })); const frame = screen.getByLabelText('图像生成占位图'); expect(frame.className).toContain( 'image-canvas-editor__generation-frame--generating', ); const initialLeft = Number.parseFloat((frame as HTMLElement).style.left); const initialTop = Number.parseFloat((frame as HTMLElement).style.top); dispatchPointerEvent(frame, 'pointerdown', { button: 0, pointerId: 67, clientX: 500, clientY: 260, }); dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', { pointerId: 67, clientX: 620, clientY: 360, }); dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointerup', { pointerId: 67, clientX: 620, clientY: 360, }); const draggedFrame = screen.getByLabelText('图像生成占位图'); expect( Number.parseFloat((draggedFrame as HTMLElement).style.left), ).toBeGreaterThan(initialLeft); expect( Number.parseFloat((draggedFrame as HTMLElement).style.top), ).toBeGreaterThan(initialTop); await act(async () => { resolveGeneration({ imageSrc: 'data:image/png;base64,Z2VuZXJhdGluZy1kcmFn', width: 1024, height: 1024, sourceType: 'generated', prompt: '生成中继续拖动的图片', actualPrompt: '生成中继续拖动的图片', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'editor-generating-drag-1', }); }); await waitFor(() => { expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy(); }); const generatedLayer = screen .getByAltText(/画布图片:生成图片/) .closest('button')!; expect( Number.parseFloat((generatedLayer as HTMLElement).style.left) + Number.parseFloat((generatedLayer as HTMLElement).style.width) / 2, ).toBeCloseTo( Number.parseFloat((draggedFrame as HTMLElement).style.left) + Number.parseFloat((draggedFrame as HTMLElement).style.width) / 2, 1, ); expect( Number.parseFloat((generatedLayer as HTMLElement).style.top) + Number.parseFloat((generatedLayer as HTMLElement).style.height) / 2, ).toBeCloseTo( Number.parseFloat((draggedFrame as HTMLElement).style.top) + Number.parseFloat((draggedFrame as HTMLElement).style.height) / 2, 1, ); }); it('hides the generation composer when selecting another image but keeps the placeholder', () => { 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.queryByRole('dialog', { name: '生成图片' })).toBeNull(); expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); fireEvent.pointerDown(screen.getByLabelText('图像生成占位图'), { button: 0, pointerId: 64, clientX: 300, clientY: 180, }); expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy(); }); it('hides 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.queryByRole('dialog', { name: '生成图片' })).toBeNull(); expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); }); it('closes the generation composer without removing the placeholder frame', () => { render(); fireEvent.click(screen.getByRole('button', { name: '生成工具' })); fireEvent.click(screen.getByRole('button', { name: '关闭生成图片' })); expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull(); 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('hides image generation setting panels after generation starts while keeping the preview frame visible', async () => { const cases = [ { open: () => { fireEvent.click(screen.getByRole('button', { name: '生成工具' })); fireEvent.change(screen.getByLabelText('生成提示词'), { target: { value: '生成中的普通图片' }, }); fireEvent.click(screen.getByRole('button', { name: '生成' })); }, dialogName: '生成图片', frameLabel: '图像生成占位图', }, { open: () => { fireEvent.click( within( screen.getByRole('toolbar', { name: 'AI画布工具栏' }), ).getByRole('button', { name: '生成规范' }), ); fireEvent.click( within( screen.getByRole('menu', { name: '生成规范类型' }), ).getByRole('menuitem', { name: '自定义规范' }), ); fireEvent.change(screen.getByLabelText('自定义规范提示词'), { target: { value: '生成中的自定义规范图' }, }); fireEvent.click( within(screen.getByRole('dialog', { name: '生成规范' })).getByRole( 'button', { name: '提交生成规范' }, ), ); }, dialogName: '生成规范', frameLabel: '规范生成占位图', }, { open: () => { fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); fireEvent.change(screen.getByLabelText('角色设定'), { target: { value: '生成中的角色形象' }, }); fireEvent.click(screen.getByRole('button', { name: '生成' })); }, dialogName: '生成角色形象', frameLabel: '角色生成占位图', }, ] as const; for (const testCase of cases) { generateEditorImageMock.mockReturnValueOnce(new Promise(() => undefined)); const { unmount } = render(); await waitFor(() => { expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled(); }); testCase.open(); expect( screen.queryByRole('dialog', { name: testCase.dialogName }), ).toBeNull(); const frame = screen.getByLabelText(testCase.frameLabel); expect(frame.className).toContain( 'image-canvas-editor__generation-frame--generating', ); expect(within(frame).getByRole('status').textContent).toContain('生成中'); unmount(); } }); it('hides the icon material panel after generation starts while keeping the icon preview frame visible', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-icons-generating', title: '图标素材生成中画布', viewport: { x: 0, y: 0, scale: 1 }, layers: [ { layerId: 'layer-icon-spec-generating', resourceId: 'resource-icon-spec-generating', title: '清爽按钮图标规范', src: 'data:image/png;base64,icon-spec-generating', x: 80, y: 80, width: 160, height: 160, originalWidth: 512, originalHeight: 512, zIndex: 10, sourceType: 'generated', assetKind: 'icon-spec', }, ], resources: [], updatedAt: '2026-06-12T00:00:00.000Z', }); generateEditorIconSpritesheetMock.mockReturnValueOnce( new Promise(() => undefined), ); render(); await screen.findByAltText('画布图片:清爽按钮图标规范'); fireEvent.click(screen.getByRole('button', { name: '生成图标素材' })); const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' }); fireEvent.click( within(iconPanel).getByRole('button', { name: '图标素材规范' }), ); fireEvent.click( within(screen.getByRole('menu', { name: '图标素材规范来源' })).getByRole( 'menuitem', { name: '从画布中选择' }, ), ); fireEvent.pointerDown( screen.getByAltText('画布图片:清爽按钮图标规范').closest('button')!, { button: 0, pointerId: 1260, clientX: 120, clientY: 120, }, ); fireEvent.click( within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole( 'button', { name: '生成' }, ), ); expect(screen.queryByRole('dialog', { name: '生成图标素材' })).toBeNull(); const frame = screen.getByLabelText('图标素材生成占位图'); expect(frame.className).toContain( 'image-canvas-editor__generation-frame--generating', ); expect(within(frame).getByRole('status').textContent).toContain('生成中'); }); it('opens character spec generation form and creates a labeled spec layer', async () => { generateEditorImageMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,c3BlYy1yb2xl', width: 2048, height: 1152, sourceType: 'generated', prompt: '角色规范提示词', actualPrompt: '角色规范提示词', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'editor-spec-role-1', }); render(); const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' }); const generationToolLabels = within(bottomToolbar) .getAllByRole('button') .filter((button) => button.getAttribute('aria-label')?.startsWith('生成')) .map((button) => button.getAttribute('aria-label')); expect(generationToolLabels).toContain('生成工具'); expect(generationToolLabels).toContain('生成规范'); fireEvent.click( within(bottomToolbar).getByRole('button', { name: '生成规范' }), ); const specMenu = screen.getByRole('menu', { name: '生成规范类型' }); expect( within(specMenu).getByRole('menuitem', { name: '角色形象规范' }), ).toBeTruthy(); expect( within(specMenu).getByRole('menuitem', { name: 'UI素材规范' }), ).toBeTruthy(); expect( within(specMenu).getByRole('menuitem', { name: '自定义规范' }), ).toBeTruthy(); fireEvent.click( within(specMenu).getByRole('menuitem', { name: '角色形象规范' }), ); const specDialog = screen.getByRole('dialog', { name: '生成规范' }); expect(screen.getByLabelText('规范生成占位图')).toBeTruthy(); expect(screen.getByText('2048 x 1152')).toBeTruthy(); expect((screen.getByLabelText('玩法设定') as HTMLInputElement).value).toBe( '战棋类RPG玩法', ); expect((screen.getByLabelText('美术风格') as HTMLInputElement).value).toBe( '像素风', ); expect((screen.getByLabelText('头身比') as HTMLSelectElement).value).toBe( '3', ); expect((screen.getByLabelText('角色视角') as HTMLInputElement).value).toBe( '右向斜侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯右视图,也禁止生成正面立绘。', ); expect( within(specDialog).getByRole('button', { name: '提交生成规范' }) .textContent, ).toContain('消耗5泥点'); fireEvent.change(screen.getByLabelText('玩法设定'), { target: { value: '平台跳跃玩法' }, }); fireEvent.change(screen.getByLabelText('美术风格'), { target: { value: '低多边形卡通' }, }); fireEvent.change(screen.getByLabelText('头身比'), { target: { value: '4' }, }); fireEvent.change(screen.getByLabelText('角色视角'), { target: { value: '左向三分之二侧身站姿' }, }); fireEvent.click( within(specDialog).getByRole('button', { name: '提交生成规范' }), ); expect(generateEditorImageMock).toHaveBeenCalledWith({ kind: 'spec', model: 'gemini-3.1-flash-image-preview', size: '2048x1152', prompt: expect.stringContaining('玩法设计:平台跳跃玩法'), }); const prompt = generateEditorImageMock.mock.calls[0]?.[0]?.prompt ?? ''; expect(prompt).toContain('生成2D 角色美术视觉规范设定图'); expect(prompt).toContain('美术风格:低多边形卡通'); expect(prompt).toContain('头身比:4'); expect(prompt).toContain('视角要求:左向三分之二侧身站姿'); await waitFor(() => { expect(screen.getByAltText(/画布图片:角色形象规范/)).toBeTruthy(); }); expect(screen.getByText('规范')).toBeTruthy(); await waitFor(() => { expect(createEditorProjectResourceMock).toHaveBeenCalledWith( 'editor-project-default', expect.objectContaining({ sourceType: 'generated', width: 2048, height: 1152, }), ); }); await waitFor(() => { expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( 'editor-project-default', expect.objectContaining({ layers: expect.arrayContaining([ expect.objectContaining({ title: expect.stringMatching(/角色形象规范/u), assetKind: 'spec', }), ]), }), ); }); }); it('shows visible titles for character spec, icon spec, and icon spritesheet generation fields', async () => { render(); await screen.findByAltText('画布图片:拼图素材'); fireEvent.click( within(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).getByRole( 'button', { name: '生成规范' }, ), ); fireEvent.click(screen.getByRole('menuitem', { name: '角色形象规范' })); const characterSpecDialog = screen.getByRole('dialog', { name: '生成规范', }); ['玩法设定', '美术风格', '头身比', '角色视角'].forEach((title) => { expect(within(characterSpecDialog).getByText(title)).toBeTruthy(); }); fireEvent.click(screen.getByRole('button', { name: '生成图标素材' })); const iconSpritesheetPanel = screen.getByRole('dialog', { name: '生成图标素材', }); expect( within(iconSpritesheetPanel).getByRole('button', { name: '图标素材规范', }), ).toBeTruthy(); expect(within(iconSpritesheetPanel).getByText('素材描述')).toBeTruthy(); expect(within(iconSpritesheetPanel).getByText('素材描述 1')).toBeTruthy(); expect(within(iconSpritesheetPanel).getByText('素材描述 6')).toBeTruthy(); expect(within(iconSpritesheetPanel).getByText('模型')).toBeTruthy(); fireEvent.click( within(iconSpritesheetPanel).getByRole('button', { name: '图标素材规范', }), ); fireEvent.click(screen.getByRole('menuitem', { name: '新建图标素材规范' })); const iconSpecDialog = screen.getByRole('dialog', { name: '生成规范' }); ['玩法设定', '美术风格'].forEach((title) => { expect(within(iconSpecDialog).getByText(title)).toBeTruthy(); }); }); it('defaults character and icon generation to nanobanana2 model options', async () => { render(); await screen.findByAltText('画布图片:拼图素材'); fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); const characterPanel = screen.getByRole('dialog', { name: '生成角色形象', }); expect(within(characterPanel).getByText('画面比例')).toBeTruthy(); expect(within(characterPanel).getByText('大小尺寸')).toBeTruthy(); expect(within(characterPanel).getByText('模型')).toBeTruthy(); expect( within(characterPanel).getByRole('button', { name: '1:1' }), ).toBeTruthy(); expect( within(characterPanel).getByRole('button', { name: '1K' }), ).toBeTruthy(); expect( within(characterPanel).getByRole('button', { name: 'nanobanana2' }), ).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '生成图标素材' })); const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' }); expect(within(iconPanel).getByText('画面比例')).toBeTruthy(); expect(within(iconPanel).getByText('大小尺寸')).toBeTruthy(); expect(within(iconPanel).getByText('模型')).toBeTruthy(); expect( within(iconPanel).getByRole('button', { name: 'nanobanana2' }), ).toBeTruthy(); }); it('submits character generation with default model and dimension options', async () => { generateEditorImageMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,character-model-options', width: 1024, height: 1536, sourceType: 'generated', prompt: '高个子游侠', actualPrompt: '高个子游侠', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'character-model-options-1', }); render(); await screen.findByAltText('画布图片:拼图素材'); fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); const characterPanel = screen.getByRole('dialog', { name: '生成角色形象', }); fireEvent.change(within(characterPanel).getByLabelText('角色设定'), { target: { value: '高个子游侠' }, }); fireEvent.click( within(characterPanel).getByRole('button', { name: '生成' }), ); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ kind: 'character', prompt: '高个子游侠', model: 'gemini-3.1-flash-image-preview', aspectRatio: '1:1', imageSize: '1K', }), ); }); }); it('remembers the last selected image model for character and icon generation', async () => { generateEditorImageMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,character-gpt-model', width: 1024, height: 1024, sourceType: 'generated', prompt: '蓝衣剑士', actualPrompt: '蓝衣剑士', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'character-gpt-model-1', }); generateEditorIconSpritesheetMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,sheet-gpt-model', spritesheetWidth: 1024, spritesheetHeight: 1024, iconImageSrcs: [ { name: '返回按钮', imageSrc: 'data:image/png;base64,back', width: 128, height: 128, }, ], prompt: '图标 prompt', actualPrompt: '图标 prompt', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'icon-gpt-model-1', }); render(); await screen.findByAltText('画布图片:拼图素材'); fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); const characterPanel = screen.getByRole('dialog', { name: '生成角色形象', }); fireEvent.click( within(characterPanel).getByRole('button', { name: 'gpt-image-2' }), ); fireEvent.click(within(characterPanel).getByRole('button', { name: '2:3' })); fireEvent.click(within(characterPanel).getByRole('button', { name: '2K' })); fireEvent.change(within(characterPanel).getByLabelText('角色设定'), { target: { value: '蓝衣剑士' }, }); fireEvent.click( within(characterPanel).getByRole('button', { name: '生成' }), ); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ kind: 'character', prompt: '蓝衣剑士', model: 'gpt-image-2', aspectRatio: '2:3', imageSize: '2K', }), ); }); fireEvent.click(screen.getByRole('button', { name: '生成图标素材' })); const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' }); expect( within(iconPanel).getByRole('button', { name: 'gpt-image-2' }), ).toBeTruthy(); fireEvent.click( within(iconPanel).getByRole('button', { name: '图标素材规范' }), ); fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' })); await userEvent.upload( screen.getByLabelText('上传图片文件'), new File(['icon-spec'], '图标规范.png', { type: 'image/png' }), ); fireEvent.click( within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole( 'button', { name: '生成' }, ), ); await waitFor(() => { expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith( expect.objectContaining({ model: 'gpt-image-2', aspectRatio: '1:1', imageSize: '1K', }), ); }); }); it('keeps the bottom AI toolbar visible while generation panels are open', () => { render(); fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy(); expect(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).toBeTruthy(); }); it('keeps existing generation placeholders when another bottom generation object is created', async () => { render(); await act(async () => {}); const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏', }); fireEvent.click( within(bottomToolbar).getByRole('button', { name: '生成规范' }), ); fireEvent.click(screen.getByRole('menuitem', { name: '角色形象规范' })); expect(screen.getByLabelText('规范生成占位图')).toBeTruthy(); expect(screen.getByRole('dialog', { name: '生成规范' })).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); expect(screen.getByLabelText('规范生成占位图')).toBeTruthy(); expect(screen.getByLabelText('角色生成占位图')).toBeTruthy(); expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy(); fireEvent.pointerDown(screen.getByLabelText('规范生成占位图'), { button: 0, pointerId: 1701, clientX: 180, clientY: 180, }); expect(screen.getByRole('dialog', { name: '生成规范' })).toBeTruthy(); expect(screen.getByLabelText('角色生成占位图')).toBeTruthy(); }); it('keeps archived generation logic using the latest placeholder when another object is active', async () => { let resolveGeneration!: (value: unknown) => void; generateEditorImageMock.mockReturnValueOnce( new Promise((resolve) => { resolveGeneration = resolve; }), ); render(); await waitFor(() => { expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled(); }); fireEvent.click(screen.getByRole('button', { name: '生成工具' })); fireEvent.change(screen.getByLabelText('生成提示词'), { target: { value: '生成中切换后仍保留位置' }, }); fireEvent.click(screen.getByRole('button', { name: '生成' })); const originalFrame = screen.getByLabelText('图像生成占位图'); const originalLeft = Number.parseFloat( (originalFrame as HTMLElement).style.left, ); const originalTop = Number.parseFloat( (originalFrame as HTMLElement).style.top, ); fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); const characterFrame = screen.getByLabelText('角色生成占位图'); expect(characterFrame).toBeTruthy(); dispatchPointerEvent( screen.getByLabelText('图像生成占位图'), 'pointerdown', { button: 0, pointerId: 1702, clientX: 500, clientY: 260, }, ); dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', { pointerId: 1702, clientX: 650, clientY: 390, }); dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointerup', { pointerId: 1702, clientX: 650, clientY: 390, }); const movedFrame = screen.getByLabelText('图像生成占位图'); const movedLeft = Number.parseFloat((movedFrame as HTMLElement).style.left); const movedTop = Number.parseFloat((movedFrame as HTMLElement).style.top); expect(movedLeft).toBeGreaterThan(originalLeft); expect(movedTop).toBeGreaterThan(originalTop); dispatchPointerEvent(characterFrame, 'pointerdown', { button: 0, pointerId: 1703, clientX: 360, clientY: 240, }); dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointerup', { pointerId: 1703, clientX: 360, clientY: 240, }); expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy(); await act(async () => { resolveGeneration({ imageSrc: 'data:image/png;base64,YXJjaGl2ZWQtbG9naWM=', width: 1024, height: 1024, sourceType: 'generated', prompt: '生成中切换后仍保留位置', actualPrompt: '生成中切换后仍保留位置', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'editor-archived-generation-1', }); }); await waitFor(() => { expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy(); }); const generatedLayer = screen .getByAltText(/画布图片:生成图片/) .closest('button') as HTMLElement; const expectedLayerLeft = movedLeft + Number.parseFloat((movedFrame as HTMLElement).style.width) / 2 - 512; const expectedLayerTop = movedTop + Number.parseFloat((movedFrame as HTMLElement).style.height) / 2 - 512; expect(Number.parseFloat(generatedLayer.style.left)).toBeCloseTo( expectedLayerLeft, 1, ); expect(Number.parseFloat(generatedLayer.style.top)).toBeCloseTo( expectedLayerTop, 1, ); expect(screen.getByLabelText('角色生成占位图')).toBeTruthy(); }); it('renders editor popup menus outside clipped local containers', () => { render(); const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' }); fireEvent.click( within(bottomToolbar).getByRole('button', { name: '生成规范' }), ); const specMenu = screen.getByRole('menu', { name: '生成规范类型' }); expect(bottomToolbar.contains(specMenu)).toBe(false); fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); const characterPanel = screen.getByRole('dialog', { name: '生成角色形象' }); fireEvent.click( within(characterPanel).getByRole('button', { name: '角色形象规范' }), ); const referenceRow = characterPanel.querySelector( '.image-canvas-editor__character-reference-row', ); const sourceMenu = screen.getByRole('menu', { name: '角色形象规范来源' }); expect(referenceRow?.contains(sourceMenu)).toBe(false); expect(sourceMenu.className).toContain('platform-floating-menu--top-start'); fireEvent.click( within(characterPanel).getByRole('button', { name: '上传常规参考图' }), ); const regularReferenceMenu = screen.getByRole('menu', { name: '常规参考图来源', }); expect(referenceRow?.contains(regularReferenceMenu)).toBe(false); expect(regularReferenceMenu.className).toContain( 'platform-floating-menu--top-start', ); }); it('uses Lovart-style reference tiles in the character generation panel', () => { render(); fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); const characterPanel = screen.getByRole('dialog', { name: '生成角色形象' }); const specTile = within(characterPanel).getByRole('button', { name: '角色形象规范', }); const uploadTile = within(characterPanel).getByRole('button', { name: '上传常规参考图', }); expect(specTile.className).toContain('image-canvas-editor__reference-tile'); expect(uploadTile.className).toContain( 'image-canvas-editor__reference-tile', ); expect( specTile.querySelector('.image-canvas-editor__reference-tile-visual'), ).toBeTruthy(); expect( uploadTile.querySelector('.image-canvas-editor__reference-tile-visual'), ).toBeTruthy(); }); it('expands the icon panel width as new description items are added', async () => { render(); await waitFor(() => { expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled(); }); fireEvent.click(screen.getByRole('button', { name: '生成图标素材' })); const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' }); expect(Number.parseFloat(iconPanel.style.width)).toBeCloseTo(52.8, 1); expect( iconPanel.querySelector('.image-canvas-editor__icon-description-list'), ).toBeTruthy(); expect( iconPanel.querySelector('.image-canvas-editor__icon-description-card'), ).toBeTruthy(); expect( iconPanel.querySelector('.image-canvas-editor__icon-spec-card'), ).toBeTruthy(); fireEvent.click( within(iconPanel).getByRole('button', { name: '添加素材描述' }), ); expect(Number.parseFloat(iconPanel.style.width)).toBeCloseTo(61.2, 1); expect(within(iconPanel).getAllByRole('textbox')).toHaveLength(7); }); it('hides the active generation panel and clears image selection after canvas background focus', async () => { generateEditorImageMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,Zm9jdXMtY2xlYXI=', width: 1024, height: 1024, sourceType: 'generated', prompt: '发光蘑菇角色', actualPrompt: '发光蘑菇角色', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'editor-focus-clear-1', }); render(); fireEvent.click(screen.getByRole('button', { name: '生成工具' })); fireEvent.change(screen.getByLabelText('生成提示词'), { target: { value: '发光蘑菇角色' }, }); fireEvent.click(screen.getByRole('button', { name: '生成' })); const generatedImage = await screen.findByAltText(/画布图片:生成图片/u); const generatedLayerButton = generatedImage.closest('button')!; expect(generatedLayerButton.className).toContain( 'image-canvas-editor__layer--selected', ); expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy(); fireEvent.pointerDown(screen.getByLabelText('画布工作区'), { button: 0, pointerId: 261, clientX: 40, clientY: 40, }); expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull(); expect(generatedLayerButton.className).not.toContain( 'image-canvas-editor__layer--selected', ); }); it('hides a newly created placeholder panel after canvas background focus', () => { render(); fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy(); expect(screen.getByLabelText('角色生成占位图')).toBeTruthy(); fireEvent.pointerDown(screen.getByLabelText('画布工作区'), { button: 0, pointerId: 262, clientX: 40, clientY: 40, }); expect(screen.queryByRole('dialog', { name: '生成角色形象' })).toBeNull(); expect(screen.getByLabelText('角色生成占位图')).toBeTruthy(); }); it('builds UI spec prompts from two fields and uses 2K landscape generation', async () => { generateEditorImageMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,c3BlYy11aQ==', width: 2048, height: 1152, sourceType: 'generated', prompt: 'UI规范提示词', actualPrompt: 'UI规范提示词', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'editor-spec-ui-1', }); render(); fireEvent.click( within(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).getByRole( 'button', { name: '生成规范' }, ), ); fireEvent.click(screen.getByRole('menuitem', { name: 'UI素材规范' })); expect((screen.getByLabelText('玩法设定') as HTMLInputElement).value).toBe( '抓娃娃题材的抓大鹅玩法', ); expect((screen.getByLabelText('美术风格') as HTMLInputElement).value).toBe( '毛茸茸', ); fireEvent.change(screen.getByLabelText('玩法设定'), { target: { value: '消除类派对玩法' }, }); fireEvent.change(screen.getByLabelText('美术风格'), { target: { value: '糖果玻璃拟物' }, }); fireEvent.click( within(screen.getByRole('dialog', { name: '生成规范' })).getByRole( 'button', { name: '提交生成规范' }, ), ); expect(generateEditorImageMock).toHaveBeenCalledWith({ kind: 'spec', model: 'gemini-3.1-flash-image-preview', size: '2048x1152', prompt: expect.stringContaining('生成一张完整游戏UI规范汇总设定展板'), }); const prompt = generateEditorImageMock.mock.calls[0]?.[0]?.prompt ?? ''; expect(prompt).toContain('玩法设定:消除类派对玩法'); expect(prompt).toContain('美术风格:糖果玻璃拟物'); await waitFor(() => { expect(screen.getByAltText(/画布图片:UI素材规范/)).toBeTruthy(); }); expect(screen.getByText('规范')).toBeTruthy(); }); it('uses the custom spec prompt without template rewriting', async () => { generateEditorImageMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,c3BlYy1jdXN0b20=', width: 2048, height: 1152, sourceType: 'generated', prompt: '自定义规范提示词', actualPrompt: '自定义规范提示词', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'editor-spec-custom-1', }); render(); fireEvent.click( within(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).getByRole( 'button', { name: '生成规范' }, ), ); fireEvent.click(screen.getByRole('menuitem', { name: '自定义规范' })); fireEvent.change(screen.getByLabelText('自定义规范提示词'), { target: { value: ' 生成一张武器图标规范展板 ' }, }); fireEvent.click( within(screen.getByRole('dialog', { name: '生成规范' })).getByRole( 'button', { name: '提交生成规范' }, ), ); expect(generateEditorImageMock).toHaveBeenCalledWith({ kind: 'spec', model: 'gemini-3.1-flash-image-preview', size: '2048x1152', prompt: '生成一张武器图标规范展板', }); await waitFor(() => { expect(screen.getByAltText(/画布图片:自定义规范/)).toBeTruthy(); }); expect(screen.getByText('规范')).toBeTruthy(); }); it('supports character generation from a picked canvas spec and numbered references', async () => { generateEditorImageMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,Y2hhcmFjdGVy', objectKey: 'generated-character-drafts/editor/character-images/editor-character-1/image.png', assetObjectId: 'asset-object-editor-character-1', width: 2048, height: 2048, sourceType: 'generated', prompt: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。', actualPrompt: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'editor-character-1', }); render(); fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); const characterPanel = screen.getByRole('dialog', { name: '生成角色形象' }); expect(screen.getByLabelText('角色生成占位图')).toBeTruthy(); expect( within(characterPanel).getByRole('button', { name: '角色形象规范' }), ).toBeTruthy(); fireEvent.click( within(characterPanel).getByRole('button', { name: '角色形象规范' }), ); const specSourceMenu = screen.getByRole('menu', { name: '角色形象规范来源', }); fireEvent.click( within(specSourceMenu).getByRole('menuitem', { name: '从画布中选择' }), ); expect( screen.getByText('请选择画布中的图片作为角色形象规范,按 Esc 退出'), ).toBeTruthy(); fireEvent.pointerDown( screen.getByAltText('画布图片:拼图素材').closest('button')!, { button: 0, pointerId: 170, clientX: 120, clientY: 120, }, ); expect(within(characterPanel).getByText('拼图素材')).toBeTruthy(); expect( screen.queryByText('请选择画布中的图片作为角色形象规范,按 Esc 退出'), ).toBeNull(); const canvasReferenceLayer = screen .getByAltText('画布图片:大鱼素材') .closest('button')!; expect(canvasReferenceLayer.className).not.toContain( 'image-canvas-editor__layer--selected', ); fireEvent.click( within(characterPanel).getByRole('button', { name: '上传常规参考图' }), ); const regularReferenceMenu = screen.getByRole('menu', { name: '常规参考图来源', }); fireEvent.click( within(regularReferenceMenu).getByRole('menuitem', { name: '从画布中选择', }), ); expect( screen.getByText('请选择画布中的图片作为常规参考图,按 Esc 退出'), ).toBeTruthy(); fireEvent.pointerDown(canvasReferenceLayer, { button: 0, pointerId: 171, clientX: 180, clientY: 120, }); expect( screen.queryByText('请选择画布中的图片作为常规参考图,按 Esc 退出'), ).toBeNull(); expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy(); expect(canvasReferenceLayer.className).not.toContain( 'image-canvas-editor__layer--selected', ); expect(within(characterPanel).getByText('1')).toBeTruthy(); fireEvent.click( within(characterPanel).getByRole('button', { name: '上传常规参考图' }), ); fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' })); await userEvent.upload( screen.getByLabelText('上传图片文件'), new File(['reference'], '常规参考.png', { type: 'image/png' }), ); await waitFor(() => { expect(within(characterPanel).getByText('2')).toBeTruthy(); }); fireEvent.change(within(characterPanel).getByLabelText('角色设定'), { target: { value: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。' }, }); fireEvent.click( within(characterPanel).getByRole('button', { name: '生成' }), ); expect(generateEditorImageMock).toHaveBeenCalledWith({ kind: 'character', prompt: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。', model: 'gemini-3.1-flash-image-preview', aspectRatio: '1:1', imageSize: '1K', referenceImageSrcs: [ '/creation-type-references/puzzle.webp', '/creation-type-references/big-fish.webp', expect.stringMatching(/^data:image\/png;base64,/u), ], }); await waitFor(() => { expect(screen.getByAltText(/画布图片:角色形象/u)).toBeTruthy(); }); expect(screen.getByText('角色')).toBeTruthy(); fireEvent.click( screen.getAllByRole('button', { name: /查看角色形象 .*图片信息/u, })[0]!, ); const characterInfoPanel = screen.getByRole('dialog', { name: /角色形象 .*图片信息/u, }); expect(within(characterInfoPanel).queryByText('Prompt')).toBeNull(); expect(within(characterInfoPanel).getByText('生成输入')).toBeTruthy(); expect(within(characterInfoPanel).getByText('角色设定')).toBeTruthy(); expect( within(characterInfoPanel).getByText( '银发游侠,蓝色披风,弓箭手,适合像素风战棋。', ), ).toBeTruthy(); expect(within(characterInfoPanel).getByText('角色形象规范')).toBeTruthy(); expect(within(characterInfoPanel).getByText('拼图素材')).toBeTruthy(); expect(within(characterInfoPanel).getByText('常规参考图 1')).toBeTruthy(); expect(within(characterInfoPanel).getByText('大鱼素材')).toBeTruthy(); expect(within(characterInfoPanel).getByText('常规参考图 2')).toBeTruthy(); expect(within(characterInfoPanel).getByText('常规参考.png')).toBeTruthy(); await waitFor(() => { expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( 'editor-project-default', expect.objectContaining({ layers: expect.arrayContaining([ expect.objectContaining({ title: expect.stringMatching(/角色形象/u), assetKind: 'character', objectKey: 'generated-character-drafts/editor/character-images/editor-character-1/image.png', assetObjectId: 'asset-object-editor-character-1', }), ]), }), ); }); await waitFor(() => { expect(createEditorProjectResourceMock).toHaveBeenCalledWith( 'editor-project-default', expect.objectContaining({ objectKey: 'generated-character-drafts/editor/character-images/editor-character-1/image.png', assetObjectId: 'asset-object-editor-character-1', }), ); }); }); it('removes the active character generation placeholder with Backspace', async () => { render(); fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); expect(screen.getByLabelText('角色生成占位图')).toBeTruthy(); expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy(); await act(async () => { fireEvent.keyDown(window, { key: 'Backspace', code: 'Backspace' }); }); expect(screen.queryByLabelText('角色生成占位图')).toBeNull(); expect(screen.queryByRole('dialog', { name: '生成角色形象' })).toBeNull(); expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy(); }); it('opens icon asset generation panel, only picks icon specs, and lays generated icons on canvas', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-icons', title: '图标素材画布', viewport: { x: 0, y: 0, scale: 1 }, layers: [ { layerId: 'layer-plain', resourceId: 'resource-plain', title: '普通参考图', src: 'data:image/png;base64,plain', x: 80, y: 80, width: 120, height: 120, originalWidth: 512, originalHeight: 512, zIndex: 10, sourceType: 'uploaded', }, { layerId: 'layer-icon-spec', resourceId: 'resource-icon-spec', title: '清爽按钮图标规范', src: 'data:image/png;base64,icon-spec', x: 240, y: 80, width: 160, height: 120, originalWidth: 2048, originalHeight: 1152, zIndex: 11, sourceType: 'generated', assetKind: 'icon-spec', }, ], resources: [], updatedAt: '2026-06-15T00:00:00.000Z', }); generateEditorIconSpritesheetMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,sheet', spritesheetWidth: 512, spritesheetHeight: 512, iconImageSrcs: [ { name: '返回按钮', imageSrc: 'data:image/png;base64,back-icon', width: 96, height: 96, }, { name: '设置按钮', imageSrc: 'data:image/png;base64,setting-icon', width: 96, height: 96, }, ], prompt: '图标 prompt', actualPrompt: '图标 prompt', model: 'gemini-3.1-flash-image-preview', provider: 'VectorEngine', taskId: 'icon-task-1', }); render(); await waitFor(() => { expect(screen.getByAltText('画布图片:普通参考图')).toBeTruthy(); expect(screen.getByAltText('画布图片:清爽按钮图标规范')).toBeTruthy(); }); fireEvent.click(screen.getByRole('button', { name: '生成图标素材' })); const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' }); expect(screen.getByLabelText('图标素材生成占位图')).toBeTruthy(); expect( within(iconPanel).getByRole('button', { name: '图标素材规范' }), ).toBeTruthy(); expect( (within(iconPanel).getAllByRole('textbox')[0] as HTMLInputElement).value, ).toBe('返回按钮'); expect( (within(iconPanel).getAllByRole('textbox')[5] as HTMLInputElement).value, ).toBe('冻结按钮'); fireEvent.click( within(iconPanel).getByRole('button', { name: '图标素材规范' }), ); fireEvent.click(screen.getByRole('menuitem', { name: '从画布中选择' })); expect( screen.getByText('请选择画布中的图标素材规范,按 Esc 退出'), ).toBeTruthy(); fireEvent.pointerDown( screen.getByAltText('画布图片:普通参考图').closest('button')!, { button: 0, pointerId: 180, clientX: 100, clientY: 100, }, ); expect( within(iconPanel).getByRole('button', { name: '图标素材规范' }), ).toBeTruthy(); fireEvent.pointerDown( screen.getByAltText('画布图片:清爽按钮图标规范').closest('button')!, { button: 0, pointerId: 181, clientX: 260, clientY: 100, }, ); expect( within(iconPanel).getByRole('button', { name: '清爽按钮图标规范' }), ).toBeTruthy(); expect( screen.queryByText('请选择画布中的图标素材规范,按 Esc 退出'), ).toBeNull(); const iconDescriptionInputs = within(iconPanel).getAllByRole('textbox'); const [ , , iconDescription3, iconDescription4, iconDescription5, iconDescription6, ] = iconDescriptionInputs; expect(iconDescription3).toBeTruthy(); expect(iconDescription4).toBeTruthy(); expect(iconDescription5).toBeTruthy(); expect(iconDescription6).toBeTruthy(); fireEvent.change(iconDescription3!, { target: { value: '' }, }); fireEvent.change(iconDescription4!, { target: { value: '' }, }); fireEvent.change(iconDescription5!, { target: { value: '' }, }); fireEvent.change(iconDescription6!, { target: { value: '' }, }); fireEvent.click(within(iconPanel).getByRole('button', { name: '生成' })); expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith({ referenceImageSrc: 'data:image/png;base64,icon-spec', iconDescriptions: ['返回按钮', '设置按钮'], model: 'gemini-3.1-flash-image-preview', aspectRatio: '1:1', imageSize: '1K', }); await waitFor(() => { expect(screen.getByAltText('画布图片:返回按钮')).toBeTruthy(); expect(screen.getByAltText('画布图片:设置按钮')).toBeTruthy(); }); expect(screen.queryByLabelText('图标素材生成占位图')).toBeNull(); expect(screen.getAllByText('图标')).toHaveLength(2); fireEvent.click( screen.getAllByRole('button', { name: '查看返回按钮图片信息' })[0]!, ); const iconInfoPanel = screen.getByRole('dialog', { name: '返回按钮图片信息', }); expect(within(iconInfoPanel).queryByText('Prompt')).toBeNull(); expect(within(iconInfoPanel).getByText('生成输入')).toBeTruthy(); expect(within(iconInfoPanel).getByText('素材描述 1')).toBeTruthy(); expect(within(iconInfoPanel).getByText('素材描述 2')).toBeTruthy(); expect(within(iconInfoPanel).getByText('返回按钮')).toBeTruthy(); expect(within(iconInfoPanel).getByText('设置按钮')).toBeTruthy(); expect(within(iconInfoPanel).getByText('图标素材规范')).toBeTruthy(); expect(within(iconInfoPanel).getByText('清爽按钮图标规范')).toBeTruthy(); await waitFor(() => { expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( 'editor-project-icons', expect.objectContaining({ layers: expect.arrayContaining([ expect.objectContaining({ title: '返回按钮', assetKind: 'icon', }), expect.objectContaining({ title: '设置按钮', assetKind: 'icon', }), ]), }), ); }); }); it('exits character generation canvas picking with Escape', () => { render(); fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); const characterPanel = screen.getByRole('dialog', { name: '生成角色形象' }); fireEvent.click( within(characterPanel).getByRole('button', { name: '角色形象规范' }), ); fireEvent.click(screen.getByRole('menuitem', { name: '从画布中选择' })); expect( screen.getByText('请选择画布中的图片作为角色形象规范,按 Esc 退出'), ).toBeTruthy(); fireEvent.keyDown(window, { key: 'Escape' }); expect( screen.queryByText('请选择画布中的图片作为角色形象规范,按 Esc 退出'), ).toBeNull(); expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy(); }); it('only exposes character animation generation for character layers and submits the panel payload', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-character-animation', title: '角色动画画布', viewport: { x: 0, y: 0, scale: 1 }, layers: [ { layerId: 'layer-character', resourceId: 'resource-character', title: '市场老妇人', src: 'data:image/png;base64,character', x: 160, y: 140, width: 320, height: 320, originalWidth: 1024, originalHeight: 1024, zIndex: 2, sourceType: 'generated', objectKey: 'generated-character-drafts/editor/character-images/source/image.png', assetKind: 'character', }, { layerId: 'layer-prop', resourceId: 'resource-prop', title: '普通道具', src: 'data:image/png;base64,prop', x: 520, y: 140, width: 280, height: 220, originalWidth: 700, originalHeight: 550, zIndex: 1, sourceType: 'uploaded', }, ], resources: [], updatedAt: '2026-06-15T00:00:00.000Z', }); generateEditorCharacterAnimationMock.mockResolvedValueOnce({ taskId: 'character-animation-task-1', model: 'seedance2.0', prompt: '生成游戏角色动画\n动作描述:\n待机', previewVideoPath: '/generated-character-drafts/editor/preview.mp4', frames: Array.from({ length: 48 }, (_, index) => ({ frameIndex: index + 1, imageSrc: `/generated-character-drafts/editor/frame${index + 1}.png`, width: 1024, height: 1024, })), frameCount: 48, durationSeconds: 6, fps: 8, priceMudPoints: 120, }); render(); const propLayer = await screen.findByAltText('画布图片:普通道具'); fireEvent.click(propLayer.closest('button')!); expect(screen.queryByRole('button', { name: '生成动画' })).toBeNull(); fireEvent.contextMenu(propLayer.closest('button')!, { clientX: 220, clientY: 180, }); expect(screen.queryByRole('menuitem', { name: '生成动画' })).toBeNull(); const characterLayer = screen.getByAltText('画布图片:市场老妇人'); fireEvent.click(characterLayer.closest('button')!); expect(screen.getByText('角色')).toBeTruthy(); expect(screen.getByRole('button', { name: '生成动画' })).toBeTruthy(); fireEvent.contextMenu(characterLayer.closest('button')!, { clientX: 260, clientY: 220, }); expect(screen.getByRole('menuitem', { name: '生成动画' })).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '生成动画' })); const panel = screen.getByRole('dialog', { name: '角色动画生成面板' }); expect(within(panel).getByText('40泥点')).toBeTruthy(); expect( (within(panel).getByLabelText('分辨率') as HTMLSelectElement).value, ).toBe('480p'); expect( (within(panel).getByLabelText('画面比例') as HTMLSelectElement).value, ).toBe('same'); expect( (within(panel).getByLabelText('时长') as HTMLSelectElement).value, ).toBe('32'); for (const actionLabel of [ '待机', '行走', '奔跑', '跳跃', '攻击', '受击', '倒下', ]) { expect( within(panel).getByRole('button', { name: actionLabel }), ).toBeTruthy(); } fireEvent.click(within(panel).getByRole('button', { name: '待机' })); expect( (within(panel).getByLabelText('动画描述') as HTMLTextAreaElement).value, ).toContain('待机'); const longPrompt = '走'.repeat(4100); fireEvent.change(within(panel).getByLabelText('动画描述'), { target: { value: longPrompt }, }); expect( (within(panel).getByLabelText('动画描述') as HTMLTextAreaElement).value, ).toHaveLength(4000); const precisePrompt = 'The elderly market woman gently shifts weight while the basket sways.'; fireEvent.change(within(panel).getByLabelText('动画描述'), { target: { value: precisePrompt }, }); expect( within(panel).getByLabelText(`生成文本:${precisePrompt}`), ).toBeTruthy(); fireEvent.change(within(panel).getByLabelText('分辨率'), { target: { value: '720p' }, }); fireEvent.change(within(panel).getByLabelText('画面比例'), { target: { value: '16:9' }, }); fireEvent.change(within(panel).getByLabelText('时长'), { target: { value: '48' }, }); expect(within(panel).getByText('120泥点')).toBeTruthy(); fireEvent.click(within(panel).getByRole('button', { name: '生成' })); expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith( expect.objectContaining({ sourceLayerId: 'layer-character', sourceImageSrc: 'generated-character-drafts/editor/character-images/source/image.png', sourceWidth: 1024, sourceHeight: 1024, resolution: '720p', ratio: '16:9', frameCount: 48, durationSeconds: 6, priceMudPoints: 120, model: 'seedance2.0', }), ); expect( generateEditorCharacterAnimationMock.mock.calls[0]?.[0]?.promptText, ).toBe(precisePrompt); await waitFor(() => { expect(within(panel).getByText('已生成 48 帧')).toBeTruthy(); }); }); it('opens quick edit from the floating toolbar with original image as first reference and generates beside the source', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-quick-edit', title: '快速编辑画布', viewport: { x: 0, y: 0, scale: 1 }, layers: [ { layerId: 'layer-quick-source', resourceId: 'resource-quick-source', title: '魔法森林', src: 'data:image/png;base64,c291cmNl', x: 120, y: 140, width: 320, height: 240, originalWidth: 1536, originalHeight: 1024, zIndex: 2, sourceType: 'generated', prompt: '魔法森林原始提示词', actualPrompt: '魔法森林原始提示词', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'source-task-1', assetKind: 'spec', }, ], resources: [], updatedAt: '2026-06-15T00:00:00.000Z', }); generateEditorImageMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,cXVpY2stZWRpdA==', width: 1536, height: 1024, sourceType: 'generated', prompt: '增加萤火虫', actualPrompt: '增加萤火虫', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'quick-edit-task-1', }); render(); const sourceImage = await screen.findByAltText('画布图片:魔法森林'); fireEvent.pointerDown(sourceImage.closest('button')!, { button: 0, pointerId: 151, clientX: 180, clientY: 180, }); fireEvent.pointerUp(screen.getByLabelText('画布工作区'), { pointerId: 151, clientX: 180, clientY: 180, }); fireEvent.click(screen.getByRole('button', { name: '快速编辑' })); const quickPanel = screen.getByRole('dialog', { name: '快速编辑图片' }); expect(quickPanel.className).toContain( 'image-canvas-editor__quick-edit-panel', ); expect(within(quickPanel).getByText('魔法森林')).toBeTruthy(); expect( (within(quickPanel).getByLabelText('快速编辑尺寸') as HTMLSelectElement) .value, ).toBe('1536x1024'); expect( (within(quickPanel).getByLabelText('快速编辑模型') as HTMLSelectElement) .value, ).toBe('gpt-image-2'); const references = within(quickPanel).getAllByRole('img'); expect(references[0]?.getAttribute('src')).toBe( 'data:image/png;base64,c291cmNl', ); fireEvent.change(within(quickPanel).getByLabelText('快速编辑提示词'), { target: { value: '增加萤火虫' }, }); fireEvent.click(within(quickPanel).getByRole('button', { name: '生成' })); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith({ prompt: '增加萤火虫', size: '1536x1024', kind: 'quick-edit', model: 'gpt-image-2', referenceImageSrcs: ['data:image/png;base64,c291cmNl'], }); }); await waitFor(() => { expect(screen.getByAltText('画布图片:魔法森林 快速编辑')).toBeTruthy(); }); const generatedLayer = screen .getByAltText('画布图片:魔法森林 快速编辑') .closest('button') as HTMLElement; expect(Number.parseFloat(generatedLayer.style.left)).toBe(1688); expect(Number.parseFloat(generatedLayer.style.top)).toBe(140); expect(Number.parseFloat(generatedLayer.style.width)).toBe(1536); expect(Number.parseFloat(generatedLayer.style.height)).toBe(1024); await waitFor(() => { expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( 'editor-project-quick-edit', expect.objectContaining({ layers: expect.arrayContaining([ expect.objectContaining({ title: '魔法森林 快速编辑', assetKind: 'spec', width: 1536, height: 1024, originalWidth: 1536, originalHeight: 1024, x: 1688, y: 140, }), ]), }), ); }); }); it('opens quick edit from the image context menu', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-context-quick-edit', title: '右键快速编辑画布', viewport: { x: 0, y: 0, scale: 1 }, layers: [ { layerId: 'layer-context-source', resourceId: 'resource-context-source', title: '右键图片', src: 'data:image/png;base64,Y29udGV4dA==', x: 80, y: 90, width: 260, height: 260, originalWidth: 1024, originalHeight: 1024, zIndex: 1, sourceType: 'uploaded', model: 'gpt-image-2', }, ], resources: [], updatedAt: '2026-06-15T00:00:00.000Z', }); generateEditorImageMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,Y29udGV4dC1xdWljaw==', width: 1024, height: 1024, sourceType: 'generated', prompt: '换成夜晚', actualPrompt: '换成夜晚', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'context-quick-task-1', }); render(); const contextImage = await screen.findByAltText('画布图片:右键图片'); fireEvent.contextMenu(contextImage.closest('button')!, { clientX: 260, clientY: 220, }); const menu = screen.getByRole('menu', { name: '图片功能面板' }); expect( within(menu).getByRole('menuitem', { name: '快速编辑' }), ).toBeTruthy(); fireEvent.click(within(menu).getByRole('menuitem', { name: '快速编辑' })); const panel = screen.getByRole('dialog', { name: '快速编辑图片' }); expect(within(panel).getByText('右键图片')).toBeTruthy(); fireEvent.change(within(panel).getByLabelText('快速编辑提示词'), { target: { value: '换成夜晚' }, }); fireEvent.click(within(panel).getByRole('button', { name: '生成' })); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ prompt: '换成夜晚', referenceImageSrcs: ['data:image/png;base64,Y29udGV4dA=='], size: '1024x1024', model: 'gpt-image-2', kind: 'quick-edit', }), ); }); await waitFor(() => { expect(screen.getByAltText('画布图片:右键图片 快速编辑')).toBeTruthy(); }); }); it('converts non-data-url quick edit source images before submitting references', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-public-quick-edit', title: '公开素材快速编辑画布', viewport: { x: 0, y: 0, scale: 1 }, layers: [ { layerId: 'layer-public-source', resourceId: 'resource-public-source', title: '公开拼图素材', src: '/creation-type-references/puzzle.webp', x: 120, y: 140, width: 320, height: 240, originalWidth: 640, originalHeight: 640, zIndex: 2, sourceType: 'uploaded', }, ], resources: [], updatedAt: '2026-06-16T00:00:00.000Z', }); vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( new Response(new Uint8Array([104, 101, 108, 108, 111]), { status: 200, headers: { 'Content-Type': 'image/webp', }, }), ); generateEditorImageMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,cHVibGljLXF1aWNr', width: 640, height: 640, sourceType: 'generated', prompt: '改成陶泥风格', actualPrompt: '改成陶泥风格', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'public-quick-edit-task-1', }); render(); const sourceImage = await screen.findByAltText('画布图片:公开拼图素材'); fireEvent.pointerDown(sourceImage.closest('button')!, { button: 0, pointerId: 161, clientX: 180, clientY: 180, }); fireEvent.pointerUp(screen.getByLabelText('画布工作区'), { pointerId: 161, clientX: 180, clientY: 180, }); fireEvent.click(screen.getByRole('button', { name: '快速编辑' })); const quickPanel = screen.getByRole('dialog', { name: '快速编辑图片' }); fireEvent.change(within(quickPanel).getByLabelText('快速编辑提示词'), { target: { value: '改成陶泥风格' }, }); fireEvent.click(within(quickPanel).getByRole('button', { name: '生成' })); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ prompt: '改成陶泥风格', kind: 'quick-edit', referenceImageSrcs: ['data:image/webp;base64,aGVsbG8='], }), ); }); expect(globalThis.fetch).toHaveBeenCalledWith( '/creation-type-references/puzzle.webp', expect.objectContaining({ signal: undefined, }), ); }); 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: -60, clientY: 180, }); 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 info 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(); }); const generatedLayer = screen .getByAltText(/画布图片:生成图片/) .closest('button') as HTMLElement; expect(Number.parseFloat(generatedLayer.style.width)).toBe(1024); expect(Number.parseFloat(generatedLayer.style.height)).toBe(1024); 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('图片类型')).toBeTruthy(); expect(within(metadataDialog).getByText('生成图片')).toBeTruthy(); expect(within(metadataDialog).queryByText('Prompt')).toBeNull(); expect( within(metadataDialog).queryByRole('button', { name: '复制Prompt' }), ).toBeNull(); expect(within(metadataDialog).getByText('生成输入')).toBeTruthy(); expect(within(metadataDialog).getByText('生成提示词')).toBeTruthy(); expect(within(metadataDialog).getByText('一张可修改的生成图')).toBeTruthy(); expect(within(metadataDialog).getByText('Model')).toBeTruthy(); expect(within(metadataDialog).getByText('gpt-image-2')).toBeTruthy(); expect(within(metadataDialog).queryByText('Size')).toBeNull(); expect(within(metadataDialog).getByText('Resolution')).toBeTruthy(); expect(within(metadataDialog).getByText('1024 x 1024 px')).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('修改中'); await waitFor(() => { expect(editEditorImageMock).toHaveBeenCalledWith({ prompt: '把画面改成黄昏光线', sourceImageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==', }); }); await waitFor(() => { expect(screen.queryByRole('dialog', { name: '修改图片' })).toBeNull(); }); expect(screen.getByAltText(/画布图片:生成图片 .* 修改结果/)).toBeTruthy(); fireEvent.click( screen.getAllByRole('button', { name: /查看生成图片 .* 修改结果图片信息/u, })[0]!, ); const editedMetadataDialog = screen.getByRole('dialog', { name: /生成图片 .* 修改结果图片信息/u, }); expect(within(editedMetadataDialog).queryByText('Prompt')).toBeNull(); expect(within(editedMetadataDialog).getByText('修改要求')).toBeTruthy(); expect( within(editedMetadataDialog).getByText('把画面改成黄昏光线'), ).toBeTruthy(); expect(within(editedMetadataDialog).getByText('参考图')).toBeTruthy(); expect( within(editedMetadataDialog).getByText(/^生成图片 \d+$/u), ).toBeTruthy(); expect( screen.getByRole('button', { name: /当前缩放比例 \d+%/u }), ).toBeTruthy(); }); it('hides the edit image panel after generation starts while keeping the source preview visible', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-edit-generating', title: '修改图片生成中画布', viewport: { x: 0, y: 0, scale: 1 }, layers: [ { layerId: 'layer-edit-generating-source', resourceId: 'resource-edit-generating-source', title: '待修改图片', src: 'data:image/png;base64,ZWRpdC1nZW5lcmF0aW5n', x: 120, y: 140, width: 320, height: 240, originalWidth: 1024, originalHeight: 768, zIndex: 2, sourceType: 'generated', prompt: '原始提示词', actualPrompt: '原始提示词', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'edit-generating-source-task', }, ], resources: [], updatedAt: '2026-06-16T00:00:00.000Z', }); editEditorImageMock.mockReturnValueOnce(new Promise(() => undefined)); render(); const sourceImage = await screen.findByAltText('画布图片:待修改图片'); const sourceLayer = sourceImage.closest('button')!; fireEvent.pointerDown(sourceLayer, { button: 0, pointerId: 171, clientX: 180, clientY: 180, }); fireEvent.pointerUp(screen.getByLabelText('画布工作区'), { pointerId: 171, clientX: 180, clientY: 180, }); fireEvent.click(screen.getByRole('button', { name: '修改图片' })); const editDialog = screen.getByRole('dialog', { name: '修改图片' }); fireEvent.change(within(editDialog).getByLabelText('生成提示词'), { target: { value: '改成雨夜灯光' }, }); fireEvent.click(within(editDialog).getByRole('button', { name: '修改' })); expect(screen.queryByRole('dialog', { name: '修改图片' })).toBeNull(); expect(screen.getByAltText('画布图片:待修改图片')).toBeTruthy(); expect(sourceLayer.className).toContain( 'image-canvas-editor__layer--generating', ); expect(within(sourceLayer).getByRole('status').textContent).toContain( '修改中', ); }); it('hides the quick edit panel after generation starts while keeping the source preview visible', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-quick-edit-generating', title: '快速编辑生成中画布', viewport: { x: 0, y: 0, scale: 1 }, layers: [ { layerId: 'layer-quick-edit-generating-source', resourceId: 'resource-quick-edit-generating-source', title: '快速编辑源图', src: 'data:image/png;base64,cXVpY2stZWRpdC1nZW5lcmF0aW5n', x: 120, y: 140, width: 320, height: 240, originalWidth: 1024, originalHeight: 768, zIndex: 2, sourceType: 'uploaded', model: 'gpt-image-2', }, ], resources: [], updatedAt: '2026-06-16T00:00:00.000Z', }); generateEditorImageMock.mockReturnValueOnce(new Promise(() => undefined)); render(); const sourceImage = await screen.findByAltText('画布图片:快速编辑源图'); const sourceLayer = sourceImage.closest('button')!; fireEvent.pointerDown(sourceLayer, { button: 0, pointerId: 172, clientX: 180, clientY: 180, }); fireEvent.pointerUp(screen.getByLabelText('画布工作区'), { pointerId: 172, clientX: 180, clientY: 180, }); fireEvent.click(screen.getByRole('button', { name: '快速编辑' })); const quickPanel = screen.getByRole('dialog', { name: '快速编辑图片' }); fireEvent.change(within(quickPanel).getByLabelText('快速编辑提示词'), { target: { value: '加一层暖光' }, }); fireEvent.click(within(quickPanel).getByRole('button', { name: '生成' })); expect(screen.queryByRole('dialog', { name: '快速编辑图片' })).toBeNull(); expect(screen.getByAltText('画布图片:快速编辑源图')).toBeTruthy(); expect(sourceLayer.className).toContain( 'image-canvas-editor__layer--generating', ); expect(within(sourceLayer).getByRole('status').textContent).toContain( '生成中', ); }); it('undoes and redoes canvas layer changes from the panel controls', () => { render(); expect(screen.getByRole('button', { name: '撤销' })).toHaveProperty( 'disabled', true, ); expect(screen.getByRole('button', { name: '重做' })).toHaveProperty( 'disabled', true, ); fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' })); expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy(); expect(screen.getByRole('button', { name: '撤销' })).toHaveProperty( 'disabled', false, ); fireEvent.click(screen.getByRole('button', { name: '撤销' })); expect(screen.queryByAltText('画布图片:声浪素材')).toBeNull(); expect(screen.getByRole('button', { name: '重做' })).toHaveProperty( 'disabled', false, ); fireEvent.click(screen.getByRole('button', { name: '重做' })); expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy(); }); it('supports undo and redo keyboard shortcuts inside the editor', () => { render(); fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' })); expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy(); fireEvent.keyDown(window, { key: 'z', code: 'KeyZ', ctrlKey: true }); expect(screen.queryByAltText('画布图片:声浪素材')).toBeNull(); fireEvent.keyDown(window, { key: 'Z', code: 'KeyZ', ctrlKey: true, shiftKey: true, }); expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy(); }); });