diff --git a/TRACKING.md b/TRACKING.md index 0da83738..74c5a0b0 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -156,3 +156,4 @@ - 2026-06-17 前端拆分第三十七阶段:继续收口 `useImageCanvasAssetLibrary`,新增 `ImageCanvasAssetLibraryModel`,把素材分组、可选择素材筛选、全选状态、素材 / 文件夹重命名、文件夹折叠、本地新建文件夹占位、持久化文件夹替换、本地删除素材、删除文件夹回默认文件夹、选择集合切换、批量删除和本地移动素材到文件夹从 hook 中抽成纯模型;asset library hook 继续保留加载账号素材库、后端 CRUD 调用、登录弹窗、DOM 框选和素材拖拽命中生命周期。新增模型单测覆盖分组 / 选择、重命名 / 折叠 / 本地文件夹、本地文件夹持久化替换、删除文件夹回默认文件夹、全选 / 批量删除和本地移动;`useImageCanvasAssetLibrary` 从 609 行降至 573 行。统一验证命令:`npm run test -- src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts src/components/image-editor/useImageCanvasAssetLibrary.test.tsx`、`npm run test -- src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts src/components/image-editor/ImageCanvasUploadModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/image-editor/ImageCanvasSidebarView.test.tsx src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx src/components/image-editor/ImageCanvasEditorShellView.test.tsx src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/ImageCanvasPanelDockView.test.tsx src/components/image-editor/ImageCanvasContextMenusView.test.tsx src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 临时 `XDG_CONFIG_HOME` 下 Chrome headless 打开成功,未登录显示 `账号入口`;关闭后 `画布背景设置` 可打开,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;`打开图层` 切换后侧栏显示 `图层`,点击 `生成工具` 后 `Image Generator`、`生成图片` 对话框和 `AI画布工具栏` 均可见;切回 `打开素材` 后侧栏显示 `素材` 且 `上传到项目素材` 入口可见,控制台仅有预期的未登录 `/api/auth/refresh` 401。 - 2026-06-17 前端拆分第三十八阶段:继续收口 `useImageCanvasGenerationWorkflow`,扩展 `ImageCanvasGenerationSubmissionModel`,把图标素材批量生成的规范校验 / 描述清洗 / 请求 payload / generationInputs,以及角色动画生成的 prompt 清洗 / objectKey 优先源图 / 尺寸 / 价格 / 模型参数从 workflow hook 中抽成纯模型;workflow hook 继续保留对话状态、真实 API 调用、生成结果落图、失败恢复和角色动画面板生命周期。新增模型单测覆盖图标缺少规范、图标空描述、图标描述 trim / 参考快照,以及角色动画 trim、objectKey 源图和价格计算;`useImageCanvasGenerationWorkflow` 从 1104 行降至 1075 行。统一验证命令:`npm run test -- src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx`、`npm run test -- src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts src/components/image-editor/ImageCanvasUploadModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/image-editor/ImageCanvasSidebarView.test.tsx src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx src/components/image-editor/ImageCanvasEditorShellView.test.tsx src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/ImageCanvasPanelDockView.test.tsx src/components/image-editor/ImageCanvasContextMenusView.test.tsx src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 临时 `XDG_CONFIG_HOME` 下 Chrome headless 打开成功,未登录显示 `账号入口`;关闭后 `画布背景设置` 可打开,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;`打开图层` 切换后侧栏显示 `图层`,点击 `生成工具` 后 `Image Generator`、`生成图片` 对话框和 `AI画布工具栏` 均可见;点击 `生成图标素材` 后 `Icon Generator` 占位和 `生成图标素材` 面板可见,控制台仅有预期的未登录 `/api/auth/refresh` 401。 - 2026-06-17 前端拆分第三十九阶段:开始拆分 `ImageCanvasEditorView.test.tsx` 巨型集成测试,新增 `ImageCanvasEditorView.test-utils` 共享默认工程 / 素材库 mock、认证上下文、pointer / dataTransfer / deferred helper 和生命周期 setup;新增 `ImageCanvasEditorAssetsIntegration.test.tsx`,把素材库默认文件夹去重、文件夹折叠 / 新建 / 重命名 / 删除、素材重命名 / 拖拽移动、多文件上传、未登录续传、上传占位、401 登录、素材选择模式、删除素材同步清理画布图层、素材框选、画布 drop 上传和素材库拖入画布等集成用例从主测试文件迁出;`ImageCanvasEditorView.test.tsx` 从 4817 行降至 3741 行。统一验证命令:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx`、`npm run test -- src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts src/components/image-editor/ImageCanvasUploadModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/image-editor/ImageCanvasSidebarView.test.tsx src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx src/components/image-editor/ImageCanvasEditorShellView.test.tsx src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/ImageCanvasPanelDockView.test.tsx src/components/image-editor/ImageCanvasContextMenusView.test.tsx src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 临时 `XDG_CONFIG_HOME` 下 Chrome headless 打开成功,未登录显示 `账号入口`;关闭后 `画布背景设置` 打开,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;`打开图层` 切换后侧栏显示 `图层`,点击 `生成工具` 后 `Image Generator`、`生成图片` 对话框和 `AI画布工具栏` 均可见,控制台仅有预期的未登录 `/api/auth/refresh` 401。 +- 2026-06-17 前端拆分第四十阶段:继续拆分 `ImageCanvasEditorView.test.tsx` 巨型集成测试,新增 `ImageCanvasEditorGenerationIntegration.test.tsx`,把画布生图占位 / 生成提交、生成错误与未登录、规范生成、角色形象生成、图标素材生成、角色动画、快速编辑、生成图元数据和修改结果等生成相关集成用例从主测试文件迁出;`ImageCanvasEditorView.test.tsx` 从 3741 行降至 1419 行,主测试保留工程加载、导出、画布基础交互、右键菜单、侧栏、缩放、小地图、工具切换、吸附和历史。统一验证命令:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx`、`npm run test -- src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts src/components/image-editor/ImageCanvasUploadModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/image-editor/ImageCanvasSidebarView.test.tsx src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx src/components/image-editor/ImageCanvasEditorShellView.test.tsx src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/ImageCanvasPanelDockView.test.tsx src/components/image-editor/ImageCanvasContextMenusView.test.tsx src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 临时 `XDG_CONFIG_HOME` 下 Chrome headless 打开成功,未登录显示 `账号入口`;关闭后 `画布背景设置` 可打开,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;`打开图层` 切换后侧栏标题为 `图层`;点击 `生成工具` 后 `Image Generator`、`生成图片` 对话框和 `AI画布工具栏` 均可见,控制台仅有预期的未登录 `/api/auth/refresh` 401。 diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx new file mode 100644 index 00000000..08059010 --- /dev/null +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -0,0 +1,2404 @@ +/* @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( + '生成中', + ); + }); +}); diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 93dc46ec..816b63bd 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -17,8 +17,6 @@ import { AuthUiContext, ImageCanvasEditorView, createAuthValue, - createDataTransferStub, - createDeferred, dispatchPointerEvent, readZipText, setupImageCanvasEditorViewTestLifecycle, @@ -1238,2087 +1236,6 @@ describe('ImageCanvasEditorView', () => { }); }); - it('opens a canvas generation frame and composer before creating a generated layer', async () => { - generateEditorImageMock.mockResolvedValueOnce({ - imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==', - width: 1024, - height: 1024, - sourceType: 'generated', - prompt: '一张明亮的拼图主视觉', - actualPrompt: '一张明亮的拼图主视觉', - model: 'gpt-image-2', - provider: 'VectorEngine', - taskId: 'editor-real-task-1', - }); - render(); - - const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' }); - fireEvent.click( - within(bottomToolbar).getByRole('button', { name: '生成工具' }), - ); - - const generateDialog = screen.getByRole('dialog', { name: '生成图片' }); - const initialComposerTop = Number.parseFloat( - (generateDialog as HTMLElement).style.top, - ); - expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); - expect(within(generateDialog).getByText('参考图')).toBeTruthy(); - expect( - within(generateDialog).getByRole('button', { name: '添加参考图' }) - .className, - ).toContain('bg-white/94'); - expect( - within(generateDialog).getByRole('button', { name: '添加参考图' }) - .className, - ).toContain('image-canvas-editor__generation-ref'); - const generatePrompt = screen.getByLabelText('生成提示词'); - expect(generatePrompt.className).toContain('platform-text-field'); - expect(generatePrompt.className).toContain( - 'image-canvas-editor__generation-prompt', - ); - expect( - within(generateDialog).getByRole('button', { - name: '生成比例 1:1 2k 1张', - }).className, - ).toContain('platform-inline-option-button'); - expect( - within(generateDialog).getByRole('button', { - name: '生成模型 GPT Image', - }).className, - ).toContain('platform-inline-option-button'); - expect( - within(generateDialog).getByRole('button', { name: '生成' }).className, - ).toContain('platform-button'); - expect( - within(generateDialog).getByRole('button', { name: '生成' }).className, - ).toContain('image-canvas-editor__generation-submit'); - expect(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).toBeTruthy(); - - fireEvent.change(screen.getByLabelText('生成提示词'), { - target: { value: '一张明亮的拼图主视觉' }, - }); - fireEvent.click( - within(generateDialog).getByRole('button', { name: '生成' }), - ); - - expect(screen.getByRole('status').textContent).toContain('生成中'); - expect(generateEditorImageMock).toHaveBeenCalledWith({ - prompt: '一张明亮的拼图主视觉', - }); - - await waitFor(() => { - expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy(); - }); - const generatedLayer = screen - .getByAltText(/画布图片:生成图片/) - .closest('button')!; - const anchoredGenerateDialog = screen.getByRole('dialog', { - name: '生成图片', - }); - expect(anchoredGenerateDialog).toBeTruthy(); - expect( - Number.parseFloat((anchoredGenerateDialog as HTMLElement).style.top), - ).toBeGreaterThan( - Number.parseFloat((generatedLayer as HTMLElement).style.top), - ); - expect( - Number.parseFloat((generatedLayer as HTMLElement).style.top), - ).toBeLessThan(initialComposerTop); - expect(screen.queryByLabelText('图像生成占位图')).toBeNull(); - const metadataButtons = screen.getAllByRole('button', { - name: /查看生成图片 .*图片信息/, - }); - expect(metadataButtons[0]).toBeTruthy(); - fireEvent.click(metadataButtons[0]!); - - const infoPanel = screen.getByRole('dialog', { - name: /生成图片 .*图片信息/, - }); - expect(within(infoPanel).queryByText('Prompt')).toBeNull(); - expect( - within(infoPanel).queryByRole('button', { name: '复制Prompt' }), - ).toBeNull(); - expect(within(infoPanel).getByText('生成输入')).toBeTruthy(); - expect(within(infoPanel).getByText('生成提示词')).toBeTruthy(); - expect(within(infoPanel).getByText('一张明亮的拼图主视觉')).toBeTruthy(); - }); - - it('drags the generation placeholder and places the generated layer there', async () => { - generateEditorImageMock.mockResolvedValueOnce({ - imageSrc: 'data:image/png;base64,ZHJhZ2dlZC1mcmFtZQ==', - width: 1024, - height: 1024, - sourceType: 'generated', - prompt: '拖拽后的生成图', - actualPrompt: '拖拽后的生成图', - model: 'gpt-image-2', - provider: 'VectorEngine', - taskId: 'editor-drag-frame-1', - }); - render(); - await waitFor(() => { - expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled(); - }); - - fireEvent.click(screen.getByRole('button', { name: '生成工具' })); - const initialComposerTop = Number.parseFloat( - (screen.getByRole('dialog', { name: '生成图片' }) as HTMLElement).style - .top, - ); - const frame = screen.getByLabelText('图像生成占位图'); - dispatchPointerEvent(frame, 'pointerdown', { - button: 0, - pointerId: 61, - clientX: 500, - clientY: 260, - }); - dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', { - pointerId: 61, - clientX: 582, - clientY: 342, - }); - dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointerup', { - pointerId: 61, - clientX: 582, - clientY: 342, - }); - const draggedComposerTop = Number.parseFloat( - (screen.getByRole('dialog', { name: '生成图片' }) as HTMLElement).style - .top, - ); - expect(draggedComposerTop).toBeGreaterThan(initialComposerTop); - const draggedFrame = screen.getByLabelText('图像生成占位图') as HTMLElement; - const draggedFrameCenterX = - Number.parseFloat(draggedFrame.style.left) + - Number.parseFloat(draggedFrame.style.width) / 2; - const draggedFrameCenterY = - Number.parseFloat(draggedFrame.style.top) + - Number.parseFloat(draggedFrame.style.height) / 2; - fireEvent.change(screen.getByLabelText('生成提示词'), { - target: { value: '拖拽后的生成图' }, - }); - fireEvent.click(screen.getByRole('button', { name: '生成' })); - - await waitFor(() => { - expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy(); - }); - - const generatedLayer = screen - .getByAltText(/画布图片:生成图片/) - .closest('button')!; - const anchoredGenerateDialog = screen.getByRole('dialog', { - name: '生成图片', - }); - expect(anchoredGenerateDialog).toBeTruthy(); - expect( - Number.parseFloat((anchoredGenerateDialog as HTMLElement).style.top), - ).toBeGreaterThan( - Number.parseFloat((generatedLayer as HTMLElement).style.top), - ); - expect(screen.queryByLabelText('图像生成占位图')).toBeNull(); - expect( - Number.parseFloat((generatedLayer as HTMLElement).style.left) + - Number.parseFloat((generatedLayer as HTMLElement).style.width) / 2, - ).toBeCloseTo(draggedFrameCenterX, 1); - expect( - Number.parseFloat((generatedLayer as HTMLElement).style.top) + - Number.parseFloat((generatedLayer as HTMLElement).style.height) / 2, - ).toBeCloseTo(draggedFrameCenterY, 1); - }); - - it('keeps the generation placeholder draggable while the image is generating', async () => { - let resolveGeneration!: (value: unknown) => void; - generateEditorImageMock.mockReturnValueOnce( - new Promise((resolve) => { - resolveGeneration = resolve; - }), - ); - render(); - await waitFor(() => { - expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled(); - }); - - fireEvent.click(screen.getByRole('button', { name: '生成工具' })); - fireEvent.change(screen.getByLabelText('生成提示词'), { - target: { value: '生成中继续拖动的图片' }, - }); - fireEvent.click(screen.getByRole('button', { name: '生成' })); - - const frame = screen.getByLabelText('图像生成占位图'); - expect(frame.className).toContain( - 'image-canvas-editor__generation-frame--generating', - ); - const initialLeft = Number.parseFloat((frame as HTMLElement).style.left); - const initialTop = Number.parseFloat((frame as HTMLElement).style.top); - - dispatchPointerEvent(frame, 'pointerdown', { - button: 0, - pointerId: 67, - clientX: 500, - clientY: 260, - }); - dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', { - pointerId: 67, - clientX: 620, - clientY: 360, - }); - dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointerup', { - pointerId: 67, - clientX: 620, - clientY: 360, - }); - - const draggedFrame = screen.getByLabelText('图像生成占位图'); - expect( - Number.parseFloat((draggedFrame as HTMLElement).style.left), - ).toBeGreaterThan(initialLeft); - expect( - Number.parseFloat((draggedFrame as HTMLElement).style.top), - ).toBeGreaterThan(initialTop); - - await act(async () => { - resolveGeneration({ - imageSrc: 'data:image/png;base64,Z2VuZXJhdGluZy1kcmFn', - width: 1024, - height: 1024, - sourceType: 'generated', - prompt: '生成中继续拖动的图片', - actualPrompt: '生成中继续拖动的图片', - model: 'gpt-image-2', - provider: 'VectorEngine', - taskId: 'editor-generating-drag-1', - }); - }); - - await waitFor(() => { - expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy(); - }); - const generatedLayer = screen - .getByAltText(/画布图片:生成图片/) - .closest('button')!; - expect( - Number.parseFloat((generatedLayer as HTMLElement).style.left) + - Number.parseFloat((generatedLayer as HTMLElement).style.width) / 2, - ).toBeCloseTo( - Number.parseFloat((draggedFrame as HTMLElement).style.left) + - Number.parseFloat((draggedFrame as HTMLElement).style.width) / 2, - 1, - ); - expect( - Number.parseFloat((generatedLayer as HTMLElement).style.top) + - Number.parseFloat((generatedLayer as HTMLElement).style.height) / 2, - ).toBeCloseTo( - Number.parseFloat((draggedFrame as HTMLElement).style.top) + - Number.parseFloat((draggedFrame as HTMLElement).style.height) / 2, - 1, - ); - }); - - it('hides the generation composer when selecting another image but keeps the placeholder', () => { - render(); - - fireEvent.click(screen.getByRole('button', { name: '生成工具' })); - expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy(); - - fireEvent.pointerDown( - screen.getByAltText('画布图片:拼图素材').closest('button')!, - { - button: 0, - pointerId: 62, - clientX: 120, - clientY: 120, - }, - ); - - expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull(); - expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); - - fireEvent.pointerDown(screen.getByLabelText('图像生成占位图'), { - button: 0, - pointerId: 64, - clientX: 300, - clientY: 180, - }); - - expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy(); - }); - - it('hides the generation composer when clicking the canvas outside generation controls', () => { - render(); - - fireEvent.click(screen.getByRole('button', { name: '生成工具' })); - expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy(); - - fireEvent.pointerDown(screen.getByLabelText('画布工作区'), { - button: 0, - pointerId: 63, - clientX: 260, - clientY: 180, - }); - - expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull(); - expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); - }); - - it('closes the generation composer without removing the placeholder frame', () => { - render(); - - fireEvent.click(screen.getByRole('button', { name: '生成工具' })); - fireEvent.click(screen.getByRole('button', { name: '关闭生成图片' })); - - expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull(); - expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); - }); - - it('shows generation errors instead of falling back to mock images', async () => { - generateEditorImageMock.mockRejectedValueOnce( - new Error('VectorEngine 未配置'), - ); - render(); - - fireEvent.click(screen.getByRole('button', { name: '生成工具' })); - fireEvent.change(screen.getByLabelText('生成提示词'), { - target: { value: '一张真实生成失败的图' }, - }); - fireEvent.click(screen.getByRole('button', { name: '生成' })); - - expect(screen.getByRole('status').textContent).toContain('生成中'); - - await waitFor(() => { - expect(screen.getByRole('alert').textContent).toContain( - 'VectorEngine 未配置', - ); - }); - expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); - expect(screen.queryByAltText(/画布图片:生成图片/)).toBeNull(); - }); - - it('asks the user to log in when real generation is unauthorized', async () => { - generateEditorImageMock.mockRejectedValueOnce( - new ApiClientError({ - message: '未授权访问(requestId: web-login-required)', - status: 401, - code: 'UNAUTHORIZED', - }), - ); - render(); - - fireEvent.click(screen.getByRole('button', { name: '生成工具' })); - fireEvent.change(screen.getByLabelText('生成提示词'), { - target: { value: '一张需要登录生成的图' }, - }); - fireEvent.click(screen.getByRole('button', { name: '生成' })); - - await waitFor(() => { - expect(screen.getByRole('alert').textContent).toBe( - '请先登录后再生成图片', - ); - }); - expect(screen.queryByText(/requestId/u)).toBeNull(); - }); - - it('hides image generation setting panels after generation starts while keeping the preview frame visible', async () => { - const cases = [ - { - open: () => { - fireEvent.click(screen.getByRole('button', { name: '生成工具' })); - fireEvent.change(screen.getByLabelText('生成提示词'), { - target: { value: '生成中的普通图片' }, - }); - fireEvent.click(screen.getByRole('button', { name: '生成' })); - }, - dialogName: '生成图片', - frameLabel: '图像生成占位图', - }, - { - open: () => { - fireEvent.click( - within( - screen.getByRole('toolbar', { name: 'AI画布工具栏' }), - ).getByRole('button', { name: '生成规范' }), - ); - fireEvent.click( - within( - screen.getByRole('menu', { name: '生成规范类型' }), - ).getByRole('menuitem', { name: '自定义规范' }), - ); - fireEvent.change(screen.getByLabelText('自定义规范提示词'), { - target: { value: '生成中的自定义规范图' }, - }); - fireEvent.click( - within(screen.getByRole('dialog', { name: '生成规范' })).getByRole( - 'button', - { name: '提交生成规范' }, - ), - ); - }, - dialogName: '生成规范', - frameLabel: '规范生成占位图', - }, - { - open: () => { - fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); - fireEvent.change(screen.getByLabelText('角色设定'), { - target: { value: '生成中的角色形象' }, - }); - fireEvent.click(screen.getByRole('button', { name: '生成' })); - }, - dialogName: '生成角色形象', - frameLabel: '角色生成占位图', - }, - ] as const; - - for (const testCase of cases) { - generateEditorImageMock.mockReturnValueOnce(new Promise(() => undefined)); - const { unmount } = render(); - await waitFor(() => { - expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled(); - }); - - testCase.open(); - - expect( - screen.queryByRole('dialog', { name: testCase.dialogName }), - ).toBeNull(); - const frame = screen.getByLabelText(testCase.frameLabel); - expect(frame.className).toContain( - 'image-canvas-editor__generation-frame--generating', - ); - expect(within(frame).getByRole('status').textContent).toContain('生成中'); - - unmount(); - } - }); - - it('hides the icon material panel after generation starts while keeping the icon preview frame visible', async () => { - loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ - projectId: 'editor-project-icons-generating', - title: '图标素材生成中画布', - viewport: { x: 0, y: 0, scale: 1 }, - layers: [ - { - layerId: 'layer-icon-spec-generating', - resourceId: 'resource-icon-spec-generating', - title: '清爽按钮图标规范', - src: 'data:image/png;base64,icon-spec-generating', - x: 80, - y: 80, - width: 160, - height: 160, - originalWidth: 512, - originalHeight: 512, - zIndex: 10, - sourceType: 'generated', - assetKind: 'icon-spec', - }, - ], - resources: [], - updatedAt: '2026-06-12T00:00:00.000Z', - }); - generateEditorIconSpritesheetMock.mockReturnValueOnce( - new Promise(() => undefined), - ); - render(); - - await screen.findByAltText('画布图片:清爽按钮图标规范'); - fireEvent.click(screen.getByRole('button', { name: '生成图标素材' })); - const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' }); - fireEvent.click( - within(iconPanel).getByRole('button', { name: '图标素材规范' }), - ); - fireEvent.click( - within(screen.getByRole('menu', { name: '图标素材规范来源' })).getByRole( - 'menuitem', - { name: '从画布中选择' }, - ), - ); - fireEvent.pointerDown( - screen.getByAltText('画布图片:清爽按钮图标规范').closest('button')!, - { - button: 0, - pointerId: 1260, - clientX: 120, - clientY: 120, - }, - ); - fireEvent.click( - within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole( - 'button', - { name: '生成' }, - ), - ); - - expect(screen.queryByRole('dialog', { name: '生成图标素材' })).toBeNull(); - const frame = screen.getByLabelText('图标素材生成占位图'); - expect(frame.className).toContain( - 'image-canvas-editor__generation-frame--generating', - ); - expect(within(frame).getByRole('status').textContent).toContain('生成中'); - }); - - it('opens character spec generation form and creates a labeled spec layer', async () => { - generateEditorImageMock.mockResolvedValueOnce({ - imageSrc: 'data:image/png;base64,c3BlYy1yb2xl', - width: 2048, - height: 1152, - sourceType: 'generated', - prompt: '角色规范提示词', - actualPrompt: '角色规范提示词', - model: 'gpt-image-2', - provider: 'VectorEngine', - taskId: 'editor-spec-role-1', - }); - render(); - - const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' }); - const generationToolLabels = within(bottomToolbar) - .getAllByRole('button') - .filter((button) => button.getAttribute('aria-label')?.startsWith('生成')) - .map((button) => button.getAttribute('aria-label')); - expect(generationToolLabels).toContain('生成工具'); - expect(generationToolLabels).toContain('生成规范'); - fireEvent.click( - within(bottomToolbar).getByRole('button', { name: '生成规范' }), - ); - - const specMenu = screen.getByRole('menu', { name: '生成规范类型' }); - expect( - within(specMenu).getByRole('menuitem', { name: '角色形象规范' }), - ).toBeTruthy(); - expect( - within(specMenu).getByRole('menuitem', { name: 'UI素材规范' }), - ).toBeTruthy(); - expect( - within(specMenu).getByRole('menuitem', { name: '自定义规范' }), - ).toBeTruthy(); - - fireEvent.click( - within(specMenu).getByRole('menuitem', { name: '角色形象规范' }), - ); - - const specDialog = screen.getByRole('dialog', { name: '生成规范' }); - expect(screen.getByLabelText('规范生成占位图')).toBeTruthy(); - expect(screen.getByText('2048 x 1152')).toBeTruthy(); - expect((screen.getByLabelText('玩法设定') as HTMLInputElement).value).toBe( - '战棋类RPG玩法', - ); - expect((screen.getByLabelText('美术风格') as HTMLInputElement).value).toBe( - '像素风', - ); - expect((screen.getByLabelText('头身比') as HTMLSelectElement).value).toBe( - '3', - ); - expect((screen.getByLabelText('角色视角') as HTMLInputElement).value).toBe( - '右向斜侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯右视图,也禁止生成正面立绘。', - ); - expect( - within(specDialog).getByRole('button', { name: '提交生成规范' }) - .textContent, - ).toContain('消耗5泥点'); - - fireEvent.change(screen.getByLabelText('玩法设定'), { - target: { value: '平台跳跃玩法' }, - }); - fireEvent.change(screen.getByLabelText('美术风格'), { - target: { value: '低多边形卡通' }, - }); - fireEvent.change(screen.getByLabelText('头身比'), { - target: { value: '4' }, - }); - fireEvent.change(screen.getByLabelText('角色视角'), { - target: { value: '左向三分之二侧身站姿' }, - }); - fireEvent.click( - within(specDialog).getByRole('button', { name: '提交生成规范' }), - ); - - expect(generateEditorImageMock).toHaveBeenCalledWith({ - kind: 'spec', - model: 'gemini-3.1-flash-image-preview', - size: '2048x1152', - prompt: expect.stringContaining('玩法设计:平台跳跃玩法'), - }); - const prompt = generateEditorImageMock.mock.calls[0]?.[0]?.prompt ?? ''; - expect(prompt).toContain('生成2D 角色美术视觉规范设定图'); - expect(prompt).toContain('美术风格:低多边形卡通'); - expect(prompt).toContain('头身比:4'); - expect(prompt).toContain('视角要求:左向三分之二侧身站姿'); - - await waitFor(() => { - expect(screen.getByAltText(/画布图片:角色形象规范/)).toBeTruthy(); - }); - expect(screen.getByText('规范')).toBeTruthy(); - await waitFor(() => { - expect(createEditorProjectResourceMock).toHaveBeenCalledWith( - 'editor-project-default', - expect.objectContaining({ - sourceType: 'generated', - width: 2048, - height: 1152, - }), - ); - }); - await waitFor(() => { - expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( - 'editor-project-default', - expect.objectContaining({ - layers: expect.arrayContaining([ - expect.objectContaining({ - title: expect.stringMatching(/角色形象规范/u), - assetKind: 'spec', - }), - ]), - }), - ); - }); - }); - - it('shows visible titles for character spec, icon spec, and icon spritesheet generation fields', async () => { - render(); - await screen.findByAltText('画布图片:拼图素材'); - - fireEvent.click( - within(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).getByRole( - 'button', - { name: '生成规范' }, - ), - ); - fireEvent.click(screen.getByRole('menuitem', { name: '角色形象规范' })); - - const characterSpecDialog = screen.getByRole('dialog', { - name: '生成规范', - }); - ['玩法设定', '美术风格', '头身比', '角色视角'].forEach((title) => { - expect(within(characterSpecDialog).getByText(title)).toBeTruthy(); - }); - - fireEvent.click(screen.getByRole('button', { name: '生成图标素材' })); - - const iconSpritesheetPanel = screen.getByRole('dialog', { - name: '生成图标素材', - }); - expect( - within(iconSpritesheetPanel).getByRole('button', { - name: '图标素材规范', - }), - ).toBeTruthy(); - expect(within(iconSpritesheetPanel).getByText('素材描述')).toBeTruthy(); - expect(within(iconSpritesheetPanel).getByText('素材描述 1')).toBeTruthy(); - expect(within(iconSpritesheetPanel).getByText('素材描述 6')).toBeTruthy(); - expect(within(iconSpritesheetPanel).getByText('模型')).toBeTruthy(); - - fireEvent.click( - within(iconSpritesheetPanel).getByRole('button', { - name: '图标素材规范', - }), - ); - fireEvent.click(screen.getByRole('menuitem', { name: '新建图标素材规范' })); - - const iconSpecDialog = screen.getByRole('dialog', { name: '生成规范' }); - ['玩法设定', '美术风格'].forEach((title) => { - expect(within(iconSpecDialog).getByText(title)).toBeTruthy(); - }); - }); - - it('defaults character and icon generation to nanobanana2 model options', async () => { - render(); - await screen.findByAltText('画布图片:拼图素材'); - - fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); - const characterPanel = screen.getByRole('dialog', { - name: '生成角色形象', - }); - expect(within(characterPanel).getByText('画面比例')).toBeTruthy(); - expect(within(characterPanel).getByText('大小尺寸')).toBeTruthy(); - expect(within(characterPanel).getByText('模型')).toBeTruthy(); - expect( - within(characterPanel).getByRole('button', { name: '1:1' }), - ).toBeTruthy(); - expect( - within(characterPanel).getByRole('button', { name: '1K' }), - ).toBeTruthy(); - expect( - within(characterPanel).getByRole('button', { name: 'nanobanana2' }), - ).toBeTruthy(); - - fireEvent.click(screen.getByRole('button', { name: '生成图标素材' })); - const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' }); - expect(within(iconPanel).getByText('画面比例')).toBeTruthy(); - expect(within(iconPanel).getByText('大小尺寸')).toBeTruthy(); - expect(within(iconPanel).getByText('模型')).toBeTruthy(); - expect( - within(iconPanel).getByRole('button', { name: 'nanobanana2' }), - ).toBeTruthy(); - }); - - it('submits character generation with default model and dimension options', async () => { - generateEditorImageMock.mockResolvedValueOnce({ - imageSrc: 'data:image/png;base64,character-model-options', - width: 1024, - height: 1536, - sourceType: 'generated', - prompt: '高个子游侠', - actualPrompt: '高个子游侠', - model: 'gpt-image-2', - provider: 'VectorEngine', - taskId: 'character-model-options-1', - }); - render(); - await screen.findByAltText('画布图片:拼图素材'); - - fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); - const characterPanel = screen.getByRole('dialog', { - name: '生成角色形象', - }); - fireEvent.change(within(characterPanel).getByLabelText('角色设定'), { - target: { value: '高个子游侠' }, - }); - fireEvent.click( - within(characterPanel).getByRole('button', { name: '生成' }), - ); - - await waitFor(() => { - expect(generateEditorImageMock).toHaveBeenCalledWith( - expect.objectContaining({ - kind: 'character', - prompt: '高个子游侠', - model: 'gemini-3.1-flash-image-preview', - aspectRatio: '1:1', - imageSize: '1K', - }), - ); - }); - }); - - it('remembers the last selected image model for character and icon generation', async () => { - generateEditorImageMock.mockResolvedValueOnce({ - imageSrc: 'data:image/png;base64,character-gpt-model', - width: 1024, - height: 1024, - sourceType: 'generated', - prompt: '蓝衣剑士', - actualPrompt: '蓝衣剑士', - model: 'gpt-image-2', - provider: 'VectorEngine', - taskId: 'character-gpt-model-1', - }); - generateEditorIconSpritesheetMock.mockResolvedValueOnce({ - spritesheetImageSrc: 'data:image/png;base64,sheet-gpt-model', - spritesheetWidth: 1024, - spritesheetHeight: 1024, - iconImageSrcs: [ - { - name: '返回按钮', - imageSrc: 'data:image/png;base64,back', - width: 128, - height: 128, - }, - ], - prompt: '图标 prompt', - actualPrompt: '图标 prompt', - model: 'gpt-image-2', - provider: 'VectorEngine', - taskId: 'icon-gpt-model-1', - }); - render(); - await screen.findByAltText('画布图片:拼图素材'); - - fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); - const characterPanel = screen.getByRole('dialog', { - name: '生成角色形象', - }); - fireEvent.click( - within(characterPanel).getByRole('button', { name: 'gpt-image-2' }), - ); - fireEvent.click(within(characterPanel).getByRole('button', { name: '2:3' })); - fireEvent.click(within(characterPanel).getByRole('button', { name: '2K' })); - fireEvent.change(within(characterPanel).getByLabelText('角色设定'), { - target: { value: '蓝衣剑士' }, - }); - fireEvent.click( - within(characterPanel).getByRole('button', { name: '生成' }), - ); - - await waitFor(() => { - expect(generateEditorImageMock).toHaveBeenCalledWith( - expect.objectContaining({ - kind: 'character', - prompt: '蓝衣剑士', - model: 'gpt-image-2', - aspectRatio: '2:3', - imageSize: '2K', - }), - ); - }); - - fireEvent.click(screen.getByRole('button', { name: '生成图标素材' })); - const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' }); - expect( - within(iconPanel).getByRole('button', { name: 'gpt-image-2' }), - ).toBeTruthy(); - fireEvent.click( - within(iconPanel).getByRole('button', { name: '图标素材规范' }), - ); - fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' })); - await userEvent.upload( - screen.getByLabelText('上传图片文件'), - new File(['icon-spec'], '图标规范.png', { type: 'image/png' }), - ); - fireEvent.click( - within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole( - 'button', - { name: '生成' }, - ), - ); - - await waitFor(() => { - expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith( - expect.objectContaining({ - model: 'gpt-image-2', - aspectRatio: '1:1', - imageSize: '1K', - }), - ); - }); - }); - - it('keeps the bottom AI toolbar visible while generation panels are open', () => { - render(); - - fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); - - expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy(); - expect(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).toBeTruthy(); - }); - - it('keeps existing generation placeholders when another bottom generation object is created', async () => { - render(); - await act(async () => {}); - - const bottomToolbar = screen.getByRole('toolbar', { - name: 'AI画布工具栏', - }); - fireEvent.click( - within(bottomToolbar).getByRole('button', { name: '生成规范' }), - ); - fireEvent.click(screen.getByRole('menuitem', { name: '角色形象规范' })); - - expect(screen.getByLabelText('规范生成占位图')).toBeTruthy(); - expect(screen.getByRole('dialog', { name: '生成规范' })).toBeTruthy(); - - fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); - - expect(screen.getByLabelText('规范生成占位图')).toBeTruthy(); - expect(screen.getByLabelText('角色生成占位图')).toBeTruthy(); - expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy(); - - fireEvent.pointerDown(screen.getByLabelText('规范生成占位图'), { - button: 0, - pointerId: 1701, - clientX: 180, - clientY: 180, - }); - - expect(screen.getByRole('dialog', { name: '生成规范' })).toBeTruthy(); - expect(screen.getByLabelText('角色生成占位图')).toBeTruthy(); - }); - - it('keeps archived generation logic using the latest placeholder when another object is active', async () => { - let resolveGeneration!: (value: unknown) => void; - generateEditorImageMock.mockReturnValueOnce( - new Promise((resolve) => { - resolveGeneration = resolve; - }), - ); - render(); - await waitFor(() => { - expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled(); - }); - - fireEvent.click(screen.getByRole('button', { name: '生成工具' })); - fireEvent.change(screen.getByLabelText('生成提示词'), { - target: { value: '生成中切换后仍保留位置' }, - }); - fireEvent.click(screen.getByRole('button', { name: '生成' })); - - const originalFrame = screen.getByLabelText('图像生成占位图'); - const originalLeft = Number.parseFloat( - (originalFrame as HTMLElement).style.left, - ); - const originalTop = Number.parseFloat( - (originalFrame as HTMLElement).style.top, - ); - - fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); - expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); - const characterFrame = screen.getByLabelText('角色生成占位图'); - expect(characterFrame).toBeTruthy(); - - dispatchPointerEvent( - screen.getByLabelText('图像生成占位图'), - 'pointerdown', - { - button: 0, - pointerId: 1702, - clientX: 500, - clientY: 260, - }, - ); - dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', { - pointerId: 1702, - clientX: 650, - clientY: 390, - }); - dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointerup', { - pointerId: 1702, - clientX: 650, - clientY: 390, - }); - const movedFrame = screen.getByLabelText('图像生成占位图'); - const movedLeft = Number.parseFloat((movedFrame as HTMLElement).style.left); - const movedTop = Number.parseFloat((movedFrame as HTMLElement).style.top); - expect(movedLeft).toBeGreaterThan(originalLeft); - expect(movedTop).toBeGreaterThan(originalTop); - - dispatchPointerEvent(characterFrame, 'pointerdown', { - button: 0, - pointerId: 1703, - clientX: 360, - clientY: 240, - }); - dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointerup', { - pointerId: 1703, - clientX: 360, - clientY: 240, - }); - expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy(); - - await act(async () => { - resolveGeneration({ - imageSrc: 'data:image/png;base64,YXJjaGl2ZWQtbG9naWM=', - width: 1024, - height: 1024, - sourceType: 'generated', - prompt: '生成中切换后仍保留位置', - actualPrompt: '生成中切换后仍保留位置', - model: 'gpt-image-2', - provider: 'VectorEngine', - taskId: 'editor-archived-generation-1', - }); - }); - - await waitFor(() => { - expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy(); - }); - const generatedLayer = screen - .getByAltText(/画布图片:生成图片/) - .closest('button') as HTMLElement; - const expectedLayerLeft = - movedLeft + - Number.parseFloat((movedFrame as HTMLElement).style.width) / 2 - - 512; - const expectedLayerTop = - movedTop + - Number.parseFloat((movedFrame as HTMLElement).style.height) / 2 - - 512; - expect(Number.parseFloat(generatedLayer.style.left)).toBeCloseTo( - expectedLayerLeft, - 1, - ); - expect(Number.parseFloat(generatedLayer.style.top)).toBeCloseTo( - expectedLayerTop, - 1, - ); - expect(screen.getByLabelText('角色生成占位图')).toBeTruthy(); - }); - - it('renders editor popup menus outside clipped local containers', () => { - render(); - - const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' }); - fireEvent.click( - within(bottomToolbar).getByRole('button', { name: '生成规范' }), - ); - const specMenu = screen.getByRole('menu', { name: '生成规范类型' }); - - expect(bottomToolbar.contains(specMenu)).toBe(false); - - fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); - const characterPanel = screen.getByRole('dialog', { name: '生成角色形象' }); - fireEvent.click( - within(characterPanel).getByRole('button', { name: '角色形象规范' }), - ); - const referenceRow = characterPanel.querySelector( - '.image-canvas-editor__character-reference-row', - ); - const sourceMenu = screen.getByRole('menu', { name: '角色形象规范来源' }); - - expect(referenceRow?.contains(sourceMenu)).toBe(false); - expect(sourceMenu.className).toContain('platform-floating-menu--top-start'); - - fireEvent.click( - within(characterPanel).getByRole('button', { name: '上传常规参考图' }), - ); - const regularReferenceMenu = screen.getByRole('menu', { - name: '常规参考图来源', - }); - expect(referenceRow?.contains(regularReferenceMenu)).toBe(false); - expect(regularReferenceMenu.className).toContain( - 'platform-floating-menu--top-start', - ); - }); - - it('uses Lovart-style reference tiles in the character generation panel', () => { - render(); - - fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); - const characterPanel = screen.getByRole('dialog', { name: '生成角色形象' }); - const specTile = within(characterPanel).getByRole('button', { - name: '角色形象规范', - }); - const uploadTile = within(characterPanel).getByRole('button', { - name: '上传常规参考图', - }); - - expect(specTile.className).toContain('image-canvas-editor__reference-tile'); - expect(uploadTile.className).toContain( - 'image-canvas-editor__reference-tile', - ); - expect( - specTile.querySelector('.image-canvas-editor__reference-tile-visual'), - ).toBeTruthy(); - expect( - uploadTile.querySelector('.image-canvas-editor__reference-tile-visual'), - ).toBeTruthy(); - }); - - it('expands the icon panel width as new description items are added', async () => { - render(); - - await waitFor(() => { - expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled(); - }); - - fireEvent.click(screen.getByRole('button', { name: '生成图标素材' })); - - const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' }); - expect(Number.parseFloat(iconPanel.style.width)).toBeCloseTo(52.8, 1); - expect( - iconPanel.querySelector('.image-canvas-editor__icon-description-list'), - ).toBeTruthy(); - expect( - iconPanel.querySelector('.image-canvas-editor__icon-description-card'), - ).toBeTruthy(); - expect( - iconPanel.querySelector('.image-canvas-editor__icon-spec-card'), - ).toBeTruthy(); - - fireEvent.click( - within(iconPanel).getByRole('button', { name: '添加素材描述' }), - ); - - expect(Number.parseFloat(iconPanel.style.width)).toBeCloseTo(61.2, 1); - expect(within(iconPanel).getAllByRole('textbox')).toHaveLength(7); - }); - - it('hides the active generation panel and clears image selection after canvas background focus', async () => { - generateEditorImageMock.mockResolvedValueOnce({ - imageSrc: 'data:image/png;base64,Zm9jdXMtY2xlYXI=', - width: 1024, - height: 1024, - sourceType: 'generated', - prompt: '发光蘑菇角色', - actualPrompt: '发光蘑菇角色', - model: 'gpt-image-2', - provider: 'VectorEngine', - taskId: 'editor-focus-clear-1', - }); - render(); - - fireEvent.click(screen.getByRole('button', { name: '生成工具' })); - fireEvent.change(screen.getByLabelText('生成提示词'), { - target: { value: '发光蘑菇角色' }, - }); - fireEvent.click(screen.getByRole('button', { name: '生成' })); - - const generatedImage = await screen.findByAltText(/画布图片:生成图片/u); - const generatedLayerButton = generatedImage.closest('button')!; - expect(generatedLayerButton.className).toContain( - 'image-canvas-editor__layer--selected', - ); - expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy(); - - fireEvent.pointerDown(screen.getByLabelText('画布工作区'), { - button: 0, - pointerId: 261, - clientX: 40, - clientY: 40, - }); - - expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull(); - expect(generatedLayerButton.className).not.toContain( - 'image-canvas-editor__layer--selected', - ); - }); - - it('hides a newly created placeholder panel after canvas background focus', () => { - render(); - - fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); - expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy(); - expect(screen.getByLabelText('角色生成占位图')).toBeTruthy(); - - fireEvent.pointerDown(screen.getByLabelText('画布工作区'), { - button: 0, - pointerId: 262, - clientX: 40, - clientY: 40, - }); - - expect(screen.queryByRole('dialog', { name: '生成角色形象' })).toBeNull(); - expect(screen.getByLabelText('角色生成占位图')).toBeTruthy(); - }); - - it('builds UI spec prompts from two fields and uses 2K landscape generation', async () => { - generateEditorImageMock.mockResolvedValueOnce({ - imageSrc: 'data:image/png;base64,c3BlYy11aQ==', - width: 2048, - height: 1152, - sourceType: 'generated', - prompt: 'UI规范提示词', - actualPrompt: 'UI规范提示词', - model: 'gpt-image-2', - provider: 'VectorEngine', - taskId: 'editor-spec-ui-1', - }); - render(); - - fireEvent.click( - within(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).getByRole( - 'button', - { name: '生成规范' }, - ), - ); - fireEvent.click(screen.getByRole('menuitem', { name: 'UI素材规范' })); - - expect((screen.getByLabelText('玩法设定') as HTMLInputElement).value).toBe( - '抓娃娃题材的抓大鹅玩法', - ); - expect((screen.getByLabelText('美术风格') as HTMLInputElement).value).toBe( - '毛茸茸', - ); - fireEvent.change(screen.getByLabelText('玩法设定'), { - target: { value: '消除类派对玩法' }, - }); - fireEvent.change(screen.getByLabelText('美术风格'), { - target: { value: '糖果玻璃拟物' }, - }); - fireEvent.click( - within(screen.getByRole('dialog', { name: '生成规范' })).getByRole( - 'button', - { name: '提交生成规范' }, - ), - ); - - expect(generateEditorImageMock).toHaveBeenCalledWith({ - kind: 'spec', - model: 'gemini-3.1-flash-image-preview', - size: '2048x1152', - prompt: expect.stringContaining('生成一张完整游戏UI规范汇总设定展板'), - }); - const prompt = generateEditorImageMock.mock.calls[0]?.[0]?.prompt ?? ''; - expect(prompt).toContain('玩法设定:消除类派对玩法'); - expect(prompt).toContain('美术风格:糖果玻璃拟物'); - - await waitFor(() => { - expect(screen.getByAltText(/画布图片:UI素材规范/)).toBeTruthy(); - }); - expect(screen.getByText('规范')).toBeTruthy(); - }); - - it('uses the custom spec prompt without template rewriting', async () => { - generateEditorImageMock.mockResolvedValueOnce({ - imageSrc: 'data:image/png;base64,c3BlYy1jdXN0b20=', - width: 2048, - height: 1152, - sourceType: 'generated', - prompt: '自定义规范提示词', - actualPrompt: '自定义规范提示词', - model: 'gpt-image-2', - provider: 'VectorEngine', - taskId: 'editor-spec-custom-1', - }); - render(); - - fireEvent.click( - within(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).getByRole( - 'button', - { name: '生成规范' }, - ), - ); - fireEvent.click(screen.getByRole('menuitem', { name: '自定义规范' })); - fireEvent.change(screen.getByLabelText('自定义规范提示词'), { - target: { value: ' 生成一张武器图标规范展板 ' }, - }); - fireEvent.click( - within(screen.getByRole('dialog', { name: '生成规范' })).getByRole( - 'button', - { name: '提交生成规范' }, - ), - ); - - expect(generateEditorImageMock).toHaveBeenCalledWith({ - kind: 'spec', - model: 'gemini-3.1-flash-image-preview', - size: '2048x1152', - prompt: '生成一张武器图标规范展板', - }); - await waitFor(() => { - expect(screen.getByAltText(/画布图片:自定义规范/)).toBeTruthy(); - }); - expect(screen.getByText('规范')).toBeTruthy(); - }); - - it('supports character generation from a picked canvas spec and numbered references', async () => { - generateEditorImageMock.mockResolvedValueOnce({ - imageSrc: 'data:image/png;base64,Y2hhcmFjdGVy', - objectKey: - 'generated-character-drafts/editor/character-images/editor-character-1/image.png', - assetObjectId: 'asset-object-editor-character-1', - width: 2048, - height: 2048, - sourceType: 'generated', - prompt: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。', - actualPrompt: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。', - model: 'gpt-image-2', - provider: 'VectorEngine', - taskId: 'editor-character-1', - }); - render(); - - fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); - - const characterPanel = screen.getByRole('dialog', { name: '生成角色形象' }); - expect(screen.getByLabelText('角色生成占位图')).toBeTruthy(); - expect( - within(characterPanel).getByRole('button', { name: '角色形象规范' }), - ).toBeTruthy(); - - fireEvent.click( - within(characterPanel).getByRole('button', { name: '角色形象规范' }), - ); - const specSourceMenu = screen.getByRole('menu', { - name: '角色形象规范来源', - }); - fireEvent.click( - within(specSourceMenu).getByRole('menuitem', { name: '从画布中选择' }), - ); - expect( - screen.getByText('请选择画布中的图片作为角色形象规范,按 Esc 退出'), - ).toBeTruthy(); - - fireEvent.pointerDown( - screen.getByAltText('画布图片:拼图素材').closest('button')!, - { - button: 0, - pointerId: 170, - clientX: 120, - clientY: 120, - }, - ); - expect(within(characterPanel).getByText('拼图素材')).toBeTruthy(); - expect( - screen.queryByText('请选择画布中的图片作为角色形象规范,按 Esc 退出'), - ).toBeNull(); - - const canvasReferenceLayer = screen - .getByAltText('画布图片:大鱼素材') - .closest('button')!; - expect(canvasReferenceLayer.className).not.toContain( - 'image-canvas-editor__layer--selected', - ); - fireEvent.click( - within(characterPanel).getByRole('button', { name: '上传常规参考图' }), - ); - const regularReferenceMenu = screen.getByRole('menu', { - name: '常规参考图来源', - }); - fireEvent.click( - within(regularReferenceMenu).getByRole('menuitem', { - name: '从画布中选择', - }), - ); - expect( - screen.getByText('请选择画布中的图片作为常规参考图,按 Esc 退出'), - ).toBeTruthy(); - fireEvent.pointerDown(canvasReferenceLayer, { - button: 0, - pointerId: 171, - clientX: 180, - clientY: 120, - }); - expect( - screen.queryByText('请选择画布中的图片作为常规参考图,按 Esc 退出'), - ).toBeNull(); - expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy(); - expect(canvasReferenceLayer.className).not.toContain( - 'image-canvas-editor__layer--selected', - ); - expect(within(characterPanel).getByText('1')).toBeTruthy(); - - fireEvent.click( - within(characterPanel).getByRole('button', { name: '上传常规参考图' }), - ); - fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' })); - await userEvent.upload( - screen.getByLabelText('上传图片文件'), - new File(['reference'], '常规参考.png', { type: 'image/png' }), - ); - await waitFor(() => { - expect(within(characterPanel).getByText('2')).toBeTruthy(); - }); - - fireEvent.change(within(characterPanel).getByLabelText('角色设定'), { - target: { value: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。' }, - }); - fireEvent.click( - within(characterPanel).getByRole('button', { name: '生成' }), - ); - - expect(generateEditorImageMock).toHaveBeenCalledWith({ - kind: 'character', - prompt: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。', - model: 'gemini-3.1-flash-image-preview', - aspectRatio: '1:1', - imageSize: '1K', - referenceImageSrcs: [ - '/creation-type-references/puzzle.webp', - '/creation-type-references/big-fish.webp', - expect.stringMatching(/^data:image\/png;base64,/u), - ], - }); - - await waitFor(() => { - expect(screen.getByAltText(/画布图片:角色形象/u)).toBeTruthy(); - }); - expect(screen.getByText('角色')).toBeTruthy(); - fireEvent.click( - screen.getAllByRole('button', { - name: /查看角色形象 .*图片信息/u, - })[0]!, - ); - const characterInfoPanel = screen.getByRole('dialog', { - name: /角色形象 .*图片信息/u, - }); - expect(within(characterInfoPanel).queryByText('Prompt')).toBeNull(); - expect(within(characterInfoPanel).getByText('生成输入')).toBeTruthy(); - expect(within(characterInfoPanel).getByText('角色设定')).toBeTruthy(); - expect( - within(characterInfoPanel).getByText( - '银发游侠,蓝色披风,弓箭手,适合像素风战棋。', - ), - ).toBeTruthy(); - expect(within(characterInfoPanel).getByText('角色形象规范')).toBeTruthy(); - expect(within(characterInfoPanel).getByText('拼图素材')).toBeTruthy(); - expect(within(characterInfoPanel).getByText('常规参考图 1')).toBeTruthy(); - expect(within(characterInfoPanel).getByText('大鱼素材')).toBeTruthy(); - expect(within(characterInfoPanel).getByText('常规参考图 2')).toBeTruthy(); - expect(within(characterInfoPanel).getByText('常规参考.png')).toBeTruthy(); - await waitFor(() => { - expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( - 'editor-project-default', - expect.objectContaining({ - layers: expect.arrayContaining([ - expect.objectContaining({ - title: expect.stringMatching(/角色形象/u), - assetKind: 'character', - objectKey: - 'generated-character-drafts/editor/character-images/editor-character-1/image.png', - assetObjectId: 'asset-object-editor-character-1', - }), - ]), - }), - ); - }); - await waitFor(() => { - expect(createEditorProjectResourceMock).toHaveBeenCalledWith( - 'editor-project-default', - expect.objectContaining({ - objectKey: - 'generated-character-drafts/editor/character-images/editor-character-1/image.png', - assetObjectId: 'asset-object-editor-character-1', - }), - ); - }); - }); - - it('removes the active character generation placeholder with Backspace', async () => { - render(); - - fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); - - expect(screen.getByLabelText('角色生成占位图')).toBeTruthy(); - expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy(); - - await act(async () => { - fireEvent.keyDown(window, { key: 'Backspace', code: 'Backspace' }); - }); - - expect(screen.queryByLabelText('角色生成占位图')).toBeNull(); - expect(screen.queryByRole('dialog', { name: '生成角色形象' })).toBeNull(); - expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy(); - }); - - it('opens icon asset generation panel, only picks icon specs, and lays generated icons on canvas', async () => { - loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ - projectId: 'editor-project-icons', - title: '图标素材画布', - viewport: { x: 0, y: 0, scale: 1 }, - layers: [ - { - layerId: 'layer-plain', - resourceId: 'resource-plain', - title: '普通参考图', - src: 'data:image/png;base64,plain', - x: 80, - y: 80, - width: 120, - height: 120, - originalWidth: 512, - originalHeight: 512, - zIndex: 10, - sourceType: 'uploaded', - }, - { - layerId: 'layer-icon-spec', - resourceId: 'resource-icon-spec', - title: '清爽按钮图标规范', - src: 'data:image/png;base64,icon-spec', - x: 240, - y: 80, - width: 160, - height: 120, - originalWidth: 2048, - originalHeight: 1152, - zIndex: 11, - sourceType: 'generated', - assetKind: 'icon-spec', - }, - ], - resources: [], - updatedAt: '2026-06-15T00:00:00.000Z', - }); - generateEditorIconSpritesheetMock.mockResolvedValueOnce({ - spritesheetImageSrc: 'data:image/png;base64,sheet', - spritesheetWidth: 512, - spritesheetHeight: 512, - iconImageSrcs: [ - { - name: '返回按钮', - imageSrc: 'data:image/png;base64,back-icon', - width: 96, - height: 96, - }, - { - name: '设置按钮', - imageSrc: 'data:image/png;base64,setting-icon', - width: 96, - height: 96, - }, - ], - prompt: '图标 prompt', - actualPrompt: '图标 prompt', - model: 'gemini-3.1-flash-image-preview', - provider: 'VectorEngine', - taskId: 'icon-task-1', - }); - render(); - - await waitFor(() => { - expect(screen.getByAltText('画布图片:普通参考图')).toBeTruthy(); - expect(screen.getByAltText('画布图片:清爽按钮图标规范')).toBeTruthy(); - }); - - fireEvent.click(screen.getByRole('button', { name: '生成图标素材' })); - - const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' }); - expect(screen.getByLabelText('图标素材生成占位图')).toBeTruthy(); - expect( - within(iconPanel).getByRole('button', { name: '图标素材规范' }), - ).toBeTruthy(); - expect( - (within(iconPanel).getAllByRole('textbox')[0] as HTMLInputElement).value, - ).toBe('返回按钮'); - expect( - (within(iconPanel).getAllByRole('textbox')[5] as HTMLInputElement).value, - ).toBe('冻结按钮'); - - fireEvent.click( - within(iconPanel).getByRole('button', { name: '图标素材规范' }), - ); - fireEvent.click(screen.getByRole('menuitem', { name: '从画布中选择' })); - expect( - screen.getByText('请选择画布中的图标素材规范,按 Esc 退出'), - ).toBeTruthy(); - - fireEvent.pointerDown( - screen.getByAltText('画布图片:普通参考图').closest('button')!, - { - button: 0, - pointerId: 180, - clientX: 100, - clientY: 100, - }, - ); - expect( - within(iconPanel).getByRole('button', { name: '图标素材规范' }), - ).toBeTruthy(); - - fireEvent.pointerDown( - screen.getByAltText('画布图片:清爽按钮图标规范').closest('button')!, - { - button: 0, - pointerId: 181, - clientX: 260, - clientY: 100, - }, - ); - expect( - within(iconPanel).getByRole('button', { name: '清爽按钮图标规范' }), - ).toBeTruthy(); - expect( - screen.queryByText('请选择画布中的图标素材规范,按 Esc 退出'), - ).toBeNull(); - - const iconDescriptionInputs = within(iconPanel).getAllByRole('textbox'); - const [ - , - , - iconDescription3, - iconDescription4, - iconDescription5, - iconDescription6, - ] = iconDescriptionInputs; - expect(iconDescription3).toBeTruthy(); - expect(iconDescription4).toBeTruthy(); - expect(iconDescription5).toBeTruthy(); - expect(iconDescription6).toBeTruthy(); - - fireEvent.change(iconDescription3!, { - target: { value: '' }, - }); - fireEvent.change(iconDescription4!, { - target: { value: '' }, - }); - fireEvent.change(iconDescription5!, { - target: { value: '' }, - }); - fireEvent.change(iconDescription6!, { - target: { value: '' }, - }); - fireEvent.click(within(iconPanel).getByRole('button', { name: '生成' })); - - expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith({ - referenceImageSrc: 'data:image/png;base64,icon-spec', - iconDescriptions: ['返回按钮', '设置按钮'], - model: 'gemini-3.1-flash-image-preview', - aspectRatio: '1:1', - imageSize: '1K', - }); - - await waitFor(() => { - expect(screen.getByAltText('画布图片:返回按钮')).toBeTruthy(); - expect(screen.getByAltText('画布图片:设置按钮')).toBeTruthy(); - }); - expect(screen.queryByLabelText('图标素材生成占位图')).toBeNull(); - expect(screen.getAllByText('图标')).toHaveLength(2); - fireEvent.click( - screen.getAllByRole('button', { name: '查看返回按钮图片信息' })[0]!, - ); - const iconInfoPanel = screen.getByRole('dialog', { - name: '返回按钮图片信息', - }); - expect(within(iconInfoPanel).queryByText('Prompt')).toBeNull(); - expect(within(iconInfoPanel).getByText('生成输入')).toBeTruthy(); - expect(within(iconInfoPanel).getByText('素材描述 1')).toBeTruthy(); - expect(within(iconInfoPanel).getByText('素材描述 2')).toBeTruthy(); - expect(within(iconInfoPanel).getByText('返回按钮')).toBeTruthy(); - expect(within(iconInfoPanel).getByText('设置按钮')).toBeTruthy(); - expect(within(iconInfoPanel).getByText('图标素材规范')).toBeTruthy(); - expect(within(iconInfoPanel).getByText('清爽按钮图标规范')).toBeTruthy(); - await waitFor(() => { - expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( - 'editor-project-icons', - expect.objectContaining({ - layers: expect.arrayContaining([ - expect.objectContaining({ - title: '返回按钮', - assetKind: 'icon', - }), - expect.objectContaining({ - title: '设置按钮', - assetKind: 'icon', - }), - ]), - }), - ); - }); - }); - - it('exits character generation canvas picking with Escape', () => { - render(); - - fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); - const characterPanel = screen.getByRole('dialog', { name: '生成角色形象' }); - fireEvent.click( - within(characterPanel).getByRole('button', { name: '角色形象规范' }), - ); - fireEvent.click(screen.getByRole('menuitem', { name: '从画布中选择' })); - - expect( - screen.getByText('请选择画布中的图片作为角色形象规范,按 Esc 退出'), - ).toBeTruthy(); - - fireEvent.keyDown(window, { key: 'Escape' }); - - expect( - screen.queryByText('请选择画布中的图片作为角色形象规范,按 Esc 退出'), - ).toBeNull(); - expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy(); - }); - - it('only exposes character animation generation for character layers and submits the panel payload', async () => { - loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ - projectId: 'editor-project-character-animation', - title: '角色动画画布', - viewport: { x: 0, y: 0, scale: 1 }, - layers: [ - { - layerId: 'layer-character', - resourceId: 'resource-character', - title: '市场老妇人', - src: 'data:image/png;base64,character', - x: 160, - y: 140, - width: 320, - height: 320, - originalWidth: 1024, - originalHeight: 1024, - zIndex: 2, - sourceType: 'generated', - objectKey: - 'generated-character-drafts/editor/character-images/source/image.png', - assetKind: 'character', - }, - { - layerId: 'layer-prop', - resourceId: 'resource-prop', - title: '普通道具', - src: 'data:image/png;base64,prop', - x: 520, - y: 140, - width: 280, - height: 220, - originalWidth: 700, - originalHeight: 550, - zIndex: 1, - sourceType: 'uploaded', - }, - ], - resources: [], - updatedAt: '2026-06-15T00:00:00.000Z', - }); - generateEditorCharacterAnimationMock.mockResolvedValueOnce({ - taskId: 'character-animation-task-1', - model: 'seedance2.0', - prompt: '生成游戏角色动画\n动作描述:\n待机', - previewVideoPath: '/generated-character-drafts/editor/preview.mp4', - frames: Array.from({ length: 48 }, (_, index) => ({ - frameIndex: index + 1, - imageSrc: `/generated-character-drafts/editor/frame${index + 1}.png`, - width: 1024, - height: 1024, - })), - frameCount: 48, - durationSeconds: 6, - fps: 8, - priceMudPoints: 120, - }); - render(); - - const propLayer = await screen.findByAltText('画布图片:普通道具'); - fireEvent.click(propLayer.closest('button')!); - expect(screen.queryByRole('button', { name: '生成动画' })).toBeNull(); - fireEvent.contextMenu(propLayer.closest('button')!, { - clientX: 220, - clientY: 180, - }); - expect(screen.queryByRole('menuitem', { name: '生成动画' })).toBeNull(); - - const characterLayer = screen.getByAltText('画布图片:市场老妇人'); - fireEvent.click(characterLayer.closest('button')!); - expect(screen.getByText('角色')).toBeTruthy(); - expect(screen.getByRole('button', { name: '生成动画' })).toBeTruthy(); - fireEvent.contextMenu(characterLayer.closest('button')!, { - clientX: 260, - clientY: 220, - }); - expect(screen.getByRole('menuitem', { name: '生成动画' })).toBeTruthy(); - - fireEvent.click(screen.getByRole('button', { name: '生成动画' })); - const panel = screen.getByRole('dialog', { name: '角色动画生成面板' }); - expect(within(panel).getByText('40泥点')).toBeTruthy(); - expect( - (within(panel).getByLabelText('分辨率') as HTMLSelectElement).value, - ).toBe('480p'); - expect( - (within(panel).getByLabelText('画面比例') as HTMLSelectElement).value, - ).toBe('same'); - expect( - (within(panel).getByLabelText('时长') as HTMLSelectElement).value, - ).toBe('32'); - for (const actionLabel of [ - '待机', - '行走', - '奔跑', - '跳跃', - '攻击', - '受击', - '倒下', - ]) { - expect( - within(panel).getByRole('button', { name: actionLabel }), - ).toBeTruthy(); - } - fireEvent.click(within(panel).getByRole('button', { name: '待机' })); - expect( - (within(panel).getByLabelText('动画描述') as HTMLTextAreaElement).value, - ).toContain('待机'); - const longPrompt = '走'.repeat(4100); - fireEvent.change(within(panel).getByLabelText('动画描述'), { - target: { value: longPrompt }, - }); - expect( - (within(panel).getByLabelText('动画描述') as HTMLTextAreaElement).value, - ).toHaveLength(4000); - const precisePrompt = - 'The elderly market woman gently shifts weight while the basket sways.'; - fireEvent.change(within(panel).getByLabelText('动画描述'), { - target: { value: precisePrompt }, - }); - expect( - within(panel).getByLabelText(`生成文本:${precisePrompt}`), - ).toBeTruthy(); - fireEvent.change(within(panel).getByLabelText('分辨率'), { - target: { value: '720p' }, - }); - fireEvent.change(within(panel).getByLabelText('画面比例'), { - target: { value: '16:9' }, - }); - fireEvent.change(within(panel).getByLabelText('时长'), { - target: { value: '48' }, - }); - expect(within(panel).getByText('120泥点')).toBeTruthy(); - fireEvent.click(within(panel).getByRole('button', { name: '生成' })); - - expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith( - expect.objectContaining({ - sourceLayerId: 'layer-character', - sourceImageSrc: - 'generated-character-drafts/editor/character-images/source/image.png', - sourceWidth: 1024, - sourceHeight: 1024, - resolution: '720p', - ratio: '16:9', - frameCount: 48, - durationSeconds: 6, - priceMudPoints: 120, - model: 'seedance2.0', - }), - ); - expect( - generateEditorCharacterAnimationMock.mock.calls[0]?.[0]?.promptText, - ).toBe(precisePrompt); - await waitFor(() => { - expect(within(panel).getByText('已生成 48 帧')).toBeTruthy(); - }); - }); - - it('opens quick edit from the floating toolbar with original image as first reference and generates beside the source', async () => { - loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ - projectId: 'editor-project-quick-edit', - title: '快速编辑画布', - viewport: { x: 0, y: 0, scale: 1 }, - layers: [ - { - layerId: 'layer-quick-source', - resourceId: 'resource-quick-source', - title: '魔法森林', - src: 'data:image/png;base64,c291cmNl', - x: 120, - y: 140, - width: 320, - height: 240, - originalWidth: 1536, - originalHeight: 1024, - zIndex: 2, - sourceType: 'generated', - prompt: '魔法森林原始提示词', - actualPrompt: '魔法森林原始提示词', - model: 'gpt-image-2', - provider: 'VectorEngine', - taskId: 'source-task-1', - assetKind: 'spec', - }, - ], - resources: [], - updatedAt: '2026-06-15T00:00:00.000Z', - }); - generateEditorImageMock.mockResolvedValueOnce({ - imageSrc: 'data:image/png;base64,cXVpY2stZWRpdA==', - width: 1536, - height: 1024, - sourceType: 'generated', - prompt: '增加萤火虫', - actualPrompt: '增加萤火虫', - model: 'gpt-image-2', - provider: 'VectorEngine', - taskId: 'quick-edit-task-1', - }); - render(); - - const sourceImage = await screen.findByAltText('画布图片:魔法森林'); - fireEvent.pointerDown(sourceImage.closest('button')!, { - button: 0, - pointerId: 151, - clientX: 180, - clientY: 180, - }); - fireEvent.pointerUp(screen.getByLabelText('画布工作区'), { - pointerId: 151, - clientX: 180, - clientY: 180, - }); - fireEvent.click(screen.getByRole('button', { name: '快速编辑' })); - - const quickPanel = screen.getByRole('dialog', { name: '快速编辑图片' }); - expect(quickPanel.className).toContain( - 'image-canvas-editor__quick-edit-panel', - ); - expect(within(quickPanel).getByText('魔法森林')).toBeTruthy(); - expect( - (within(quickPanel).getByLabelText('快速编辑尺寸') as HTMLSelectElement) - .value, - ).toBe('1536x1024'); - expect( - (within(quickPanel).getByLabelText('快速编辑模型') as HTMLSelectElement) - .value, - ).toBe('gpt-image-2'); - const references = within(quickPanel).getAllByRole('img'); - expect(references[0]?.getAttribute('src')).toBe( - 'data:image/png;base64,c291cmNl', - ); - - fireEvent.change(within(quickPanel).getByLabelText('快速编辑提示词'), { - target: { value: '增加萤火虫' }, - }); - fireEvent.click(within(quickPanel).getByRole('button', { name: '生成' })); - - await waitFor(() => { - expect(generateEditorImageMock).toHaveBeenCalledWith({ - prompt: '增加萤火虫', - size: '1536x1024', - kind: 'quick-edit', - model: 'gpt-image-2', - referenceImageSrcs: ['data:image/png;base64,c291cmNl'], - }); - }); - await waitFor(() => { - expect(screen.getByAltText('画布图片:魔法森林 快速编辑')).toBeTruthy(); - }); - const generatedLayer = screen - .getByAltText('画布图片:魔法森林 快速编辑') - .closest('button') as HTMLElement; - expect(Number.parseFloat(generatedLayer.style.left)).toBe(1688); - expect(Number.parseFloat(generatedLayer.style.top)).toBe(140); - expect(Number.parseFloat(generatedLayer.style.width)).toBe(1536); - expect(Number.parseFloat(generatedLayer.style.height)).toBe(1024); - await waitFor(() => { - expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( - 'editor-project-quick-edit', - expect.objectContaining({ - layers: expect.arrayContaining([ - expect.objectContaining({ - title: '魔法森林 快速编辑', - assetKind: 'spec', - width: 1536, - height: 1024, - originalWidth: 1536, - originalHeight: 1024, - x: 1688, - y: 140, - }), - ]), - }), - ); - }); - }); - - it('opens quick edit from the image context menu', async () => { - loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ - projectId: 'editor-project-context-quick-edit', - title: '右键快速编辑画布', - viewport: { x: 0, y: 0, scale: 1 }, - layers: [ - { - layerId: 'layer-context-source', - resourceId: 'resource-context-source', - title: '右键图片', - src: 'data:image/png;base64,Y29udGV4dA==', - x: 80, - y: 90, - width: 260, - height: 260, - originalWidth: 1024, - originalHeight: 1024, - zIndex: 1, - sourceType: 'uploaded', - model: 'gpt-image-2', - }, - ], - resources: [], - updatedAt: '2026-06-15T00:00:00.000Z', - }); - generateEditorImageMock.mockResolvedValueOnce({ - imageSrc: 'data:image/png;base64,Y29udGV4dC1xdWljaw==', - width: 1024, - height: 1024, - sourceType: 'generated', - prompt: '换成夜晚', - actualPrompt: '换成夜晚', - model: 'gpt-image-2', - provider: 'VectorEngine', - taskId: 'context-quick-task-1', - }); - render(); - - const contextImage = await screen.findByAltText('画布图片:右键图片'); - fireEvent.contextMenu(contextImage.closest('button')!, { - clientX: 260, - clientY: 220, - }); - - const menu = screen.getByRole('menu', { name: '图片功能面板' }); - expect( - within(menu).getByRole('menuitem', { name: '快速编辑' }), - ).toBeTruthy(); - fireEvent.click(within(menu).getByRole('menuitem', { name: '快速编辑' })); - - const panel = screen.getByRole('dialog', { name: '快速编辑图片' }); - expect(within(panel).getByText('右键图片')).toBeTruthy(); - fireEvent.change(within(panel).getByLabelText('快速编辑提示词'), { - target: { value: '换成夜晚' }, - }); - fireEvent.click(within(panel).getByRole('button', { name: '生成' })); - - await waitFor(() => { - expect(generateEditorImageMock).toHaveBeenCalledWith( - expect.objectContaining({ - prompt: '换成夜晚', - referenceImageSrcs: ['data:image/png;base64,Y29udGV4dA=='], - size: '1024x1024', - model: 'gpt-image-2', - kind: 'quick-edit', - }), - ); - }); - await waitFor(() => { - expect(screen.getByAltText('画布图片:右键图片 快速编辑')).toBeTruthy(); - }); - }); - - it('converts non-data-url quick edit source images before submitting references', async () => { - loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ - projectId: 'editor-project-public-quick-edit', - title: '公开素材快速编辑画布', - viewport: { x: 0, y: 0, scale: 1 }, - layers: [ - { - layerId: 'layer-public-source', - resourceId: 'resource-public-source', - title: '公开拼图素材', - src: '/creation-type-references/puzzle.webp', - x: 120, - y: 140, - width: 320, - height: 240, - originalWidth: 640, - originalHeight: 640, - zIndex: 2, - sourceType: 'uploaded', - }, - ], - resources: [], - updatedAt: '2026-06-16T00:00:00.000Z', - }); - vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( - new Response(new Uint8Array([104, 101, 108, 108, 111]), { - status: 200, - headers: { - 'Content-Type': 'image/webp', - }, - }), - ); - generateEditorImageMock.mockResolvedValueOnce({ - imageSrc: 'data:image/png;base64,cHVibGljLXF1aWNr', - width: 640, - height: 640, - sourceType: 'generated', - prompt: '改成陶泥风格', - actualPrompt: '改成陶泥风格', - model: 'gpt-image-2', - provider: 'VectorEngine', - taskId: 'public-quick-edit-task-1', - }); - render(); - - const sourceImage = await screen.findByAltText('画布图片:公开拼图素材'); - fireEvent.pointerDown(sourceImage.closest('button')!, { - button: 0, - pointerId: 161, - clientX: 180, - clientY: 180, - }); - fireEvent.pointerUp(screen.getByLabelText('画布工作区'), { - pointerId: 161, - clientX: 180, - clientY: 180, - }); - fireEvent.click(screen.getByRole('button', { name: '快速编辑' })); - - const quickPanel = screen.getByRole('dialog', { name: '快速编辑图片' }); - fireEvent.change(within(quickPanel).getByLabelText('快速编辑提示词'), { - target: { value: '改成陶泥风格' }, - }); - fireEvent.click(within(quickPanel).getByRole('button', { name: '生成' })); - - await waitFor(() => { - expect(generateEditorImageMock).toHaveBeenCalledWith( - expect.objectContaining({ - prompt: '改成陶泥风格', - kind: 'quick-edit', - referenceImageSrcs: ['data:image/webp;base64,aGVsbG8='], - }), - ); - }); - expect(globalThis.fetch).toHaveBeenCalledWith( - '/creation-type-references/puzzle.webp', - expect.objectContaining({ - signal: undefined, - }), - ); - }); - it('switches tools and restores the previous tool after holding Space', async () => { const user = userEvent.setup(); render(); @@ -3451,245 +1368,6 @@ describe('ImageCanvasEditorView', () => { ).toBeNull(); }); - it('opens generated image info from the corner button and creates a real right-side edit result', async () => { - generateEditorImageMock.mockResolvedValueOnce({ - imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==', - width: 1024, - height: 1024, - sourceType: 'generated', - prompt: '一张可修改的生成图', - actualPrompt: '一张可修改的生成图', - model: 'gpt-image-2', - provider: 'VectorEngine', - taskId: 'editor-real-task-2', - }); - editEditorImageMock.mockResolvedValueOnce({ - imageSrc: 'data:image/png;base64,ZWRpdGVkLWltYWdl', - width: 1024, - height: 1024, - sourceType: 'generated', - prompt: '把画面改成黄昏光线', - actualPrompt: '把画面改成黄昏光线', - model: 'gpt-image-2', - provider: 'VectorEngine', - taskId: 'editor-real-edit-1', - }); - render(); - - fireEvent.click(screen.getByRole('button', { name: '生成工具' })); - fireEvent.change(screen.getByLabelText('生成提示词'), { - target: { value: '一张可修改的生成图' }, - }); - fireEvent.click(screen.getByRole('button', { name: '生成' })); - - await waitFor(() => { - expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy(); - }); - const generatedLayer = screen - .getByAltText(/画布图片:生成图片/) - .closest('button') as HTMLElement; - expect(Number.parseFloat(generatedLayer.style.width)).toBe(1024); - expect(Number.parseFloat(generatedLayer.style.height)).toBe(1024); - expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy(); - - const metadataCornerButton = screen.getAllByRole('button', { - name: /查看生成图片 .*图片信息/, - })[0]; - if (!metadataCornerButton) { - throw new Error('metadata corner button should exist'); - } - expect(metadataCornerButton.className).toContain('bg-black/55'); - expect(metadataCornerButton.className).toContain( - 'image-canvas-editor__metadata-corner', - ); - fireEvent.click(metadataCornerButton); - - const metadataDialog = screen.getByRole('dialog', { - name: /生成图片 .*图片信息/, - }); - expect(metadataDialog).toBeTruthy(); - expect(within(metadataDialog).getByText('图片类型')).toBeTruthy(); - expect(within(metadataDialog).getByText('生成图片')).toBeTruthy(); - expect(within(metadataDialog).queryByText('Prompt')).toBeNull(); - expect( - within(metadataDialog).queryByRole('button', { name: '复制Prompt' }), - ).toBeNull(); - expect(within(metadataDialog).getByText('生成输入')).toBeTruthy(); - expect(within(metadataDialog).getByText('生成提示词')).toBeTruthy(); - expect(within(metadataDialog).getByText('一张可修改的生成图')).toBeTruthy(); - expect(within(metadataDialog).getByText('Model')).toBeTruthy(); - expect(within(metadataDialog).getByText('gpt-image-2')).toBeTruthy(); - expect(within(metadataDialog).queryByText('Size')).toBeNull(); - expect(within(metadataDialog).getByText('Resolution')).toBeTruthy(); - expect(within(metadataDialog).getByText('1024 x 1024 px')).toBeTruthy(); - - fireEvent.click(screen.getByRole('button', { name: '修改图片' })); - const editDialog = screen.getByRole('dialog', { name: '修改图片' }); - expect(editDialog).toBeTruthy(); - const editPrompt = screen.getByLabelText('生成提示词'); - expect(editPrompt.className).toContain('platform-text-field'); - expect(editPrompt.className).toContain( - 'image-canvas-editor__generate-prompt', - ); - fireEvent.change(editPrompt, { - target: { value: '把画面改成黄昏光线' }, - }); - fireEvent.click(screen.getByRole('button', { name: '修改' })); - - expect(screen.getByRole('status').textContent).toContain('修改中'); - await waitFor(() => { - expect(editEditorImageMock).toHaveBeenCalledWith({ - prompt: '把画面改成黄昏光线', - sourceImageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==', - }); - }); - - await waitFor(() => { - expect(screen.queryByRole('dialog', { name: '修改图片' })).toBeNull(); - }); - expect(screen.getByAltText(/画布图片:生成图片 .* 修改结果/)).toBeTruthy(); - fireEvent.click( - screen.getAllByRole('button', { - name: /查看生成图片 .* 修改结果图片信息/u, - })[0]!, - ); - const editedMetadataDialog = screen.getByRole('dialog', { - name: /生成图片 .* 修改结果图片信息/u, - }); - expect(within(editedMetadataDialog).queryByText('Prompt')).toBeNull(); - expect(within(editedMetadataDialog).getByText('修改要求')).toBeTruthy(); - expect( - within(editedMetadataDialog).getByText('把画面改成黄昏光线'), - ).toBeTruthy(); - expect(within(editedMetadataDialog).getByText('参考图')).toBeTruthy(); - expect( - within(editedMetadataDialog).getByText(/^生成图片 \d+$/u), - ).toBeTruthy(); - expect( - screen.getByRole('button', { name: /当前缩放比例 \d+%/u }), - ).toBeTruthy(); - }); - - it('hides the edit image panel after generation starts while keeping the source preview visible', async () => { - loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ - projectId: 'editor-project-edit-generating', - title: '修改图片生成中画布', - viewport: { x: 0, y: 0, scale: 1 }, - layers: [ - { - layerId: 'layer-edit-generating-source', - resourceId: 'resource-edit-generating-source', - title: '待修改图片', - src: 'data:image/png;base64,ZWRpdC1nZW5lcmF0aW5n', - x: 120, - y: 140, - width: 320, - height: 240, - originalWidth: 1024, - originalHeight: 768, - zIndex: 2, - sourceType: 'generated', - prompt: '原始提示词', - actualPrompt: '原始提示词', - model: 'gpt-image-2', - provider: 'VectorEngine', - taskId: 'edit-generating-source-task', - }, - ], - resources: [], - updatedAt: '2026-06-16T00:00:00.000Z', - }); - editEditorImageMock.mockReturnValueOnce(new Promise(() => undefined)); - render(); - - const sourceImage = await screen.findByAltText('画布图片:待修改图片'); - const sourceLayer = sourceImage.closest('button')!; - fireEvent.pointerDown(sourceLayer, { - button: 0, - pointerId: 171, - clientX: 180, - clientY: 180, - }); - fireEvent.pointerUp(screen.getByLabelText('画布工作区'), { - pointerId: 171, - clientX: 180, - clientY: 180, - }); - fireEvent.click(screen.getByRole('button', { name: '修改图片' })); - const editDialog = screen.getByRole('dialog', { name: '修改图片' }); - fireEvent.change(within(editDialog).getByLabelText('生成提示词'), { - target: { value: '改成雨夜灯光' }, - }); - fireEvent.click(within(editDialog).getByRole('button', { name: '修改' })); - - expect(screen.queryByRole('dialog', { name: '修改图片' })).toBeNull(); - expect(screen.getByAltText('画布图片:待修改图片')).toBeTruthy(); - expect(sourceLayer.className).toContain( - 'image-canvas-editor__layer--generating', - ); - expect(within(sourceLayer).getByRole('status').textContent).toContain( - '修改中', - ); - }); - - it('hides the quick edit panel after generation starts while keeping the source preview visible', async () => { - loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ - projectId: 'editor-project-quick-edit-generating', - title: '快速编辑生成中画布', - viewport: { x: 0, y: 0, scale: 1 }, - layers: [ - { - layerId: 'layer-quick-edit-generating-source', - resourceId: 'resource-quick-edit-generating-source', - title: '快速编辑源图', - src: 'data:image/png;base64,cXVpY2stZWRpdC1nZW5lcmF0aW5n', - x: 120, - y: 140, - width: 320, - height: 240, - originalWidth: 1024, - originalHeight: 768, - zIndex: 2, - sourceType: 'uploaded', - model: 'gpt-image-2', - }, - ], - resources: [], - updatedAt: '2026-06-16T00:00:00.000Z', - }); - generateEditorImageMock.mockReturnValueOnce(new Promise(() => undefined)); - render(); - - const sourceImage = await screen.findByAltText('画布图片:快速编辑源图'); - const sourceLayer = sourceImage.closest('button')!; - fireEvent.pointerDown(sourceLayer, { - button: 0, - pointerId: 172, - clientX: 180, - clientY: 180, - }); - fireEvent.pointerUp(screen.getByLabelText('画布工作区'), { - pointerId: 172, - clientX: 180, - clientY: 180, - }); - fireEvent.click(screen.getByRole('button', { name: '快速编辑' })); - const quickPanel = screen.getByRole('dialog', { name: '快速编辑图片' }); - fireEvent.change(within(quickPanel).getByLabelText('快速编辑提示词'), { - target: { value: '加一层暖光' }, - }); - fireEvent.click(within(quickPanel).getByRole('button', { name: '生成' })); - - expect(screen.queryByRole('dialog', { name: '快速编辑图片' })).toBeNull(); - expect(screen.getByAltText('画布图片:快速编辑源图')).toBeTruthy(); - expect(sourceLayer.className).toContain( - 'image-canvas-editor__layer--generating', - ); - expect(within(sourceLayer).getByRole('status').textContent).toContain( - '生成中', - ); - }); - it('undoes and redoes canvas layer changes from the panel controls', () => { render();