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();