Files
Genarrative/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx
kdletters c22b7803cc 拆分编辑器生成集成测试
迁出图片生成和生成错误集成用例

迁出规范生成、角色形象、图标素材和角色动画集成用例

迁出快速编辑、生成图元数据和修改结果集成用例

保留主编辑器测试的画布基础链路

更新 TRACKING.md 记录第四十阶段验证
2026-06-17 19:00:56 +08:00

2405 lines
86 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* @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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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(
'生成中',
);
});
});