/* @vitest-environment jsdom */ import { act, fireEvent, render, screen, waitFor, within, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { ApiClientError, ImageCanvasEditorView, dispatchPointerEvent, setupImageCanvasEditorViewTestLifecycle, } from './ImageCanvasEditorView.test-utils'; 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()); 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, }; }); describe('ImageCanvasEditorView generation integration', () => { setupImageCanvasEditorViewTestLifecycle({ generateEditorImageMock, generateEditorIconSpritesheetMock, generateEditorCharacterAnimationMock, editEditorImageMock, createEditorAssetMock, createEditorProjectResourceMock, createEditorAssetFolderMock, updateEditorAssetMock, updateEditorAssetFolderMock, deleteEditorAssetFolderMock, deleteEditorAssetMock, loadEditorAssetLibraryMock, loadEditorProjectMock, loadOrCreateRecentEditorProjectMock, renameEditorProjectMock, saveEditorProjectLayoutMock, }); 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('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( '生成中', ); }); });