迁出图片生成和生成错误集成用例 迁出规范生成、角色形象、图标素材和角色动画集成用例 迁出快速编辑、生成图元数据和修改结果集成用例 保留主编辑器测试的画布基础链路 更新 TRACKING.md 记录第四十阶段验证
2405 lines
86 KiB
TypeScript
2405 lines
86 KiB
TypeScript
/* @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(
|
||
'生成中',
|
||
);
|
||
});
|
||
});
|