/* @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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { EditorAgentConversationClient } from './useEditorAgentConversation'; import { ApiClientError, AuthUiContext, createAuthValue, defaultEditorAssetLibraryAssets, defaultEditorProjectLayers, defaultEditorProjectResources, dispatchPointerEvent, ImageCanvasEditorView, readZipText, setupImageCanvasEditorViewTestLifecycle, } from './ImageCanvasEditorView.test-utils'; type EditorAgentListConversations = EditorAgentConversationClient['listConversations']; type EditorAgentCreateConversation = EditorAgentConversationClient['createConversation']; type EditorAgentGetConversation = EditorAgentConversationClient['getConversation']; type EditorAgentDeleteConversation = EditorAgentConversationClient['deleteConversation']; type EditorAgentStreamMessage = EditorAgentConversationClient['streamMessage']; type EditorAgentConversationSummary = Awaited< ReturnType >[number]; type EditorAgentConversationDetail = Awaited< ReturnType >; const EDITOR_AGENT_SIDEBAR_ENV = 'VITE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR'; const listEditorAgentConversationsMock = vi.hoisted(() => vi.fn< Parameters, ReturnType >(async () => []), ); const createEditorAgentConversationMock = vi.hoisted(() => vi.fn< Parameters, ReturnType >(async () => ({ conversationId: 'editor-agent-conv-created', projectId: 'editor-project-default', title: '画布 Agent', createdAt: '2026-07-03T00:00:00.000Z', updatedAt: '2026-07-03T00:00:00.000Z', messages: [], })), ); const getEditorAgentConversationMock = vi.hoisted(() => vi.fn< Parameters, ReturnType >(async () => ({ conversationId: 'editor-agent-conv-test', projectId: 'editor-project-default', title: '画布 Agent', createdAt: '2026-07-03T00:00:00.000Z', updatedAt: '2026-07-03T00:00:00.000Z', messages: [], })), ); const deleteEditorAgentConversationMock = vi.hoisted(() => vi.fn< Parameters, ReturnType >(async () => ({ conversationId: 'editor-agent-conv-test', projectId: 'editor-project-default', title: '画布 Agent', createdAt: '2026-07-03T00:00:00.000Z', updatedAt: '2026-07-03T00:00:00.000Z', })), ); const streamEditorAgentMessageMock = vi.hoisted(() => vi.fn< Parameters, ReturnType >(async () => undefined), ); const generateEditorImageMock = vi.hoisted(() => vi.fn()); const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn()); const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn()); const editEditorImageMock = vi.hoisted(() => vi.fn()); const createEditorAssetMock = vi.hoisted(() => vi.fn()); const createEditorProjectResourceMock = vi.hoisted(() => vi.fn()); const createEditorAssetFolderMock = vi.hoisted(() => vi.fn()); const updateEditorAssetMock = vi.hoisted(() => vi.fn()); const updateEditorAssetFolderMock = vi.hoisted(() => vi.fn()); const deleteEditorAssetFolderMock = vi.hoisted(() => vi.fn()); const deleteEditorAssetMock = vi.hoisted(() => vi.fn()); const loadEditorAssetLibraryMock = vi.hoisted(() => vi.fn()); const loadEditorProjectMock = vi.hoisted(() => vi.fn()); const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn()); const renameEditorProjectMock = vi.hoisted(() => vi.fn()); const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn()); const getPlatformProfileDashboardMock = vi.hoisted(() => vi.fn()); vi.mock('../../services/image-editor/editorProjectClient', async () => { const actual = await vi.importActual< typeof import('../../services/image-editor/editorProjectClient') >('../../services/image-editor/editorProjectClient'); return { ...actual, editEditorImage: editEditorImageMock, createEditorAsset: createEditorAssetMock, createEditorAssetFolder: createEditorAssetFolderMock, createEditorProjectResource: createEditorProjectResourceMock, deleteEditorAsset: deleteEditorAssetMock, deleteEditorAssetFolder: deleteEditorAssetFolderMock, generateEditorCharacterAnimation: generateEditorCharacterAnimationMock, generateEditorIconSpritesheet: generateEditorIconSpritesheetMock, generateEditorImage: generateEditorImageMock, loadEditorAssetLibrary: loadEditorAssetLibraryMock, loadEditorProject: loadEditorProjectMock, loadOrCreateRecentEditorProject: loadOrCreateRecentEditorProjectMock, renameEditorProject: renameEditorProjectMock, saveEditorProjectLayout: saveEditorProjectLayoutMock, updateEditorAsset: updateEditorAssetMock, updateEditorAssetFolder: updateEditorAssetFolderMock, }; }); vi.mock('../../services/platform-entry/platformProfileClient', () => ({ getPlatformProfileDashboard: getPlatformProfileDashboardMock, })); vi.mock('../../services/image-editor/editorAgentClient', () => ({ createEditorAgentConversation: createEditorAgentConversationMock, deleteEditorAgentConversation: deleteEditorAgentConversationMock, getEditorAgentConversation: getEditorAgentConversationMock, listEditorAgentConversations: listEditorAgentConversationsMock, streamEditorAgentMessage: streamEditorAgentMessageMock, })); function createEditorAgentConversationSummary( overrides: Partial = {}, ): EditorAgentConversationSummary { return { conversationId: 'editor-agent-conv-test', projectId: 'editor-project-default', title: '画布 Agent', createdAt: '2026-07-03T00:00:00.000Z', updatedAt: '2026-07-03T00:00:00.000Z', ...overrides, }; } function createEditorAgentDetailWithGeneration( resourceId: string, ): EditorAgentConversationDetail { const summary = createEditorAgentConversationSummary(); return { ...summary, messages: [ { id: 'agent-message-result', role: 'assistant', kind: 'chat', text: '生成完成', attachments: [], generations: [ { toolCallId: 'agent-tool-call-1', toolName: 'generate_image', taskId: 'task-agent-1', status: 'completed', model: 'gpt-image-2', images: [ { resourceId, imageSrc: '/agent-result.png', thumbnailSrc: '/agent-result-thumb.png', width: 512, height: 512, }, ], error: null, }, ], status: 'completed', createdAt: '2026-07-03T00:00:01.000Z', }, ], }; } function openAssetSidebar() { fireEvent.click(screen.getByRole('button', { name: '打开素材' })); return screen.getByRole('complementary', { name: '图片资源栏' }); } function openMinimap() { const panelToolbar = screen.getByRole('toolbar', { name: '画布面板入口' }); fireEvent.click( within(panelToolbar).getByRole('button', { name: '切换小地图' }), ); return screen.getByRole('button', { name: '画布小地图' }); } async function ensureAgentConversationOpen() { const agentButton = screen.getByRole('button', { name: '画布 Agent' }); if (agentButton.getAttribute('aria-pressed') !== 'true') { fireEvent.click(agentButton); } return screen.findByLabelText('发送给画布 Agent'); } function enableEditorAgentSidebarForTest() { vi.stubEnv(EDITOR_AGENT_SIDEBAR_ENV, 'true'); } describe('ImageCanvasEditorView', () => { setupImageCanvasEditorViewTestLifecycle({ generateEditorImageMock, generateEditorIconSpritesheetMock, generateEditorCharacterAnimationMock, editEditorImageMock, createEditorAssetMock, createEditorProjectResourceMock, createEditorAssetFolderMock, updateEditorAssetMock, updateEditorAssetFolderMock, deleteEditorAssetFolderMock, deleteEditorAssetMock, loadEditorAssetLibraryMock, loadEditorProjectMock, loadOrCreateRecentEditorProjectMock, renameEditorProjectMock, saveEditorProjectLayoutMock, }); beforeEach(() => { vi.stubEnv(EDITOR_AGENT_SIDEBAR_ENV, 'false'); listEditorAgentConversationsMock.mockResolvedValue([]); createEditorAgentConversationMock.mockResolvedValue({ ...createEditorAgentConversationSummary({ conversationId: 'editor-agent-conv-created', }), messages: [], }); getEditorAgentConversationMock.mockResolvedValue({ ...createEditorAgentConversationSummary(), messages: [], }); deleteEditorAgentConversationMock.mockResolvedValue( createEditorAgentConversationSummary(), ); streamEditorAgentMessageMock.mockResolvedValue(undefined); getPlatformProfileDashboardMock.mockResolvedValue({ walletBalance: 1234, totalPlayTimeMs: 0, playedWorldCount: 0, updatedAt: null, }); }); afterEach(() => { listEditorAgentConversationsMock.mockReset(); createEditorAgentConversationMock.mockReset(); getEditorAgentConversationMock.mockReset(); deleteEditorAgentConversationMock.mockReset(); streamEditorAgentMessageMock.mockReset(); getPlatformProfileDashboardMock.mockReset(); vi.unstubAllEnvs(); }); it('loads the project from projectid query before falling back to recent project', async () => { listEditorAgentConversationsMock.mockResolvedValue([]); 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 toolbar guide for a newly created blank project until a generator opens', async () => { loadEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-guide', title: '空白项目', viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], updatedAt: '2026-06-12T00:00:00.000Z', }); window.history.replaceState( null, '', '/editor/canvas?projectid=editor-project-guide&guide=toolbar', ); render(); expect( await screen.findByText('工具栏在这里,快试着随便做点什么'), ).toBeTruthy(); expect( document .querySelector('.image-canvas-editor__toolbar-guide-art') ?.getAttribute('src'), ).toBe('/branding/taonier-toolbar-guide-bubble.png'); const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏', }); expect(bottomToolbar.className).toContain( 'image-canvas-editor__bottom-toolbar--guided', ); expect(window.location.search).toBe('?projectid=editor-project-guide'); fireEvent.click( within(bottomToolbar).getByRole('button', { name: '生成图片' }), ); expect( await screen.findByRole('dialog', { name: '生成图片' }), ).toBeTruthy(); await waitFor(() => { expect(screen.queryByText('工具栏在这里,快试着随便做点什么')).toBeNull(); }); expect(bottomToolbar.className).not.toContain( 'image-canvas-editor__bottom-toolbar--guided', ); }); it('opens the requested canvas startup tool from the project query once', async () => { loadEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-music', title: '音乐项目', viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], updatedAt: '2026-06-12T00:00:00.000Z', }); window.history.replaceState( null, '', '/editor/canvas?projectid=editor-project-music&tool=background-music', ); render(); expect( await screen.findByRole('dialog', { name: '生成游戏背景音乐' }), ).toBeTruthy(); expect(screen.queryByText('工具栏在这里,快试着随便做点什么')).toBeNull(); expect(window.location.search).toBe('?projectid=editor-project-music'); }); 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('shows the live mud point balance in the canvas topbar when logged in', async () => { render( , ); expect(await screen.findByLabelText('泥点余额 1,234泥点')).toBeTruthy(); expect(getPlatformProfileDashboardMock).toHaveBeenCalledWith({ authImpact: 'local', skipRefresh: true, notifyAuthStateChange: false, clearAuthOnUnauthorized: false, }); }); it('opens the account modal from the canvas topbar avatar entry', async () => { const openAccountModal = vi.fn(); render( , ); const accountButton = await screen.findByRole('button', { name: '账号 测试用户', }); expect(accountButton.querySelector('img')?.getAttribute('src')).toBe( 'data:image/png;base64,AVATAR', ); fireEvent.click(accountButton); expect(openAccountModal).toHaveBeenCalledTimes(1); }); it('opens the same account wallet entry from the canvas topbar mud point button', async () => { render( , ); const walletButton = await screen.findByRole('button', { name: '泥点余额 1,234泥点', }); fireEvent.click(walletButton); expect(await screen.findByRole('dialog', { name: '兑换码' })).toBeTruthy(); expect(screen.getByPlaceholderText('输入兑换码')).toBeTruthy(); }); 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 () => { listEditorAgentConversationsMock.mockResolvedValue([]); 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(); openAssetSidebar(); 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', sourceResourceId: 'resource-generated', prompt: '明亮主视觉', actualPrompt: '后端拼接提示词', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'task-1', assetObjectId: 'asset-object-visible', generationInputs: { fields: [{ title: '生成提示词', value: '明亮主视觉' }], references: [], }, }, { 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', sourceResourceId: 'resource-failed', }, ], 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) => { const pathname = new URL(url, 'http://localhost').pathname; if (pathname === '/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).toBeUndefined(); 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).toBeUndefined(); expect(metadata.layers[0].visible.type).toBe('上传图片'); expect(metadata.layers[2].visible).toEqual({ type: '生成图片', generationInputs: { fields: [{ title: '生成提示词', value: '明亮主视觉' }], references: [], }, model: 'gpt-image-2', task: '1', object: 'asset-object-visible', resolution: '1024 x 1024 px', }); expect(metadata.layers[2].sourceType).toBeUndefined(); expect(metadata.layers[2].prompt).toBeUndefined(); expect(metadata.layers[2].actualPrompt).toBeUndefined(); expect(metadata.layers[2].provider).toBeUndefined(); expect(metadata.layers[2].sourceResourceId).toBeUndefined(); expect(metadata.layers[3].file).toBeNull(); expect(metadata.layers[3].exportError).toContain('404'); expect(metadata.failedImages).toEqual([ { title: '失败图', error: expect.stringContaining('404') }, ]); 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('shows image resolution on hover and opens the crop-expand panel after selecting a layer', () => { 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(screen.getByRole('dialog', { name: '裁扩图片' })).toBeTruthy(); expect( screen .getByRole('button', { name: '自由比例' }) .getAttribute('aria-pressed'), ).toBe('true'); expect(screen.queryByLabelText('裁扩左')).toBeNull(); 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.getByRole('button', { name: '改造' })).toBeTruthy(); }); 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('downloads the selected layer from the floating toolbar', async () => { let downloadName = ''; let downloadHref = ''; const originalFetch = globalThis.fetch; const originalCreateObjectUrl = URL.createObjectURL; const originalRevokeObjectUrl = URL.revokeObjectURL; globalThis.fetch = vi.fn(async () => { return new Response(new Blob(['puzzle'], { type: 'image/webp' })); }) as typeof fetch; URL.createObjectURL = vi.fn(() => 'blob:layer-download'); URL.revokeObjectURL = vi.fn(); vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation( function click(this: HTMLAnchorElement) { downloadName = this.download; downloadHref = this.href; }, ); try { 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: '下载按钮' })); await waitFor(() => { expect(downloadName).toBe('拼图素材.webp'); }); expect(downloadHref).toBe('blob:layer-download'); expect(globalThis.fetch).toHaveBeenCalledWith( expect.stringContaining('/creation-type-references/puzzle.webp'), expect.any(Object), ); expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy(); expect(screen.getByAltText('画布图片:大鱼素材')).toBeTruthy(); } finally { globalThis.fetch = originalFetch; URL.createObjectURL = originalCreateObjectUrl; URL.revokeObjectURL = originalRevokeObjectUrl; } }); 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('moves focus from the Agent input back to the canvas layer before handling shortcuts', async () => { enableEditorAgentSidebarForTest(); render(); const agentInput = await ensureAgentConversationOpen(); agentInput.focus(); expect(document.activeElement).toBe(agentInput); const layerButton = screen .getByAltText('画布图片:拼图素材') .closest('button')!; fireEvent.pointerDown(layerButton, { button: 0, pointerId: 53, clientX: 120, clientY: 120, }); expect(document.activeElement).toBe(layerButton); await act(async () => { fireEvent.keyDown(window, { key: 'Backspace', code: 'Backspace' }); }); expect(screen.queryByAltText('画布图片:拼图素材')).toBeNull(); expect(screen.getByAltText('画布图片:大鱼素材')).toBeTruthy(); }); it('opens the matching audio generation panel when remodeling an existing sound effect layer', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-audio-remodel', title: '音频改造画布', viewport: { x: 0, y: 0, scale: 1 }, layers: [ { layerId: 'layer-sound-effect', resourceId: 'resource-sound-effect', title: '游戏音效', src: '/generated-character-drafts/editor-audios/sound.wav', mediaType: 'audio', assetKind: 'sound-effect', x: 120, y: 140, width: 420, height: 120, originalWidth: 420, originalHeight: 120, zIndex: 2, sourceType: 'generated', prompt: '金币跳出音', model: 'audio1.0', generationInputs: { fields: [ { title: '音效提示词', value: '金币跳出音' }, { title: 'model', value: 'audio1.0' }, { title: 'duration', value: '8秒' }, ], references: [ { label: '历史参考图', src: '/creation-type-references/puzzle.webp', }, ], }, }, ], resources: [], updatedAt: '2026-06-19T00:00:00.000Z', }); render(); const audioLayer = await screen.findByRole('button', { name: '选择游戏音效', }); fireEvent.pointerDown(audioLayer, { button: 0, pointerId: 54, clientX: 160, clientY: 180, }); fireEvent.click(screen.getByRole('button', { name: '改造' })); const panel = screen.getByRole('dialog', { name: '生成游戏音效' }); expect(within(panel).getByDisplayValue('金币跳出音')).toBeTruthy(); expect( within(panel).getByRole('button', { name: '音效时长 8秒' }), ).toBeTruthy(); expect( panel.querySelector('.image-canvas-editor__reference-strip'), ).toBeNull(); }); it('deletes the selected audio layer with Backspace from the audio control focus', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-audio-delete', title: '音频删除画布', viewport: { x: 0, y: 0, scale: 1 }, layers: [ { layerId: 'layer-audio', resourceId: 'resource-audio', title: '背景音乐', src: '/generated-character-drafts/editor-audios/bgm.mp3', mediaType: 'audio', assetKind: 'background-music', x: 120, y: 140, width: 420, height: 120, originalWidth: 420, originalHeight: 120, zIndex: 2, sourceType: 'generated', }, { layerId: 'layer-reference', resourceId: 'resource-reference', title: '参考图片', src: '/creation-type-references/puzzle.webp', x: 620, y: 140, width: 320, height: 320, originalWidth: 320, originalHeight: 320, zIndex: 1, sourceType: 'uploaded', }, ], resources: [], updatedAt: '2026-06-18T00:00:00.000Z', }); render(); const audioControl = await screen.findByLabelText('画布音频:背景音乐'); fireEvent.pointerDown(audioControl, { button: 0, pointerId: 53, clientX: 160, clientY: 180, }); await act(async () => { fireEvent.keyDown(audioControl, { key: 'Backspace', code: 'Backspace' }); }); expect(screen.queryByLabelText('画布音频:背景音乐')).toBeNull(); expect(screen.getByAltText('画布图片:参考图片')).toBeTruthy(); }); it('blocks the browser context menu inside the editor workspace', () => { render(); const editor = screen.getByRole('region', { name: '图片画布编辑器' }); const contextMenuEvent = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, }); const wasNotCanceled = editor.dispatchEvent(contextMenuEvent); expect(wasNotCanceled).toBe(false); expect(contextMenuEvent.defaultPrevented).toBe(true); }); it('shows the blank canvas context menu with paste, 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(false); 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('shows delete when right-clicking a generation placeholder', async () => { render(); fireEvent.click(screen.getByRole('button', { name: '生成图片' })); const frame = await screen.findByLabelText('图像生成占位图'); fireEvent.contextMenu(frame, { clientX: 510, clientY: 330, }); const menu = screen.getByRole('menu', { name: '生成器右键菜单' }); fireEvent.click(within(menu).getByRole('menuitem', { name: '删除' })); expect(screen.queryByLabelText('图像生成占位图')).toBeNull(); }); 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(); expect( screen.queryByRole('complementary', { name: '图片资源栏' }), ).toBeNull(); const sidebar = openAssetSidebar(); 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.getByRole('button', { name: '改造' })).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '打开素材' })); expect(screen.getByRole('button', { name: '添加拼图素材' })).toBeTruthy(); }); it('hides the right Agent conversation entry by default', () => { render(); expect(screen.queryByRole('button', { name: '画布 Agent' })).toBeNull(); expect( screen.queryByRole('button', { name: '打开画布 Agent' }), ).toBeNull(); expect(screen.queryByLabelText('发送给画布 Agent')).toBeNull(); expect(listEditorAgentConversationsMock).not.toHaveBeenCalled(); }); it('opens the right Agent conversation panel by default when enabled', async () => { enableEditorAgentSidebarForTest(); render(); expect(await screen.findByLabelText('发送给画布 Agent')).toBeTruthy(); expect( screen.queryByRole('complementary', { name: '画布任务列表' }), ).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '画布 Agent' })); expect(screen.queryByLabelText('发送给画布 Agent')).toBeNull(); }); it('keeps the Agent conversation panel open when restored tasks auto-open the task list', async () => { enableEditorAgentSidebarForTest(); loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-with-restored-task', title: '恢复任务项目', viewport: { x: 0, y: 0, scale: 1 }, layers: [ ...defaultEditorProjectLayers, { layerId: 'generation-dialog:restored-task', resourceId: 'generation-dialog:restored-task', itemType: 'generation-dialog', dialog: { id: 'restored-task', mode: 'generate', prompt: '继续生成', status: 'idle', }, }, ], resources: defaultEditorProjectResources, updatedAt: '2026-07-06T00:00:00.000Z', }); render(); expect(await screen.findByLabelText('发送给画布 Agent')).toBeTruthy(); await waitFor(() => { expect( screen.queryByRole('complementary', { name: '画布任务列表' }), ).toBeNull(); }); }); it('keeps the left resource sidebar independent from right-side task and Agent panels', async () => { enableEditorAgentSidebarForTest(); render(); const assetSidebar = openAssetSidebar(); expect(within(assetSidebar).getByText('素材')).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '任务列表' })); expect( screen.getByRole('complementary', { name: '画布任务列表' }), ).toBeTruthy(); expect( screen.getByRole('complementary', { name: '图片资源栏' }), ).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '打开图层' })); const layerSidebar = screen.getByRole('complementary', { name: '图片资源栏', }); expect(within(layerSidebar).getByText('图层')).toBeTruthy(); expect( screen.getByRole('complementary', { name: '画布任务列表' }), ).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '画布 Agent' })); expect( screen.getByRole('complementary', { name: '图片资源栏' }), ).toBeTruthy(); expect( screen.queryByRole('complementary', { name: '画布任务列表' }), ).toBeNull(); expect(await screen.findByLabelText('发送给画布 Agent')).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '任务列表' })); expect( screen.getByRole('complementary', { name: '图片资源栏' }), ).toBeTruthy(); expect( screen.getByRole('complementary', { name: '画布任务列表' }), ).toBeTruthy(); expect(screen.queryByLabelText('发送给画布 Agent')).toBeNull(); }); 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(); openAssetSidebar(); 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(); openAssetSidebar(); 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('saves manually selected layer tags in the canvas layout', async () => { render(); const layerButton = await screen.findByRole('button', { name: '选择拼图素材', }); fireEvent.click( within(layerButton).getByRole('button', { name: '素材标签:未知' }), ); fireEvent.click( within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole( 'menuitem', { name: '角色' }, ), ); await screen.findByRole('button', { name: '素材标签:角色' }); await waitFor(() => { expect(createEditorProjectResourceMock).toHaveBeenCalledWith( 'editor-project-default', expect.objectContaining({ imageSrc: '/creation-type-references/puzzle.webp', assetKind: 'character', }), ); }); await waitFor(() => { const savedLayout = saveEditorProjectLayoutMock.mock.calls .map((call) => call[1]) .find((layout) => layout.layers.some( (layer: { resourceId?: unknown; title?: unknown }) => layer.title === '拼图素材' && layer.resourceId === 'resource-editor-project-default-640', ), ); expect(savedLayout).toBeTruthy(); expect(savedLayout.layers).toEqual( expect.arrayContaining([ expect.not.objectContaining({ title: '拼图素材', assetKind: expect.anything(), }), ]), ); }); }); 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: '当前缩放比例 100%' }), ).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 100%' })); fireEvent.click(screen.getByRole('menuitem', { name: '缩放至50%' })); expect( screen.getByRole('button', { name: '当前缩放比例 50%' }), ).toBeTruthy(); }); it('shows the Lovart-style minimap and canvas background settings panel', () => { render(); const viewport = screen.getByLabelText('画布工作区'); const panelToolbar = screen.getByRole('toolbar', { name: '画布面板入口' }); const backgroundButton = within(panelToolbar).getByRole('button', { name: '画布背景色', }); expect(screen.queryByRole('button', { name: '画布小地图' })).toBeNull(); expect(backgroundButton.className).toContain('platform-icon-button'); const minimapToggle = within(panelToolbar).getByRole('button', { name: '切换小地图', }); expect(minimapToggle).toBeTruthy(); fireEvent.click(minimapToggle); expect(screen.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(minimapToggle); expect(screen.queryByRole('button', { name: '画布小地图' })).toBeNull(); }); it('renders Agent generation thumbnails as passive previews', async () => { enableEditorAgentSidebarForTest(); const detail = createEditorAgentDetailWithGeneration('resource-puzzle'); listEditorAgentConversationsMock.mockResolvedValueOnce([ createEditorAgentConversationSummary(), ]); getEditorAgentConversationMock.mockResolvedValueOnce(detail); render(); await ensureAgentConversationOpen(); const messageLog = await screen.findByRole('log', { name: '画布 Agent 消息流', }); expect( within(messageLog).queryByRole('button', { name: '生成结果' }), ).toBeNull(); expect(within(messageLog).queryByText('gpt-image-2')).toBeNull(); expect(loadEditorProjectMock).not.toHaveBeenCalled(); }); it('refreshes canvas and asset library when Agent generation finishes', async () => { enableEditorAgentSidebarForTest(); let showGeneratedAsset = false; loadEditorAssetLibraryMock.mockImplementation(async () => ({ folders: [ { folderId: 'project', label: '项目素材', sortOrder: 0, collapsed: false, systemDefault: true, }, ], assets: showGeneratedAsset ? [ ...defaultEditorAssetLibraryAssets, { assetId: 'asset-agent-stream', folderId: 'project', label: 'Agent结果素材', imageSrc: '/agent-stream.png', width: 512, height: 512, sourceType: 'generated', assetKind: 'editor_agent_generated_image', }, ] : defaultEditorAssetLibraryAssets, })); listEditorAgentConversationsMock.mockResolvedValueOnce([ createEditorAgentConversationSummary(), ]); getEditorAgentConversationMock.mockResolvedValueOnce({ ...createEditorAgentConversationSummary(), messages: [], }); streamEditorAgentMessageMock.mockImplementation( async (conversationId, _payload, options) => { options.onEvent?.({ event: 'message_delta', data: { conversationId, messageId: 'assistant-agent-stream', role: 'assistant', kind: 'chat', textDelta: '我来生成图片。', }, }); showGeneratedAsset = true; options.onEvent?.({ event: 'generation_result', data: { conversationId, messageId: 'assistant-agent-stream', toolCallId: 'tool-call-agent-stream', toolName: 'generate_image', model: 'gpt-image-2', images: [ { resourceId: 'resource-agent-stream', imageSrc: '/agent-stream.png', thumbnailSrc: null, width: 512, height: 512, }, ], }, }); options.onEvent?.({ event: 'done', data: { conversationId, title: null }, }); }, ); loadEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-default', title: '默认项目', viewport: { x: 0, y: 0, scale: 1 }, layers: [ ...defaultEditorProjectLayers, { layerId: 'layer-agent-stream', resourceId: 'resource-agent-stream', title: 'Agent结果图', src: '/agent-stream.png', x: 1500, y: 300, width: 512, height: 512, originalWidth: 512, originalHeight: 512, zIndex: 3, sourceType: 'generated', }, ], resources: [ ...defaultEditorProjectResources, { resourceId: 'resource-agent-stream', projectId: 'editor-project-default', imageSrc: '/agent-stream.png', width: 512, height: 512, sourceType: 'generated', prompt: 'Agent生成结果', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'task-agent-stream', assetKind: 'editor_agent_generated_image', }, ], updatedAt: '2026-07-03T00:02:00.000Z', }); render(); await ensureAgentConversationOpen(); await waitFor(() => { expect(getEditorAgentConversationMock).toHaveBeenCalledWith( 'editor-agent-conv-test', ); }); fireEvent.change(await screen.findByLabelText('发送给画布 Agent'), { target: { value: '生成一张图' }, }); fireEvent.click(screen.getByRole('button', { name: '发送' })); await waitFor(() => { expect(loadEditorProjectMock).toHaveBeenCalledWith( 'editor-project-default', ); }); expect(await screen.findByAltText('画布图片:Agent结果图')).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '打开素材' })); expect(await screen.findByText('Agent结果素材')).toBeTruthy(); }); 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('renders 100 percent zoom with the previous 50 percent visual scale', () => { render(); expect( screen.getByRole('button', { name: '当前缩放比例 100%' }), ).toBeTruthy(); expect( (screen.getByTestId('image-canvas-world') as HTMLElement).style.transform, ).toBe('translate(0px, 0px) scale(0.5)'); }); 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 = openMinimap(); 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', async () => { render(); const minimap = openMinimap(); 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, }); await waitFor(() => { expect(readTranslateX()).toBeLessThan(0); }); const translateAfterRightDrag = readTranslateX(); dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', { button: 0, pointerId: 72, clientX: 90, clientY: 42, }); await waitFor(() => { 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('switches tools and restores the previous tool after holding Space', async () => { render(); const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' }); const selectTool = within(bottomToolbar).getByRole('button', { name: '选择工具', }); const handTool = within(bottomToolbar).getByRole('button', { name: '抓手工具', }); expect(selectTool.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(selectTool.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 selectTool = within(bottomToolbar).getByRole('button', { name: '选择工具', }); await user.click(handTool); expect(handTool.getAttribute('aria-pressed')).toBe('true'); await user.click(selectTool); expect(selectTool.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: 30, clientY: 150, }); 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 handTool = within(bottomToolbar).getByRole('button', { name: '抓手工具', }); await user.click(handTool); expect(handTool.getAttribute('aria-pressed')).toBe('true'); expect( screen.queryByTestId('image-canvas-editor-snap-guide-vertical'), ).toBeNull(); }); 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, ); openAssetSidebar(); 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(); openAssetSidebar(); 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(); }); });