/* @vitest-environment jsdom */
import {
act,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiClientError } from '../../services/apiClient';
import { ImageCanvasEditorView } from './ImageCanvasEditorView';
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 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 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,
saveEditorProjectLayout: saveEditorProjectLayoutMock,
updateEditorAssetFolder: updateEditorAssetFolderMock,
};
});
function dispatchPointerEvent(
target: Element,
type: string,
init: MouseEventInit & { pointerId: number },
) {
const event = new MouseEvent(type, {
bubbles: true,
cancelable: true,
...init,
});
Object.defineProperty(event, 'pointerId', { value: init.pointerId });
fireEvent(target, event);
}
describe('ImageCanvasEditorView', () => {
beforeEach(() => {
loadOrCreateRecentEditorProjectMock.mockResolvedValue({
projectId: 'editor-project-default',
title: '默认项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
});
loadEditorAssetLibraryMock.mockResolvedValue({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [],
});
createEditorAssetMock.mockImplementation(async (input) => ({
assetId: `persisted-${input.label}`,
folderId: input.folderId,
label: input.label,
imageSrc: input.imageSrc,
width: input.width,
height: input.height,
sourceType: input.sourceType,
}));
createEditorAssetFolderMock.mockResolvedValue({
folderId: 'folder-role-persisted',
label: '角色上传',
collapsed: false,
systemDefault: false,
});
updateEditorAssetFolderMock.mockImplementation(async (folderId, input) => ({
folderId,
label: input.label ?? '角色上传',
collapsed: input.collapsed ?? false,
systemDefault: false,
}));
deleteEditorAssetFolderMock.mockResolvedValue({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [],
});
deleteEditorAssetMock.mockResolvedValue({});
createEditorProjectResourceMock.mockImplementation(
async (projectId, input) => ({
resourceId: `resource-${projectId}-${input.width}`,
projectId,
imageSrc: input.imageSrc,
width: input.width,
height: input.height,
sourceType: input.sourceType,
}),
);
saveEditorProjectLayoutMock.mockResolvedValue({});
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
generateEditorImageMock.mockReset();
generateEditorIconSpritesheetMock.mockReset();
generateEditorCharacterAnimationMock.mockReset();
editEditorImageMock.mockReset();
createEditorAssetMock.mockReset();
createEditorProjectResourceMock.mockReset();
createEditorAssetFolderMock.mockReset();
updateEditorAssetFolderMock.mockReset();
deleteEditorAssetFolderMock.mockReset();
deleteEditorAssetMock.mockReset();
loadEditorAssetLibraryMock.mockReset();
loadEditorProjectMock.mockReset();
loadOrCreateRecentEditorProjectMock.mockReset();
saveEditorProjectLayoutMock.mockReset();
window.history.replaceState(null, '', '/editor/canvas');
});
it('loads the project from projectid query before falling back to recent project', async () => {
loadEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-query',
title: '查询项目',
viewport: { x: 12, y: 16, scale: 0.8 },
layers: [],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
});
window.history.replaceState(
null,
'',
'/editor/canvas?projectid=editor-project-query',
);
render();
await waitFor(() => {
expect(loadEditorProjectMock).toHaveBeenCalledWith(
'editor-project-query',
);
});
expect(loadOrCreateRecentEditorProjectMock).not.toHaveBeenCalled();
});
it('toggles the shared sidebar from canvas panel buttons', () => {
render();
const sidebar = screen.getByRole('complementary', { name: '图片资源栏' });
const panelToolbar = screen.getByRole('toolbar', { name: '画布面板入口' });
const assetsButton = within(panelToolbar).getByRole('button', {
name: '打开素材',
});
const layersButton = within(panelToolbar).getByRole('button', {
name: '打开图层',
});
expect(within(sidebar).getByText('素材')).toBeTruthy();
expect(
within(sidebar).getByRole('button', { name: '添加拼图素材' }),
).toBeTruthy();
expect(assetsButton.getAttribute('aria-pressed')).toBe('true');
expect(screen.queryByRole('button', { name: '打开已生成文件' })).toBeNull();
expect(screen.queryByRole('button', { name: '收起素材栏' })).toBeNull();
expect(screen.queryByRole('button', { name: '展开素材栏' })).toBeNull();
fireEvent.click(layersButton);
const layerSidebar = screen.getByRole('complementary', {
name: '图片资源栏',
});
expect(within(layerSidebar).getByText('图层')).toBeTruthy();
expect(
within(layerSidebar).getByRole('button', { name: '选择图层拼图素材' }),
).toBeTruthy();
expect(layersButton.getAttribute('aria-pressed')).toBe('true');
expect(screen.queryByRole('button', { name: '添加拼图素材' })).toBeNull();
fireEvent.click(layersButton);
expect(
screen.queryByRole('complementary', { name: '图片资源栏' }),
).toBeNull();
expect(layersButton.getAttribute('aria-pressed')).toBe('false');
});
it('groups assets by folder and renames sidebar materials', async () => {
const user = userEvent.setup();
render();
const sidebar = screen.getByRole('complementary', { name: '图片资源栏' });
expect(
within(sidebar).getByRole('region', { name: '项目素材' }),
).toBeTruthy();
expect(
within(sidebar).queryByRole('region', { name: '参考素材' }),
).toBeNull();
await user.click(
screen.getByRole('button', { name: '重命名素材拼图素材' }),
);
const renameInput = screen.getByLabelText('重命名素材拼图素材');
expect(renameInput.className).toContain('platform-text-field');
expect(renameInput.className).toContain(
'image-canvas-editor__asset-rename-input',
);
await user.clear(renameInput);
await user.type(renameInput, '主视觉素材');
await user.click(
screen.getByRole('button', { name: '保存素材拼图素材名称' }),
);
expect(screen.queryByRole('button', { name: '添加拼图素材' })).toBeNull();
await user.click(screen.getByRole('button', { name: '添加主视觉素材' }));
expect(screen.getByAltText('画布图片:主视觉素材')).toBeTruthy();
});
it('collapses folders, creates upload folders, and deletes uploaded materials', async () => {
const user = userEvent.setup();
const createObjectUrlSpy = vi.fn(() => 'blob:folder-uploaded-image');
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: createObjectUrlSpy,
});
render();
await user.click(screen.getByRole('button', { name: '折叠项目素材' }));
expect(screen.queryByRole('button', { name: '添加拼图素材' })).toBeNull();
await user.click(screen.getByRole('button', { name: '展开项目素材' }));
expect(screen.getByRole('button', { name: '添加拼图素材' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '新建素材文件夹' }));
const folderNameInput = screen.getByLabelText('素材文件夹名称');
expect(folderNameInput.className).toContain('platform-text-field');
expect(folderNameInput.className).toContain(
'image-canvas-editor__folder-create-input',
);
await user.type(folderNameInput, '角色上传');
await user.click(screen.getByRole('button', { name: '保存素材文件夹' }));
const uploadInput = screen.getByLabelText('上传图片文件');
await user.click(screen.getByRole('button', { name: '上传到角色上传' }));
await userEvent.upload(
uploadInput,
new File(['image'], '角色草图.png', { type: 'image/png' }),
);
const customFolder = screen.getByRole('region', { name: '角色上传' });
await waitFor(() => {
expect(
within(customFolder).getByRole('button', { name: '添加角色草图.png' }),
).toBeTruthy();
expect(
within(customFolder).getByRole('button', {
name: '删除素材角色草图.png',
}),
).toBeTruthy();
});
await user.click(
within(customFolder).getByRole('button', {
name: '删除素材角色草图.png',
}),
);
expect(
screen.queryByRole('button', { name: '添加角色草图.png' }),
).toBeNull();
expect(screen.queryByAltText('画布图片:角色草图.png')).toBeNull();
});
it('renames and deletes asset folders through the persisted asset library API', async () => {
const user = userEvent.setup();
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
{
folderId: 'folder-role',
label: '角色',
sortOrder: 100,
collapsed: false,
systemDefault: false,
},
],
assets: [],
});
render();
await screen.findByRole('region', { name: '角色' });
await user.click(screen.getByRole('button', { name: '重命名文件夹角色' }));
const folderRenameInput = screen.getByLabelText('重命名文件夹角色');
expect(folderRenameInput.className).toContain('platform-text-field');
expect(folderRenameInput.className).toContain(
'image-canvas-editor__folder-rename-input',
);
await user.clear(folderRenameInput);
await user.type(folderRenameInput, '角色参考');
await user.click(
screen.getByRole('button', { name: '保存文件夹角色名称' }),
);
expect(updateEditorAssetFolderMock).toHaveBeenCalledWith('folder-role', {
label: '角色参考',
});
await user.click(
screen.getByRole('button', { name: '删除文件夹角色参考' }),
);
expect(deleteEditorAssetFolderMock).toHaveBeenCalledWith('folder-role');
});
it('uploads multiple files as account-level assets without adding canvas layers', async () => {
render();
await userEvent.upload(screen.getByLabelText('上传图片文件'), [
new File(['image-a'], '第一张.png', { type: 'image/png' }),
new File(['image-b'], '第二张.png', { type: 'image/png' }),
]);
await waitFor(() => {
expect(
screen.getByRole('button', { name: '添加第一张.png' }),
).toBeTruthy();
expect(
screen.getByRole('button', { name: '添加第二张.png' }),
).toBeTruthy();
});
expect(createEditorAssetMock).toHaveBeenCalledTimes(2);
expect(screen.queryByAltText('画布图片:第一张.png')).toBeNull();
expect(screen.queryByAltText('画布图片:第二张.png')).toBeNull();
});
it('supports asset selection mode and batch delete with shared toolbar', async () => {
const user = userEvent.setup();
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [
{
assetId: 'asset-a',
folderId: 'project',
label: '账号素材A',
imageSrc: 'data:image/png;base64,YQ==',
width: 320,
height: 240,
sourceType: 'uploaded',
},
{
assetId: 'asset-b',
folderId: 'project',
label: '账号素材B',
imageSrc: 'data:image/png;base64,Yg==',
width: 320,
height: 240,
sourceType: 'uploaded',
},
],
});
render();
await screen.findByRole('button', { name: '添加账号素材A' });
await user.click(screen.getByRole('button', { name: '素材选择模式' }));
await user.click(screen.getByRole('button', { name: '选择素材账号素材A' }));
const batchToolbar = screen.getByRole('toolbar', { name: '素材批量操作' });
expect(within(batchToolbar).getByText(/已选 1/u)).toBeTruthy();
await user.click(
within(batchToolbar).getByRole('button', { name: '删除' }),
);
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-a');
expect(
screen.queryByRole('button', { name: '选择素材账号素材A' }),
).toBeNull();
});
it('selects multiple assets with a marquee in asset selection mode', async () => {
const user = userEvent.setup();
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [
{
assetId: 'asset-a',
folderId: 'project',
label: '账号素材A',
imageSrc: 'data:image/png;base64,YQ==',
width: 320,
height: 240,
sourceType: 'uploaded',
},
{
assetId: 'asset-b',
folderId: 'project',
label: '账号素材B',
imageSrc: 'data:image/png;base64,Yg==',
width: 320,
height: 240,
sourceType: 'uploaded',
},
],
});
render();
const firstAssetButton = await screen.findByRole('button', {
name: '添加账号素材A',
});
const secondAssetButton = screen.getByRole('button', {
name: '添加账号素材B',
});
const assetList = firstAssetButton.closest(
'.image-canvas-editor__asset-list',
) as HTMLElement;
vi.spyOn(assetList, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
left: 0,
top: 0,
right: 320,
bottom: 600,
width: 320,
height: 600,
toJSON: () => ({}),
});
vi.spyOn(
firstAssetButton.closest('[data-asset-id]') as HTMLElement,
'getBoundingClientRect',
).mockReturnValue({
x: 16,
y: 120,
left: 16,
top: 120,
right: 280,
bottom: 200,
width: 264,
height: 80,
toJSON: () => ({}),
});
vi.spyOn(
secondAssetButton.closest('[data-asset-id]') as HTMLElement,
'getBoundingClientRect',
).mockReturnValue({
x: 16,
y: 240,
left: 16,
top: 240,
right: 280,
bottom: 320,
width: 264,
height: 80,
toJSON: () => ({}),
});
await user.click(screen.getByRole('button', { name: '素材选择模式' }));
dispatchPointerEvent(assetList, 'pointerdown', {
button: 0,
pointerId: 88,
clientX: 8,
clientY: 100,
});
dispatchPointerEvent(assetList, 'pointermove', {
button: 0,
pointerId: 88,
clientX: 300,
clientY: 330,
});
dispatchPointerEvent(assetList, 'pointerup', {
button: 0,
pointerId: 88,
clientX: 300,
clientY: 330,
});
const batchToolbar = screen.getByRole('toolbar', { name: '素材批量操作' });
expect(within(batchToolbar).getByText(/已选 2/u)).toBeTruthy();
});
it('shows image resolution on hover and placeholder toolbar after selecting a layer', () => {
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
render();
const canvasImage = screen.getByAltText('画布图片:拼图素材');
fireEvent.mouseEnter(canvasImage.closest('button')!);
const sizeBadge = screen.getByText('640 x 640 px');
expect(sizeBadge.className).toContain('rounded-full');
expect(sizeBadge.className).toContain('image-canvas-editor__size-badge');
fireEvent.pointerDown(canvasImage.closest('button')!, {
button: 0,
pointerId: 1,
clientX: 120,
clientY: 120,
});
const cropButton = screen.getByRole('button', { name: '裁剪占位' });
fireEvent.pointerDown(cropButton, {
button: 0,
pointerId: 2,
clientX: 120,
clientY: 96,
});
fireEvent.click(cropButton);
expect(alertSpy).toHaveBeenCalledWith('裁剪功能建设中');
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).toBeTruthy();
});
it('opens image info for uploaded canvas images without generated edit tools', () => {
render();
fireEvent.pointerDown(
screen.getByAltText('画布图片:拼图素材').closest('button')!,
{
button: 0,
pointerId: 61,
clientX: 120,
clientY: 120,
},
);
const infoButton = screen.getByRole('button', {
name: '查看拼图素材图片信息',
});
expect(infoButton.className).toContain(
'image-canvas-editor__metadata-corner',
);
fireEvent.click(infoButton);
const infoPanel = screen.getByRole('dialog', { name: '拼图素材图片信息' });
expect(within(infoPanel).getByText('图片类型')).toBeTruthy();
expect(within(infoPanel).getByText('上传图片')).toBeTruthy();
expect(within(infoPanel).getByText('生成输入')).toBeTruthy();
expect(
infoPanel.querySelector('.image-canvas-editor__metadata-inputs')
?.textContent,
).toBe('-');
expect(within(infoPanel).queryByText('Prompt')).toBeNull();
expect(within(infoPanel).getByText('Model')).toBeTruthy();
expect(within(infoPanel).queryByText('Size')).toBeNull();
expect(within(infoPanel).getByText('Resolution')).toBeTruthy();
expect(within(infoPanel).getByText('640 x 640 px')).toBeTruthy();
expect(
within(infoPanel).queryByRole('button', { name: '复制Prompt' }),
).toBeNull();
expect(screen.queryByRole('button', { name: '修改图片' })).toBeNull();
});
it('hydrates canvas images from Resolution instead of saved Size', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-resolution',
title: '原分辨率画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-resolution',
resourceId: 'resource-resolution',
title: '旧布局图片',
src: 'data:image/png;base64,cmVzb2x1dGlvbg==',
x: 120,
y: 140,
width: 320,
height: 240,
originalWidth: 1536,
originalHeight: 1024,
zIndex: 2,
sourceType: 'generated',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'resolution-task-1',
},
],
resources: [],
updatedAt: '2026-06-16T00:00:00.000Z',
});
render();
const canvasImage = await screen.findByAltText('画布图片:旧布局图片');
const canvasLayer = canvasImage.closest('button') as HTMLElement;
expect(Number.parseFloat(canvasLayer.style.width)).toBe(1536);
expect(Number.parseFloat(canvasLayer.style.height)).toBe(1024);
fireEvent.mouseEnter(canvasLayer);
expect(screen.getByText('1536 x 1024 px')).toBeTruthy();
fireEvent.click(
screen.getAllByRole('button', { name: '查看旧布局图片图片信息' })[0]!,
);
const infoPanel = screen.getByRole('dialog', {
name: '旧布局图片图片信息',
});
expect(within(infoPanel).queryByText('Size')).toBeNull();
expect(within(infoPanel).getByText('Resolution')).toBeTruthy();
expect(within(infoPanel).getByText('1536 x 1024 px')).toBeTruthy();
});
it('deletes the selected layer from the floating toolbar', () => {
render();
expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy();
fireEvent.pointerDown(
screen.getByAltText('画布图片:拼图素材').closest('button')!,
{
button: 0,
pointerId: 51,
clientX: 120,
clientY: 120,
},
);
fireEvent.click(screen.getByRole('button', { name: '删除图片' }));
expect(screen.queryByAltText('画布图片:拼图素材')).toBeNull();
expect(screen.getByAltText('画布图片:大鱼素材')).toBeTruthy();
});
it('deletes the selected layer with Backspace when focus is outside text inputs', async () => {
render();
expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy();
fireEvent.pointerDown(
screen.getByAltText('画布图片:拼图素材').closest('button')!,
{
button: 0,
pointerId: 52,
clientX: 120,
clientY: 120,
},
);
await act(async () => {
fireEvent.keyDown(window, { key: 'Backspace', code: 'Backspace' });
});
expect(screen.queryByAltText('画布图片:拼图素材')).toBeNull();
expect(screen.getByAltText('画布图片:大鱼素材')).toBeTruthy();
});
it('drops an image file on the canvas as a new canvas layer', async () => {
render();
await waitFor(() => {
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
});
const viewport = screen.getByLabelText('画布工作区');
fireEvent.drop(viewport, {
clientX: 430,
clientY: 260,
dataTransfer: {
files: [new File(['image'], '测试上传.png', { type: 'image/png' })],
types: ['Files'],
},
});
await waitFor(() => {
expect(screen.getByAltText('画布图片:测试上传.png')).toBeTruthy();
});
expect(createEditorAssetMock).toHaveBeenCalledWith(
expect.objectContaining({
label: '测试上传.png',
imageSrc: expect.stringMatching(/^data:image\/png;base64,/u),
}),
);
expect(
screen.getByRole('button', { name: '选择图层测试上传.png' }),
).toBeTruthy();
});
it('drops files into the asset panel only once without creating canvas layers', async () => {
render();
fireEvent.drop(screen.getByRole('region', { name: '项目素材' }), {
dataTransfer: {
files: [new File(['image'], '素材拖拽.png', { type: 'image/png' })],
types: ['Files'],
},
});
await waitFor(() => {
expect(
screen.getByRole('button', { name: '添加素材拖拽.png' }),
).toBeTruthy();
});
expect(createEditorAssetMock).toHaveBeenCalledTimes(1);
expect(screen.queryByAltText('画布图片:素材拖拽.png')).toBeNull();
});
it('blocks the browser context menu inside the editor workspace', () => {
render();
const editor = screen.getByRole('region', { name: '图片画布编辑器' });
const contextMenuEvent = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
});
const wasNotCanceled = editor.dispatchEvent(contextMenuEvent);
expect(wasNotCanceled).toBe(false);
expect(contextMenuEvent.defaultPrevented).toBe(true);
});
it('switches the shared sidebar between assets and layers', () => {
render();
const sidebar = screen.getByRole('complementary', { name: '图片资源栏' });
expect(within(sidebar).getByText('素材')).toBeTruthy();
expect(within(sidebar).queryByText('已生成文件')).toBeNull();
expect(within(sidebar).queryByText('图层')).toBeNull();
expect(screen.queryByRole('toolbar', { name: '画布主工具栏' })).toBeNull();
expect(
screen.queryByRole('complementary', { name: '图层面板' }),
).toBeNull();
expect(screen.queryByRole('dialog', { name: '已生成文件' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '打开图层' }));
const layersPanel = screen.getByRole('complementary', {
name: '图片资源栏',
});
expect(
within(layersPanel).getByRole('button', { name: '选择图层拼图素材' }),
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '选择图层大鱼素材' }));
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).toBeTruthy();
expect(
screen.getByRole('button', { name: '查看大鱼素材图片信息' }),
).toBeTruthy();
expect(screen.queryByRole('button', { name: '修改图片' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '打开素材' }));
expect(screen.getByRole('button', { name: '添加拼图素材' })).toBeTruthy();
});
it('adds assets from the sidebar and supports zoom buttons', () => {
render();
expect(
screen.getByRole('button', { name: '当前缩放比例 82%' }).className,
).toContain('platform-inline-option-button');
fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 82%' }));
fireEvent.click(screen.getByRole('menuitem', { name: '放大' }));
expect(
screen.getByRole('button', { name: '当前缩放比例 95%' }),
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' }));
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
expect(
screen.getByRole('complementary', { name: '图片资源栏' }),
).toBeTruthy();
});
it('offers Lovart-style zoom menu commands', async () => {
render();
fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 82%' }));
expect(screen.getByRole('menu', { name: '缩放菜单' })).toBeTruthy();
expect(
screen.getByRole('menuitem', { name: '显示画布所有元素' }),
).toBeTruthy();
fireEvent.click(screen.getByRole('menuitem', { name: '缩放至100%' }));
expect(screen.getByRole('button', { name: /当前缩放比例 \d+%/u })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 100%' }));
fireEvent.click(screen.getByRole('menuitem', { name: '缩放至50%' }));
expect(
screen.getByRole('button', { name: '当前缩放比例 50%' }),
).toBeTruthy();
});
it('shows the Lovart-style minimap and canvas background controls', () => {
render();
const viewport = screen.getByLabelText('画布工作区');
const panelToolbar = screen.getByRole('toolbar', { name: '画布面板入口' });
expect(screen.getByRole('button', { name: '画布小地图' })).toBeTruthy();
expect(
within(panelToolbar).getByRole('button', { name: '画布背景色' })
.className,
).toContain('platform-icon-button');
expect(
within(panelToolbar).getByRole('button', { name: '切换小地图' }),
).toBeTruthy();
fireEvent.click(
within(panelToolbar).getByRole('button', { name: '画布背景色' }),
);
expect(screen.getByRole('menu', { name: '画布背景色菜单' })).toBeTruthy();
fireEvent.click(
screen.getByRole('menuitem', { name: '切换画布背景色为暖灰' }),
);
expect((viewport as HTMLElement).style.backgroundColor).toBe(
'rgb(243, 240, 234)',
);
fireEvent.click(
within(panelToolbar).getByRole('button', { name: '切换小地图' }),
);
expect(screen.queryByRole('button', { name: '画布小地图' })).toBeNull();
});
it('uses normal wheel for vertical canvas scroll and ctrl wheel for zoom', () => {
render();
const viewport = screen.getByLabelText('画布工作区');
expect(
screen.getByRole('button', { name: '当前缩放比例 82%' }),
).toBeTruthy();
fireEvent.wheel(viewport, { deltaY: 120, clientX: 400, clientY: 280 });
expect(
screen.getByRole('button', { name: '当前缩放比例 82%' }),
).toBeTruthy();
fireEvent.wheel(viewport, {
deltaY: -120,
ctrlKey: true,
clientX: 400,
clientY: 280,
});
expect(
screen.getByRole('button', { name: '当前缩放比例 90%' }),
).toBeTruthy();
const ctrlWheelEvent = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
ctrlKey: true,
deltaY: -120,
clientX: 400,
clientY: 280,
});
viewport.dispatchEvent(ctrlWheelEvent);
expect(ctrlWheelEvent.defaultPrevented).toBe(true);
});
it('selects multiple canvas layers with shift click', async () => {
render();
const firstLayer = screen
.getByAltText('画布图片:拼图素材')
.closest('button')!;
const secondLayer = screen
.getByAltText('画布图片:大鱼素材')
.closest('button')!;
fireEvent.pointerDown(firstLayer, {
button: 0,
pointerId: 81,
clientX: 120,
clientY: 120,
});
fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
pointerId: 81,
clientX: 120,
clientY: 120,
});
await waitFor(() => {
expect(
screen.getByAltText('画布图片:拼图素材').closest('button')?.className,
).toContain('image-canvas-editor__layer--selected');
});
fireEvent.keyDown(window, { key: 'Shift', code: 'ShiftLeft' });
fireEvent.pointerDown(secondLayer, {
button: 0,
pointerId: 82,
clientX: 520,
clientY: 180,
shiftKey: true,
});
fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
pointerId: 82,
clientX: 520,
clientY: 180,
});
fireEvent.keyUp(window, { key: 'Shift', code: 'ShiftLeft' });
await waitFor(() => {
expect(
screen.getByAltText('画布图片:拼图素材').closest('button')?.className,
).toContain('image-canvas-editor__layer--selected');
expect(
screen.getByAltText('画布图片:大鱼素材').closest('button')?.className,
).toContain('image-canvas-editor__layer--selected');
});
});
it('drags the minimap to move the canvas viewport', () => {
render();
const minimap = screen.getByRole('button', { name: '画布小地图' });
vi.spyOn(minimap, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
left: 0,
top: 0,
right: 132,
bottom: 84,
width: 132,
height: 84,
toJSON: () => ({}),
});
dispatchPointerEvent(minimap, 'pointerdown', {
button: 0,
pointerId: 71,
clientX: 120,
clientY: 72,
});
const firstLayer = screen
.getByAltText('画布图片:拼图素材')
.closest('button')!;
expect(Number.parseFloat((firstLayer as HTMLElement).style.left)).toBe(470);
expect(screen.getByRole('button', { name: '画布小地图' })).toBeTruthy();
});
it('persists layer groups in the canvas layer snapshot', async () => {
render();
fireEvent.click(screen.getByRole('button', { name: '打开图层' }));
fireEvent.pointerDown(
screen.getByAltText('画布图片:拼图素材').closest('button')!,
{
button: 0,
pointerId: 90,
clientX: 120,
clientY: 120,
},
);
fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
pointerId: 90,
clientX: 120,
clientY: 120,
});
await waitFor(() => {
expect(
screen.getByAltText('画布图片:拼图素材').closest('button')?.className,
).toContain('image-canvas-editor__layer--selected');
});
fireEvent.keyDown(window, { key: 'Shift', code: 'ShiftLeft' });
fireEvent.pointerDown(
screen.getByAltText('画布图片:大鱼素材').closest('button')!,
{
button: 0,
pointerId: 91,
clientX: 520,
clientY: 180,
shiftKey: true,
},
);
fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
pointerId: 91,
clientX: 520,
clientY: 180,
});
fireEvent.keyUp(window, { key: 'Shift', code: 'ShiftLeft' });
await waitFor(() => {
expect(
screen.getByAltText('画布图片:拼图素材').closest('button')?.className,
).toContain('image-canvas-editor__layer--selected');
expect(
screen.getByAltText('画布图片:大鱼素材').closest('button')?.className,
).toContain('image-canvas-editor__layer--selected');
});
fireEvent.click(screen.getByRole('button', { name: '图层打组' }));
await waitFor(() => {
expect(screen.getAllByText(/已打组/u)).toHaveLength(2);
});
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
layers: expect.arrayContaining([
expect.objectContaining({
title: '拼图素材',
groupId: expect.stringMatching(/^layer-group-/u),
}),
expect.objectContaining({
title: '大鱼素材',
groupId: expect.stringMatching(/^layer-group-/u),
}),
]),
}),
);
});
});
it('opens a canvas generation frame and composer before creating a generated layer', async () => {
generateEditorImageMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==',
width: 1024,
height: 1024,
sourceType: 'generated',
prompt: '一张明亮的拼图主视觉',
actualPrompt: '一张明亮的拼图主视觉',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'editor-real-task-1',
});
render();
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
fireEvent.click(
within(bottomToolbar).getByRole('button', { name: '生成工具' }),
);
const generateDialog = screen.getByRole('dialog', { name: '生成图片' });
const initialComposerTop = Number.parseFloat(
(generateDialog as HTMLElement).style.top,
);
expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
expect(within(generateDialog).getByText('参考图')).toBeTruthy();
expect(
within(generateDialog).getByRole('button', { name: '添加参考图' })
.className,
).toContain('bg-white/94');
expect(
within(generateDialog).getByRole('button', { name: '添加参考图' })
.className,
).toContain('image-canvas-editor__generation-ref');
const generatePrompt = screen.getByLabelText('生成提示词');
expect(generatePrompt.className).toContain('platform-text-field');
expect(generatePrompt.className).toContain(
'image-canvas-editor__generation-prompt',
);
expect(
within(generateDialog).getByRole('button', {
name: '生成比例 1:1 2k 1张',
}).className,
).toContain('platform-inline-option-button');
expect(
within(generateDialog).getByRole('button', {
name: '生成模型 GPT Image',
}).className,
).toContain('platform-inline-option-button');
expect(
within(generateDialog).getByRole('button', { name: '生成' }).className,
).toContain('platform-button');
expect(
within(generateDialog).getByRole('button', { name: '生成' }).className,
).toContain('image-canvas-editor__generation-submit');
expect(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).toBeTruthy();
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '一张明亮的拼图主视觉' },
});
fireEvent.click(
within(generateDialog).getByRole('button', { name: '生成' }),
);
expect(screen.getByRole('status').textContent).toContain('生成中');
expect(generateEditorImageMock).toHaveBeenCalledWith({
prompt: '一张明亮的拼图主视觉',
});
await waitFor(() => {
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
});
const generatedLayer = screen
.getByAltText(/画布图片:生成图片/)
.closest('button')!;
const anchoredGenerateDialog = screen.getByRole('dialog', {
name: '生成图片',
});
expect(anchoredGenerateDialog).toBeTruthy();
expect(
Number.parseFloat((anchoredGenerateDialog as HTMLElement).style.top),
).toBeGreaterThan(
Number.parseFloat((generatedLayer as HTMLElement).style.top),
);
expect(
Number.parseFloat((generatedLayer as HTMLElement).style.top),
).toBeLessThan(initialComposerTop);
expect(screen.queryByLabelText('图像生成占位图')).toBeNull();
const metadataButtons = screen.getAllByRole('button', {
name: /查看生成图片 .*图片信息/,
});
expect(metadataButtons[0]).toBeTruthy();
fireEvent.click(metadataButtons[0]!);
const infoPanel = screen.getByRole('dialog', {
name: /生成图片 .*图片信息/,
});
expect(within(infoPanel).queryByText('Prompt')).toBeNull();
expect(
within(infoPanel).queryByRole('button', { name: '复制Prompt' }),
).toBeNull();
expect(within(infoPanel).getByText('生成输入')).toBeTruthy();
expect(within(infoPanel).getByText('生成提示词')).toBeTruthy();
expect(within(infoPanel).getByText('一张明亮的拼图主视觉')).toBeTruthy();
});
it('drags the generation placeholder and places the generated layer there', async () => {
generateEditorImageMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,ZHJhZ2dlZC1mcmFtZQ==',
width: 1024,
height: 1024,
sourceType: 'generated',
prompt: '拖拽后的生成图',
actualPrompt: '拖拽后的生成图',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'editor-drag-frame-1',
});
render();
await waitFor(() => {
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
});
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
const initialComposerTop = Number.parseFloat(
(screen.getByRole('dialog', { name: '生成图片' }) as HTMLElement).style
.top,
);
const frame = screen.getByLabelText('图像生成占位图');
dispatchPointerEvent(frame, 'pointerdown', {
button: 0,
pointerId: 61,
clientX: 500,
clientY: 260,
});
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', {
pointerId: 61,
clientX: 582,
clientY: 342,
});
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointerup', {
pointerId: 61,
clientX: 582,
clientY: 342,
});
const draggedComposerTop = Number.parseFloat(
(screen.getByRole('dialog', { name: '生成图片' }) as HTMLElement).style
.top,
);
expect(draggedComposerTop).toBeGreaterThan(initialComposerTop);
const draggedFrame = screen.getByLabelText('图像生成占位图') as HTMLElement;
const draggedFrameCenterX =
Number.parseFloat(draggedFrame.style.left) +
Number.parseFloat(draggedFrame.style.width) / 2;
const draggedFrameCenterY =
Number.parseFloat(draggedFrame.style.top) +
Number.parseFloat(draggedFrame.style.height) / 2;
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '拖拽后的生成图' },
});
fireEvent.click(screen.getByRole('button', { name: '生成' }));
await waitFor(() => {
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
});
const generatedLayer = screen
.getByAltText(/画布图片:生成图片/)
.closest('button')!;
const anchoredGenerateDialog = screen.getByRole('dialog', {
name: '生成图片',
});
expect(anchoredGenerateDialog).toBeTruthy();
expect(
Number.parseFloat((anchoredGenerateDialog as HTMLElement).style.top),
).toBeGreaterThan(
Number.parseFloat((generatedLayer as HTMLElement).style.top),
);
expect(screen.queryByLabelText('图像生成占位图')).toBeNull();
expect(
Number.parseFloat((generatedLayer as HTMLElement).style.left) +
Number.parseFloat((generatedLayer as HTMLElement).style.width) / 2,
).toBeCloseTo(draggedFrameCenterX, 1);
expect(
Number.parseFloat((generatedLayer as HTMLElement).style.top) +
Number.parseFloat((generatedLayer as HTMLElement).style.height) / 2,
).toBeCloseTo(draggedFrameCenterY, 1);
});
it('keeps the generation placeholder draggable while the image is generating', async () => {
let resolveGeneration!: (value: unknown) => void;
generateEditorImageMock.mockReturnValueOnce(
new Promise((resolve) => {
resolveGeneration = resolve;
}),
);
render();
await waitFor(() => {
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
});
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '生成中继续拖动的图片' },
});
fireEvent.click(screen.getByRole('button', { name: '生成' }));
const frame = screen.getByLabelText('图像生成占位图');
expect(frame.className).toContain(
'image-canvas-editor__generation-frame--generating',
);
const initialLeft = Number.parseFloat((frame as HTMLElement).style.left);
const initialTop = Number.parseFloat((frame as HTMLElement).style.top);
dispatchPointerEvent(frame, 'pointerdown', {
button: 0,
pointerId: 67,
clientX: 500,
clientY: 260,
});
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', {
pointerId: 67,
clientX: 620,
clientY: 360,
});
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointerup', {
pointerId: 67,
clientX: 620,
clientY: 360,
});
const draggedFrame = screen.getByLabelText('图像生成占位图');
expect(
Number.parseFloat((draggedFrame as HTMLElement).style.left),
).toBeGreaterThan(initialLeft);
expect(
Number.parseFloat((draggedFrame as HTMLElement).style.top),
).toBeGreaterThan(initialTop);
await act(async () => {
resolveGeneration({
imageSrc: 'data:image/png;base64,Z2VuZXJhdGluZy1kcmFn',
width: 1024,
height: 1024,
sourceType: 'generated',
prompt: '生成中继续拖动的图片',
actualPrompt: '生成中继续拖动的图片',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'editor-generating-drag-1',
});
});
await waitFor(() => {
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
});
const generatedLayer = screen
.getByAltText(/画布图片:生成图片/)
.closest('button')!;
expect(
Number.parseFloat((generatedLayer as HTMLElement).style.left) +
Number.parseFloat((generatedLayer as HTMLElement).style.width) / 2,
).toBeCloseTo(
Number.parseFloat((draggedFrame as HTMLElement).style.left) +
Number.parseFloat((draggedFrame as HTMLElement).style.width) / 2,
1,
);
expect(
Number.parseFloat((generatedLayer as HTMLElement).style.top) +
Number.parseFloat((generatedLayer as HTMLElement).style.height) / 2,
).toBeCloseTo(
Number.parseFloat((draggedFrame as HTMLElement).style.top) +
Number.parseFloat((draggedFrame as HTMLElement).style.height) / 2,
1,
);
});
it('hides the generation composer when selecting another image but keeps the placeholder', () => {
render();
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
fireEvent.pointerDown(
screen.getByAltText('画布图片:拼图素材').closest('button')!,
{
button: 0,
pointerId: 62,
clientX: 120,
clientY: 120,
},
);
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
fireEvent.pointerDown(screen.getByLabelText('图像生成占位图'), {
button: 0,
pointerId: 64,
clientX: 300,
clientY: 180,
});
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
});
it('hides the generation composer when clicking the canvas outside generation controls', () => {
render();
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
fireEvent.pointerDown(screen.getByLabelText('画布工作区'), {
button: 0,
pointerId: 63,
clientX: 260,
clientY: 180,
});
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
});
it('closes the generation composer without removing the placeholder frame', () => {
render();
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
fireEvent.click(screen.getByRole('button', { name: '关闭生成图片' }));
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
});
it('shows generation errors instead of falling back to mock images', async () => {
generateEditorImageMock.mockRejectedValueOnce(
new Error('VectorEngine 未配置'),
);
render();
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '一张真实生成失败的图' },
});
fireEvent.click(screen.getByRole('button', { name: '生成' }));
expect(screen.getByRole('status').textContent).toContain('生成中');
await waitFor(() => {
expect(screen.getByRole('alert').textContent).toContain(
'VectorEngine 未配置',
);
});
expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
expect(screen.queryByAltText(/画布图片:生成图片/)).toBeNull();
});
it('asks the user to log in when real generation is unauthorized', async () => {
generateEditorImageMock.mockRejectedValueOnce(
new ApiClientError({
message: '未授权访问(requestId: web-login-required)',
status: 401,
code: 'UNAUTHORIZED',
}),
);
render();
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '一张需要登录生成的图' },
});
fireEvent.click(screen.getByRole('button', { name: '生成' }));
await waitFor(() => {
expect(screen.getByRole('alert').textContent).toBe(
'请先登录后再生成图片',
);
});
expect(screen.queryByText(/requestId/u)).toBeNull();
});
it('hides image generation setting panels after generation starts while keeping the preview frame visible', async () => {
const cases = [
{
open: () => {
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '生成中的普通图片' },
});
fireEvent.click(screen.getByRole('button', { name: '生成' }));
},
dialogName: '生成图片',
frameLabel: '图像生成占位图',
},
{
open: () => {
fireEvent.click(
within(
screen.getByRole('toolbar', { name: 'AI画布工具栏' }),
).getByRole('button', { name: '生成规范' }),
);
fireEvent.click(
within(
screen.getByRole('menu', { name: '生成规范类型' }),
).getByRole('menuitem', { name: '自定义规范' }),
);
fireEvent.change(screen.getByLabelText('自定义规范提示词'), {
target: { value: '生成中的自定义规范图' },
});
fireEvent.click(
within(screen.getByRole('dialog', { name: '生成规范' })).getByRole(
'button',
{ name: '提交生成规范' },
),
);
},
dialogName: '生成规范',
frameLabel: '规范生成占位图',
},
{
open: () => {
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
fireEvent.change(screen.getByLabelText('角色设定'), {
target: { value: '生成中的角色形象' },
});
fireEvent.click(screen.getByRole('button', { name: '生成' }));
},
dialogName: '生成角色形象',
frameLabel: '角色生成占位图',
},
] as const;
for (const testCase of cases) {
generateEditorImageMock.mockReturnValueOnce(new Promise(() => undefined));
const { unmount } = render();
await waitFor(() => {
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
});
testCase.open();
expect(
screen.queryByRole('dialog', { name: testCase.dialogName }),
).toBeNull();
const frame = screen.getByLabelText(testCase.frameLabel);
expect(frame.className).toContain(
'image-canvas-editor__generation-frame--generating',
);
expect(within(frame).getByRole('status').textContent).toContain('生成中');
unmount();
}
});
it('hides the icon material panel after generation starts while keeping the icon preview frame visible', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-icons-generating',
title: '图标素材生成中画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-icon-spec-generating',
resourceId: 'resource-icon-spec-generating',
title: '清爽按钮图标规范',
src: 'data:image/png;base64,icon-spec-generating',
x: 80,
y: 80,
width: 160,
height: 160,
originalWidth: 512,
originalHeight: 512,
zIndex: 10,
sourceType: 'generated',
assetKind: 'icon-spec',
},
],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
});
generateEditorIconSpritesheetMock.mockReturnValueOnce(
new Promise(() => undefined),
);
render();
await screen.findByAltText('画布图片:清爽按钮图标规范');
fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
fireEvent.click(
within(iconPanel).getByRole('button', { name: '图标素材规范' }),
);
fireEvent.click(
within(screen.getByRole('menu', { name: '图标素材规范来源' })).getByRole(
'menuitem',
{ name: '从画布中选择' },
),
);
fireEvent.pointerDown(
screen.getByAltText('画布图片:清爽按钮图标规范').closest('button')!,
{
button: 0,
pointerId: 1260,
clientX: 120,
clientY: 120,
},
);
fireEvent.click(
within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole(
'button',
{ name: '生成' },
),
);
expect(screen.queryByRole('dialog', { name: '生成图标素材' })).toBeNull();
const frame = screen.getByLabelText('图标素材生成占位图');
expect(frame.className).toContain(
'image-canvas-editor__generation-frame--generating',
);
expect(within(frame).getByRole('status').textContent).toContain('生成中');
});
it('opens character spec generation form and creates a labeled spec layer', async () => {
generateEditorImageMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,c3BlYy1yb2xl',
width: 2048,
height: 1152,
sourceType: 'generated',
prompt: '角色规范提示词',
actualPrompt: '角色规范提示词',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'editor-spec-role-1',
});
render();
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
const generationToolLabels = within(bottomToolbar)
.getAllByRole('button')
.filter((button) => button.getAttribute('aria-label')?.startsWith('生成'))
.map((button) => button.getAttribute('aria-label'));
expect(generationToolLabels).toContain('生成工具');
expect(generationToolLabels).toContain('生成规范');
fireEvent.click(
within(bottomToolbar).getByRole('button', { name: '生成规范' }),
);
const specMenu = screen.getByRole('menu', { name: '生成规范类型' });
expect(
within(specMenu).getByRole('menuitem', { name: '角色形象规范' }),
).toBeTruthy();
expect(
within(specMenu).getByRole('menuitem', { name: 'UI素材规范' }),
).toBeTruthy();
expect(
within(specMenu).getByRole('menuitem', { name: '自定义规范' }),
).toBeTruthy();
fireEvent.click(
within(specMenu).getByRole('menuitem', { name: '角色形象规范' }),
);
const specDialog = screen.getByRole('dialog', { name: '生成规范' });
expect(screen.getByLabelText('规范生成占位图')).toBeTruthy();
expect(screen.getByText('2048 x 1152')).toBeTruthy();
expect((screen.getByLabelText('玩法设定') as HTMLInputElement).value).toBe(
'战棋类RPG玩法',
);
expect((screen.getByLabelText('美术风格') as HTMLInputElement).value).toBe(
'像素风',
);
expect((screen.getByLabelText('头身比') as HTMLSelectElement).value).toBe(
'3',
);
expect((screen.getByLabelText('角色视角') as HTMLInputElement).value).toBe(
'右向斜侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯右视图,也禁止生成正面立绘。',
);
expect(
within(specDialog).getByRole('button', { name: '提交生成规范' })
.textContent,
).toContain('消耗5泥点');
fireEvent.change(screen.getByLabelText('玩法设定'), {
target: { value: '平台跳跃玩法' },
});
fireEvent.change(screen.getByLabelText('美术风格'), {
target: { value: '低多边形卡通' },
});
fireEvent.change(screen.getByLabelText('头身比'), {
target: { value: '4' },
});
fireEvent.change(screen.getByLabelText('角色视角'), {
target: { value: '左向三分之二侧身站姿' },
});
fireEvent.click(
within(specDialog).getByRole('button', { name: '提交生成规范' }),
);
expect(generateEditorImageMock).toHaveBeenCalledWith({
kind: 'spec',
model: 'gpt-image-2',
size: '2048x1152',
prompt: expect.stringContaining('玩法设计:平台跳跃玩法'),
});
const prompt = generateEditorImageMock.mock.calls[0]?.[0]?.prompt ?? '';
expect(prompt).toContain('生成2D 角色美术视觉规范设定图');
expect(prompt).toContain('美术风格:低多边形卡通');
expect(prompt).toContain('头身比:4');
expect(prompt).toContain('视角要求:左向三分之二侧身站姿');
await waitFor(() => {
expect(screen.getByAltText(/画布图片:角色形象规范/)).toBeTruthy();
});
expect(screen.getByText('规范')).toBeTruthy();
await waitFor(() => {
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
sourceType: 'generated',
width: 2048,
height: 1152,
}),
);
});
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
layers: expect.arrayContaining([
expect.objectContaining({
title: expect.stringMatching(/角色形象规范/u),
assetKind: 'spec',
}),
]),
}),
);
});
});
it('shows visible titles for character spec, icon spec, and icon spritesheet generation fields', async () => {
render();
await screen.findByAltText('画布图片:拼图素材');
fireEvent.click(
within(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).getByRole(
'button',
{ name: '生成规范' },
),
);
fireEvent.click(screen.getByRole('menuitem', { name: '角色形象规范' }));
const characterSpecDialog = screen.getByRole('dialog', {
name: '生成规范',
});
['玩法设定', '美术风格', '头身比', '角色视角'].forEach((title) => {
expect(within(characterSpecDialog).getByText(title)).toBeTruthy();
});
fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
const iconSpritesheetPanel = screen.getByRole('dialog', {
name: '生成图标素材',
});
expect(
within(iconSpritesheetPanel).getByRole('button', {
name: '图标素材规范',
}),
).toBeTruthy();
expect(within(iconSpritesheetPanel).getByText('素材描述')).toBeTruthy();
expect(within(iconSpritesheetPanel).getByText('素材描述 1')).toBeTruthy();
expect(within(iconSpritesheetPanel).getByText('素材描述 6')).toBeTruthy();
expect(within(iconSpritesheetPanel).getByText('模型')).toBeTruthy();
fireEvent.click(
within(iconSpritesheetPanel).getByRole('button', {
name: '图标素材规范',
}),
);
fireEvent.click(screen.getByRole('menuitem', { name: '新建图标素材规范' }));
const iconSpecDialog = screen.getByRole('dialog', { name: '生成规范' });
['玩法设定', '美术风格'].forEach((title) => {
expect(within(iconSpecDialog).getByText(title)).toBeTruthy();
});
});
it('defaults character and icon generation to nanobanana2 model options', async () => {
render();
await screen.findByAltText('画布图片:拼图素材');
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
const characterPanel = screen.getByRole('dialog', {
name: '生成角色形象',
});
expect(within(characterPanel).getByText('尺寸比例')).toBeTruthy();
expect(within(characterPanel).getByText('大小尺寸')).toBeTruthy();
expect(
(within(characterPanel).getByLabelText('角色模型') as HTMLSelectElement)
.selectedOptions[0]?.textContent,
).toBe('nanobanana2');
expect(
(
within(characterPanel).getByLabelText(
'角色尺寸比例',
) as HTMLSelectElement
).value,
).toBe('1:1');
expect(
(
within(characterPanel).getByLabelText(
'角色大小尺寸',
) as HTMLSelectElement
).value,
).toBe('1K');
expect(
Array.from(
(
within(characterPanel).getByLabelText(
'角色尺寸比例',
) as HTMLSelectElement
).options,
).map((option) => option.value),
).toContain('1:8');
fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
expect(
(within(iconPanel).getByLabelText('图标模型') as HTMLSelectElement)
.selectedOptions[0]?.textContent,
).toBe('nanobanana2');
expect(
(within(iconPanel).getByLabelText('图标大小尺寸') as HTMLSelectElement)
.value,
).toBe('1K');
});
it('remembers the edited image model and submits character dimension options', async () => {
generateEditorImageMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,character-model-options',
width: 1024,
height: 1536,
sourceType: 'generated',
prompt: '高个子游侠',
actualPrompt: '高个子游侠',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'character-model-options-1',
});
render();
await screen.findByAltText('画布图片:拼图素材');
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
const characterPanel = screen.getByRole('dialog', {
name: '生成角色形象',
});
fireEvent.change(within(characterPanel).getByLabelText('角色模型'), {
target: { value: 'gpt-image-2' },
});
expect(
Array.from(
(
within(characterPanel).getByLabelText(
'角色尺寸比例',
) as HTMLSelectElement
).options,
).map((option) => option.value),
).not.toContain('1:8');
fireEvent.change(within(characterPanel).getByLabelText('角色尺寸比例'), {
target: { value: '2:3' },
});
fireEvent.change(within(characterPanel).getByLabelText('角色大小尺寸'), {
target: { value: '1K' },
});
fireEvent.change(within(characterPanel).getByLabelText('角色设定'), {
target: { value: '高个子游侠' },
});
fireEvent.click(within(characterPanel).getByRole('button', { name: '生成' }));
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'character',
model: 'gpt-image-2',
aspectRatio: '2:3',
imageSize: '1K',
}),
);
});
fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
expect(
(within(iconPanel).getByLabelText('图标模型') as HTMLSelectElement).value,
).toBe('gpt-image-2');
});
it('keeps the bottom AI toolbar visible while generation panels are open', () => {
render();
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy();
expect(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).toBeTruthy();
});
it('keeps existing generation placeholders when another bottom generation object is created', async () => {
render();
await act(async () => {});
const bottomToolbar = screen.getByRole('toolbar', {
name: 'AI画布工具栏',
});
fireEvent.click(
within(bottomToolbar).getByRole('button', { name: '生成规范' }),
);
fireEvent.click(screen.getByRole('menuitem', { name: '角色形象规范' }));
expect(screen.getByLabelText('规范生成占位图')).toBeTruthy();
expect(screen.getByRole('dialog', { name: '生成规范' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
expect(screen.getByLabelText('规范生成占位图')).toBeTruthy();
expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy();
fireEvent.pointerDown(screen.getByLabelText('规范生成占位图'), {
button: 0,
pointerId: 1701,
clientX: 180,
clientY: 180,
});
expect(screen.getByRole('dialog', { name: '生成规范' })).toBeTruthy();
expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
});
it('keeps archived generation logic using the latest placeholder when another object is active', async () => {
let resolveGeneration!: (value: unknown) => void;
generateEditorImageMock.mockReturnValueOnce(
new Promise((resolve) => {
resolveGeneration = resolve;
}),
);
render();
await waitFor(() => {
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
});
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '生成中切换后仍保留位置' },
});
fireEvent.click(screen.getByRole('button', { name: '生成' }));
const originalFrame = screen.getByLabelText('图像生成占位图');
const originalLeft = Number.parseFloat(
(originalFrame as HTMLElement).style.left,
);
const originalTop = Number.parseFloat(
(originalFrame as HTMLElement).style.top,
);
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
const characterFrame = screen.getByLabelText('角色生成占位图');
expect(characterFrame).toBeTruthy();
dispatchPointerEvent(screen.getByLabelText('图像生成占位图'), 'pointerdown', {
button: 0,
pointerId: 1702,
clientX: 500,
clientY: 260,
});
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', {
pointerId: 1702,
clientX: 650,
clientY: 390,
});
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointerup', {
pointerId: 1702,
clientX: 650,
clientY: 390,
});
const movedFrame = screen.getByLabelText('图像生成占位图');
const movedLeft = Number.parseFloat(
(movedFrame as HTMLElement).style.left,
);
const movedTop = Number.parseFloat((movedFrame as HTMLElement).style.top);
expect(movedLeft).toBeGreaterThan(originalLeft);
expect(movedTop).toBeGreaterThan(originalTop);
dispatchPointerEvent(characterFrame, 'pointerdown', {
button: 0,
pointerId: 1703,
clientX: 360,
clientY: 240,
});
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointerup', {
pointerId: 1703,
clientX: 360,
clientY: 240,
});
expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy();
await act(async () => {
resolveGeneration({
imageSrc: 'data:image/png;base64,YXJjaGl2ZWQtbG9naWM=',
width: 1024,
height: 1024,
sourceType: 'generated',
prompt: '生成中切换后仍保留位置',
actualPrompt: '生成中切换后仍保留位置',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'editor-archived-generation-1',
});
});
await waitFor(() => {
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
});
const generatedLayer = screen
.getByAltText(/画布图片:生成图片/)
.closest('button') as HTMLElement;
const expectedLayerLeft =
movedLeft + Number.parseFloat((movedFrame as HTMLElement).style.width) / 2 - 512;
const expectedLayerTop =
movedTop + Number.parseFloat((movedFrame as HTMLElement).style.height) / 2 - 512;
expect(Number.parseFloat(generatedLayer.style.left)).toBeCloseTo(
expectedLayerLeft,
1,
);
expect(Number.parseFloat(generatedLayer.style.top)).toBeCloseTo(
expectedLayerTop,
1,
);
expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
});
it('renders editor popup menus outside clipped local containers', () => {
render();
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
fireEvent.click(
within(bottomToolbar).getByRole('button', { name: '生成规范' }),
);
const specMenu = screen.getByRole('menu', { name: '生成规范类型' });
expect(bottomToolbar.contains(specMenu)).toBe(false);
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
const characterPanel = screen.getByRole('dialog', { name: '生成角色形象' });
fireEvent.click(
within(characterPanel).getByRole('button', { name: '角色形象规范' }),
);
const referenceRow = characterPanel.querySelector(
'.image-canvas-editor__character-reference-row',
);
const sourceMenu = screen.getByRole('menu', { name: '角色形象规范来源' });
expect(referenceRow?.contains(sourceMenu)).toBe(false);
});
it('uses Lovart-style reference tiles in the character generation panel', () => {
render();
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
const characterPanel = screen.getByRole('dialog', { name: '生成角色形象' });
const specTile = within(characterPanel).getByRole('button', {
name: '角色形象规范',
});
const uploadTile = within(characterPanel).getByRole('button', {
name: '上传常规参考图',
});
expect(specTile.className).toContain('image-canvas-editor__reference-tile');
expect(uploadTile.className).toContain(
'image-canvas-editor__reference-tile',
);
expect(
specTile.querySelector('.image-canvas-editor__reference-tile-visual'),
).toBeTruthy();
expect(
uploadTile.querySelector('.image-canvas-editor__reference-tile-visual'),
).toBeTruthy();
});
it('expands the icon panel width as new description items are added', async () => {
render();
await waitFor(() => {
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
});
fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
expect(Number.parseFloat(iconPanel.style.width)).toBeCloseTo(52.8, 1);
expect(
iconPanel.querySelector('.image-canvas-editor__icon-description-list'),
).toBeTruthy();
expect(
iconPanel.querySelector('.image-canvas-editor__icon-description-card'),
).toBeTruthy();
expect(
iconPanel.querySelector('.image-canvas-editor__icon-spec-card'),
).toBeTruthy();
fireEvent.click(within(iconPanel).getByRole('button', { name: '添加素材描述' }));
expect(Number.parseFloat(iconPanel.style.width)).toBeCloseTo(61.2, 1);
expect(within(iconPanel).getAllByRole('textbox')).toHaveLength(7);
});
it('hides the active generation panel and clears image selection after canvas background focus', async () => {
generateEditorImageMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,Zm9jdXMtY2xlYXI=',
width: 1024,
height: 1024,
sourceType: 'generated',
prompt: '发光蘑菇角色',
actualPrompt: '发光蘑菇角色',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'editor-focus-clear-1',
});
render();
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '发光蘑菇角色' },
});
fireEvent.click(screen.getByRole('button', { name: '生成' }));
const generatedImage = await screen.findByAltText(/画布图片:生成图片/u);
const generatedLayerButton = generatedImage.closest('button')!;
expect(generatedLayerButton.className).toContain(
'image-canvas-editor__layer--selected',
);
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
fireEvent.pointerDown(screen.getByLabelText('画布工作区'), {
button: 0,
pointerId: 261,
clientX: 40,
clientY: 40,
});
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
expect(generatedLayerButton.className).not.toContain(
'image-canvas-editor__layer--selected',
);
});
it('hides a newly created placeholder panel after canvas background focus', () => {
render();
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy();
expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
fireEvent.pointerDown(screen.getByLabelText('画布工作区'), {
button: 0,
pointerId: 262,
clientX: 40,
clientY: 40,
});
expect(screen.queryByRole('dialog', { name: '生成角色形象' })).toBeNull();
expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
});
it('builds UI spec prompts from two fields and uses 2K landscape generation', async () => {
generateEditorImageMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,c3BlYy11aQ==',
width: 2048,
height: 1152,
sourceType: 'generated',
prompt: 'UI规范提示词',
actualPrompt: 'UI规范提示词',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'editor-spec-ui-1',
});
render();
fireEvent.click(
within(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).getByRole(
'button',
{ name: '生成规范' },
),
);
fireEvent.click(screen.getByRole('menuitem', { name: 'UI素材规范' }));
expect((screen.getByLabelText('玩法设定') as HTMLInputElement).value).toBe(
'抓娃娃题材的抓大鹅玩法',
);
expect((screen.getByLabelText('美术风格') as HTMLInputElement).value).toBe(
'毛茸茸',
);
fireEvent.change(screen.getByLabelText('玩法设定'), {
target: { value: '消除类派对玩法' },
});
fireEvent.change(screen.getByLabelText('美术风格'), {
target: { value: '糖果玻璃拟物' },
});
fireEvent.click(
within(screen.getByRole('dialog', { name: '生成规范' })).getByRole(
'button',
{ name: '提交生成规范' },
),
);
expect(generateEditorImageMock).toHaveBeenCalledWith({
kind: 'spec',
model: 'gpt-image-2',
size: '2048x1152',
prompt: expect.stringContaining('生成一张完整游戏UI规范汇总设定展板'),
});
const prompt = generateEditorImageMock.mock.calls[0]?.[0]?.prompt ?? '';
expect(prompt).toContain('玩法设定:消除类派对玩法');
expect(prompt).toContain('美术风格:糖果玻璃拟物');
await waitFor(() => {
expect(screen.getByAltText(/画布图片:UI素材规范/)).toBeTruthy();
});
expect(screen.getByText('规范')).toBeTruthy();
});
it('uses the custom spec prompt without template rewriting', async () => {
generateEditorImageMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,c3BlYy1jdXN0b20=',
width: 2048,
height: 1152,
sourceType: 'generated',
prompt: '自定义规范提示词',
actualPrompt: '自定义规范提示词',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'editor-spec-custom-1',
});
render();
fireEvent.click(
within(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).getByRole(
'button',
{ name: '生成规范' },
),
);
fireEvent.click(screen.getByRole('menuitem', { name: '自定义规范' }));
fireEvent.change(screen.getByLabelText('自定义规范提示词'), {
target: { value: ' 生成一张武器图标规范展板 ' },
});
fireEvent.click(
within(screen.getByRole('dialog', { name: '生成规范' })).getByRole(
'button',
{ name: '提交生成规范' },
),
);
expect(generateEditorImageMock).toHaveBeenCalledWith({
kind: 'spec',
model: 'gpt-image-2',
size: '2048x1152',
prompt: '生成一张武器图标规范展板',
});
await waitFor(() => {
expect(screen.getByAltText(/画布图片:自定义规范/)).toBeTruthy();
});
expect(screen.getByText('规范')).toBeTruthy();
});
it('supports character generation from a picked canvas spec and numbered references', async () => {
generateEditorImageMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,Y2hhcmFjdGVy',
objectKey:
'generated-character-drafts/editor/character-images/editor-character-1/image.png',
assetObjectId: 'asset-object-editor-character-1',
width: 2048,
height: 2048,
sourceType: 'generated',
prompt: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。',
actualPrompt: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'editor-character-1',
});
render();
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
const characterPanel = screen.getByRole('dialog', { name: '生成角色形象' });
expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
expect(
within(characterPanel).getByRole('button', { name: '角色形象规范' }),
).toBeTruthy();
fireEvent.click(
within(characterPanel).getByRole('button', { name: '角色形象规范' }),
);
const specSourceMenu = screen.getByRole('menu', {
name: '角色形象规范来源',
});
fireEvent.click(
within(specSourceMenu).getByRole('menuitem', { name: '从画布中选择' }),
);
expect(
screen.getByText('请选择画布中的图片作为角色形象规范,按 Esc 退出'),
).toBeTruthy();
fireEvent.pointerDown(
screen.getByAltText('画布图片:拼图素材').closest('button')!,
{
button: 0,
pointerId: 170,
clientX: 120,
clientY: 120,
},
);
expect(within(characterPanel).getByText('拼图素材')).toBeTruthy();
expect(
screen.queryByText('请选择画布中的图片作为角色形象规范,按 Esc 退出'),
).toBeNull();
fireEvent.click(
within(characterPanel).getByRole('button', { name: '上传常规参考图' }),
);
await userEvent.upload(
screen.getByLabelText('上传图片文件'),
new File(['reference'], '常规参考.png', { type: 'image/png' }),
);
await waitFor(() => {
expect(within(characterPanel).getByText('1')).toBeTruthy();
});
fireEvent.change(within(characterPanel).getByLabelText('角色设定'), {
target: { value: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。' },
});
fireEvent.click(
within(characterPanel).getByRole('button', { name: '生成' }),
);
expect(generateEditorImageMock).toHaveBeenCalledWith({
kind: 'character',
prompt: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。',
referenceImageSrcs: [
'/creation-type-references/puzzle.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('常规参考.png')).toBeTruthy();
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
layers: expect.arrayContaining([
expect.objectContaining({
title: expect.stringMatching(/角色形象/u),
assetKind: 'character',
objectKey:
'generated-character-drafts/editor/character-images/editor-character-1/image.png',
assetObjectId: 'asset-object-editor-character-1',
}),
]),
}),
);
});
await waitFor(() => {
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
objectKey:
'generated-character-drafts/editor/character-images/editor-character-1/image.png',
assetObjectId: 'asset-object-editor-character-1',
}),
);
});
});
it('removes the active character generation placeholder with Backspace', async () => {
render();
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy();
await act(async () => {
fireEvent.keyDown(window, { key: 'Backspace', code: 'Backspace' });
});
expect(screen.queryByLabelText('角色生成占位图')).toBeNull();
expect(screen.queryByRole('dialog', { name: '生成角色形象' })).toBeNull();
expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy();
});
it('opens icon asset generation panel, only picks icon specs, and lays generated icons on canvas', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-icons',
title: '图标素材画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-plain',
resourceId: 'resource-plain',
title: '普通参考图',
src: 'data:image/png;base64,plain',
x: 80,
y: 80,
width: 120,
height: 120,
originalWidth: 512,
originalHeight: 512,
zIndex: 10,
sourceType: 'uploaded',
},
{
layerId: 'layer-icon-spec',
resourceId: 'resource-icon-spec',
title: '清爽按钮图标规范',
src: 'data:image/png;base64,icon-spec',
x: 240,
y: 80,
width: 160,
height: 120,
originalWidth: 2048,
originalHeight: 1152,
zIndex: 11,
sourceType: 'generated',
assetKind: 'icon-spec',
},
],
resources: [],
updatedAt: '2026-06-15T00:00:00.000Z',
});
generateEditorIconSpritesheetMock.mockResolvedValueOnce({
spritesheetImageSrc: 'data:image/png;base64,sheet',
spritesheetWidth: 512,
spritesheetHeight: 512,
iconImageSrcs: [
{
name: '返回按钮',
imageSrc: 'data:image/png;base64,back-icon',
width: 96,
height: 96,
},
{
name: '设置按钮',
imageSrc: 'data:image/png;base64,setting-icon',
width: 96,
height: 96,
},
],
prompt: '图标 prompt',
actualPrompt: '图标 prompt',
model: 'gemini-3.1-flash-image-preview',
provider: 'VectorEngine',
taskId: 'icon-task-1',
});
render();
await waitFor(() => {
expect(screen.getByAltText('画布图片:普通参考图')).toBeTruthy();
expect(screen.getByAltText('画布图片:清爽按钮图标规范')).toBeTruthy();
});
fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
expect(screen.getByLabelText('图标素材生成占位图')).toBeTruthy();
expect(
within(iconPanel).getByRole('button', { name: '图标素材规范' }),
).toBeTruthy();
expect(
(within(iconPanel).getAllByRole('textbox')[0] as HTMLInputElement).value,
).toBe('返回按钮');
expect(
(within(iconPanel).getAllByRole('textbox')[5] as HTMLInputElement).value,
).toBe('冻结按钮');
fireEvent.click(
within(iconPanel).getByRole('button', { name: '图标素材规范' }),
);
fireEvent.click(screen.getByRole('menuitem', { name: '从画布中选择' }));
expect(
screen.getByText('请选择画布中的图标素材规范,按 Esc 退出'),
).toBeTruthy();
fireEvent.pointerDown(
screen.getByAltText('画布图片:普通参考图').closest('button')!,
{
button: 0,
pointerId: 180,
clientX: 100,
clientY: 100,
},
);
expect(
within(iconPanel).getByRole('button', { name: '图标素材规范' }),
).toBeTruthy();
fireEvent.pointerDown(
screen.getByAltText('画布图片:清爽按钮图标规范').closest('button')!,
{
button: 0,
pointerId: 181,
clientX: 260,
clientY: 100,
},
);
expect(
within(iconPanel).getByRole('button', { name: '清爽按钮图标规范' }),
).toBeTruthy();
expect(
screen.queryByText('请选择画布中的图标素材规范,按 Esc 退出'),
).toBeNull();
const iconDescriptionInputs = within(iconPanel).getAllByRole('textbox');
const [
,
,
iconDescription3,
iconDescription4,
iconDescription5,
iconDescription6,
] = iconDescriptionInputs;
expect(iconDescription3).toBeTruthy();
expect(iconDescription4).toBeTruthy();
expect(iconDescription5).toBeTruthy();
expect(iconDescription6).toBeTruthy();
fireEvent.change(iconDescription3!, {
target: { value: '' },
});
fireEvent.change(iconDescription4!, {
target: { value: '' },
});
fireEvent.change(iconDescription5!, {
target: { value: '' },
});
fireEvent.change(iconDescription6!, {
target: { value: '' },
});
fireEvent.click(within(iconPanel).getByRole('button', { name: '生成' }));
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith({
referenceImageSrc: 'data:image/png;base64,icon-spec',
iconDescriptions: ['返回按钮', '设置按钮'],
});
await waitFor(() => {
expect(screen.getByAltText('画布图片:返回按钮')).toBeTruthy();
expect(screen.getByAltText('画布图片:设置按钮')).toBeTruthy();
});
expect(screen.queryByLabelText('图标素材生成占位图')).toBeNull();
expect(screen.getAllByText('图标')).toHaveLength(2);
fireEvent.click(
screen.getAllByRole('button', { name: '查看返回按钮图片信息' })[0]!,
);
const iconInfoPanel = screen.getByRole('dialog', {
name: '返回按钮图片信息',
});
expect(within(iconInfoPanel).queryByText('Prompt')).toBeNull();
expect(within(iconInfoPanel).getByText('生成输入')).toBeTruthy();
expect(within(iconInfoPanel).getByText('素材描述 1')).toBeTruthy();
expect(within(iconInfoPanel).getByText('素材描述 2')).toBeTruthy();
expect(within(iconInfoPanel).getByText('返回按钮')).toBeTruthy();
expect(within(iconInfoPanel).getByText('设置按钮')).toBeTruthy();
expect(within(iconInfoPanel).getByText('图标素材规范')).toBeTruthy();
expect(within(iconInfoPanel).getByText('清爽按钮图标规范')).toBeTruthy();
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
'editor-project-icons',
expect.objectContaining({
layers: expect.arrayContaining([
expect.objectContaining({
title: '返回按钮',
assetKind: 'icon',
}),
expect.objectContaining({
title: '设置按钮',
assetKind: 'icon',
}),
]),
}),
);
});
});
it('exits character generation canvas picking with Escape', () => {
render();
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
const characterPanel = screen.getByRole('dialog', { name: '生成角色形象' });
fireEvent.click(
within(characterPanel).getByRole('button', { name: '角色形象规范' }),
);
fireEvent.click(screen.getByRole('menuitem', { name: '从画布中选择' }));
expect(
screen.getByText('请选择画布中的图片作为角色形象规范,按 Esc 退出'),
).toBeTruthy();
fireEvent.keyDown(window, { key: 'Escape' });
expect(
screen.queryByText('请选择画布中的图片作为角色形象规范,按 Esc 退出'),
).toBeNull();
expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy();
});
it('only exposes character animation generation for character layers and submits the panel payload', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-character-animation',
title: '角色动画画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-character',
resourceId: 'resource-character',
title: '市场老妇人',
src: 'data:image/png;base64,character',
x: 160,
y: 140,
width: 320,
height: 320,
originalWidth: 1024,
originalHeight: 1024,
zIndex: 2,
sourceType: 'generated',
objectKey:
'generated-character-drafts/editor/character-images/source/image.png',
assetKind: 'character',
},
{
layerId: 'layer-prop',
resourceId: 'resource-prop',
title: '普通道具',
src: 'data:image/png;base64,prop',
x: 520,
y: 140,
width: 280,
height: 220,
originalWidth: 700,
originalHeight: 550,
zIndex: 1,
sourceType: 'uploaded',
},
],
resources: [],
updatedAt: '2026-06-15T00:00:00.000Z',
});
generateEditorCharacterAnimationMock.mockResolvedValueOnce({
taskId: 'character-animation-task-1',
model: 'seedance2.0',
prompt: '生成游戏角色动画\n动作描述:\n待机',
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
frames: Array.from({ length: 48 }, (_, index) => ({
frameIndex: index + 1,
imageSrc: `/generated-character-drafts/editor/frame${index + 1}.png`,
width: 1024,
height: 1024,
})),
frameCount: 48,
durationSeconds: 6,
fps: 8,
priceMudPoints: 120,
});
render();
const propLayer = await screen.findByAltText('画布图片:普通道具');
fireEvent.click(propLayer.closest('button')!);
expect(screen.queryByRole('button', { name: '生成动画' })).toBeNull();
fireEvent.contextMenu(propLayer.closest('button')!, {
clientX: 220,
clientY: 180,
});
expect(screen.queryByRole('menuitem', { name: '生成动画' })).toBeNull();
const characterLayer = screen.getByAltText('画布图片:市场老妇人');
fireEvent.click(characterLayer.closest('button')!);
expect(screen.getByText('角色')).toBeTruthy();
expect(screen.getByRole('button', { name: '生成动画' })).toBeTruthy();
fireEvent.contextMenu(characterLayer.closest('button')!, {
clientX: 260,
clientY: 220,
});
expect(screen.getByRole('menuitem', { name: '生成动画' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '生成动画' }));
const panel = screen.getByRole('dialog', { name: '角色动画生成面板' });
expect(within(panel).getByText('40泥点')).toBeTruthy();
expect(
(within(panel).getByLabelText('分辨率') as HTMLSelectElement).value,
).toBe('480p');
expect(
(within(panel).getByLabelText('画面比例') as HTMLSelectElement).value,
).toBe('same');
expect(
(within(panel).getByLabelText('时长') as HTMLSelectElement).value,
).toBe('32');
for (const actionLabel of [
'待机',
'行走',
'奔跑',
'跳跃',
'攻击',
'受击',
'倒下',
]) {
expect(
within(panel).getByRole('button', { name: actionLabel }),
).toBeTruthy();
}
fireEvent.click(within(panel).getByRole('button', { name: '待机' }));
expect(
(within(panel).getByLabelText('动画描述') as HTMLTextAreaElement).value,
).toContain('待机');
const longPrompt = '走'.repeat(4100);
fireEvent.change(within(panel).getByLabelText('动画描述'), {
target: { value: longPrompt },
});
expect(
(within(panel).getByLabelText('动画描述') as HTMLTextAreaElement).value,
).toHaveLength(4000);
const precisePrompt =
'The elderly market woman gently shifts weight while the basket sways.';
fireEvent.change(within(panel).getByLabelText('动画描述'), {
target: { value: precisePrompt },
});
expect(
within(panel).getByLabelText(`生成文本:${precisePrompt}`),
).toBeTruthy();
fireEvent.change(within(panel).getByLabelText('分辨率'), {
target: { value: '720p' },
});
fireEvent.change(within(panel).getByLabelText('画面比例'), {
target: { value: '16:9' },
});
fireEvent.change(within(panel).getByLabelText('时长'), {
target: { value: '48' },
});
expect(within(panel).getByText('120泥点')).toBeTruthy();
fireEvent.click(within(panel).getByRole('button', { name: '生成' }));
expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith(
expect.objectContaining({
sourceLayerId: 'layer-character',
sourceImageSrc:
'generated-character-drafts/editor/character-images/source/image.png',
sourceWidth: 1024,
sourceHeight: 1024,
resolution: '720p',
ratio: '16:9',
frameCount: 48,
durationSeconds: 6,
priceMudPoints: 120,
model: 'seedance2.0',
}),
);
expect(
generateEditorCharacterAnimationMock.mock.calls[0]?.[0]?.promptText,
).toBe(precisePrompt);
await waitFor(() => {
expect(within(panel).getByText('已生成 48 帧')).toBeTruthy();
});
});
it('opens quick edit from the floating toolbar with original image as first reference and generates beside the source', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-quick-edit',
title: '快速编辑画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-quick-source',
resourceId: 'resource-quick-source',
title: '魔法森林',
src: 'data:image/png;base64,c291cmNl',
x: 120,
y: 140,
width: 320,
height: 240,
originalWidth: 1536,
originalHeight: 1024,
zIndex: 2,
sourceType: 'generated',
prompt: '魔法森林原始提示词',
actualPrompt: '魔法森林原始提示词',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'source-task-1',
assetKind: 'spec',
},
],
resources: [],
updatedAt: '2026-06-15T00:00:00.000Z',
});
generateEditorImageMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,cXVpY2stZWRpdA==',
width: 1536,
height: 1024,
sourceType: 'generated',
prompt: '增加萤火虫',
actualPrompt: '增加萤火虫',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'quick-edit-task-1',
});
render();
const sourceImage = await screen.findByAltText('画布图片:魔法森林');
fireEvent.pointerDown(sourceImage.closest('button')!, {
button: 0,
pointerId: 151,
clientX: 180,
clientY: 180,
});
fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
pointerId: 151,
clientX: 180,
clientY: 180,
});
fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));
const quickPanel = screen.getByRole('dialog', { name: '快速编辑图片' });
expect(quickPanel.className).toContain(
'image-canvas-editor__quick-edit-panel',
);
expect(within(quickPanel).getByText('魔法森林')).toBeTruthy();
expect(
(within(quickPanel).getByLabelText('快速编辑尺寸') as HTMLSelectElement)
.value,
).toBe('1536x1024');
expect(
(within(quickPanel).getByLabelText('快速编辑模型') as HTMLSelectElement)
.value,
).toBe('gpt-image-2');
const references = within(quickPanel).getAllByRole('img');
expect(references[0]?.getAttribute('src')).toBe(
'data:image/png;base64,c291cmNl',
);
fireEvent.change(within(quickPanel).getByLabelText('快速编辑提示词'), {
target: { value: '增加萤火虫' },
});
fireEvent.click(within(quickPanel).getByRole('button', { name: '生成' }));
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith({
prompt: '增加萤火虫',
size: '1536x1024',
kind: 'quick-edit',
model: 'gpt-image-2',
referenceImageSrcs: ['data:image/png;base64,c291cmNl'],
});
});
await waitFor(() => {
expect(screen.getByAltText('画布图片:魔法森林 快速编辑')).toBeTruthy();
});
const generatedLayer = screen
.getByAltText('画布图片:魔法森林 快速编辑')
.closest('button') as HTMLElement;
expect(Number.parseFloat(generatedLayer.style.left)).toBe(1688);
expect(Number.parseFloat(generatedLayer.style.top)).toBe(140);
expect(Number.parseFloat(generatedLayer.style.width)).toBe(1536);
expect(Number.parseFloat(generatedLayer.style.height)).toBe(1024);
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
'editor-project-quick-edit',
expect.objectContaining({
layers: expect.arrayContaining([
expect.objectContaining({
title: '魔法森林 快速编辑',
assetKind: 'spec',
width: 1536,
height: 1024,
originalWidth: 1536,
originalHeight: 1024,
x: 1688,
y: 140,
}),
]),
}),
);
});
});
it('opens quick edit from the image context menu', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-context-quick-edit',
title: '右键快速编辑画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-context-source',
resourceId: 'resource-context-source',
title: '右键图片',
src: 'data:image/png;base64,Y29udGV4dA==',
x: 80,
y: 90,
width: 260,
height: 260,
originalWidth: 1024,
originalHeight: 1024,
zIndex: 1,
sourceType: 'uploaded',
model: 'gpt-image-2',
},
],
resources: [],
updatedAt: '2026-06-15T00:00:00.000Z',
});
generateEditorImageMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,Y29udGV4dC1xdWljaw==',
width: 1024,
height: 1024,
sourceType: 'generated',
prompt: '换成夜晚',
actualPrompt: '换成夜晚',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'context-quick-task-1',
});
render();
const contextImage = await screen.findByAltText('画布图片:右键图片');
fireEvent.contextMenu(contextImage.closest('button')!, {
clientX: 260,
clientY: 220,
});
const menu = screen.getByRole('menu', { name: '图片功能面板' });
expect(
within(menu).getByRole('menuitem', { name: '快速编辑' }),
).toBeTruthy();
fireEvent.click(within(menu).getByRole('menuitem', { name: '快速编辑' }));
const panel = screen.getByRole('dialog', { name: '快速编辑图片' });
expect(within(panel).getByText('右键图片')).toBeTruthy();
fireEvent.change(within(panel).getByLabelText('快速编辑提示词'), {
target: { value: '换成夜晚' },
});
fireEvent.click(within(panel).getByRole('button', { name: '生成' }));
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '换成夜晚',
referenceImageSrcs: ['data:image/png;base64,Y29udGV4dA=='],
size: '1024x1024',
model: 'gpt-image-2',
kind: 'quick-edit',
}),
);
});
await waitFor(() => {
expect(screen.getByAltText('画布图片:右键图片 快速编辑')).toBeTruthy();
});
});
it('converts non-data-url quick edit source images before submitting references', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-public-quick-edit',
title: '公开素材快速编辑画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-public-source',
resourceId: 'resource-public-source',
title: '公开拼图素材',
src: '/creation-type-references/puzzle.webp',
x: 120,
y: 140,
width: 320,
height: 240,
originalWidth: 640,
originalHeight: 640,
zIndex: 2,
sourceType: 'uploaded',
},
],
resources: [],
updatedAt: '2026-06-16T00:00:00.000Z',
});
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
new Response(new Uint8Array([104, 101, 108, 108, 111]), {
status: 200,
headers: {
'Content-Type': 'image/webp',
},
}),
);
generateEditorImageMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,cHVibGljLXF1aWNr',
width: 640,
height: 640,
sourceType: 'generated',
prompt: '改成陶泥风格',
actualPrompt: '改成陶泥风格',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'public-quick-edit-task-1',
});
render();
const sourceImage = await screen.findByAltText('画布图片:公开拼图素材');
fireEvent.pointerDown(sourceImage.closest('button')!, {
button: 0,
pointerId: 161,
clientX: 180,
clientY: 180,
});
fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
pointerId: 161,
clientX: 180,
clientY: 180,
});
fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));
const quickPanel = screen.getByRole('dialog', { name: '快速编辑图片' });
fireEvent.change(within(quickPanel).getByLabelText('快速编辑提示词'), {
target: { value: '改成陶泥风格' },
});
fireEvent.click(within(quickPanel).getByRole('button', { name: '生成' }));
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '改成陶泥风格',
kind: 'quick-edit',
referenceImageSrcs: ['data:image/webp;base64,aGVsbG8='],
}),
);
});
expect(globalThis.fetch).toHaveBeenCalledWith(
'/creation-type-references/puzzle.webp',
expect.objectContaining({
signal: undefined,
}),
);
});
it('switches tools and restores the previous tool after holding Space', async () => {
const user = userEvent.setup();
render();
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
const selectTool = within(bottomToolbar).getByRole('button', {
name: '选择工具',
});
const textTool = within(bottomToolbar).getByRole('button', {
name: '文字工具',
});
const handTool = within(bottomToolbar).getByRole('button', {
name: '抓手工具',
});
expect(selectTool.getAttribute('aria-pressed')).toBe('true');
await user.click(textTool);
expect(textTool.getAttribute('aria-pressed')).toBe('true');
fireEvent.keyDown(window, { code: 'Space', key: ' ' });
expect(handTool.getAttribute('aria-pressed')).toBe('true');
fireEvent.keyUp(window, { code: 'Space', key: ' ' });
expect(textTool.getAttribute('aria-pressed')).toBe('true');
});
it('switches away from hand tool from the bottom toolbar', async () => {
const user = userEvent.setup();
render();
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
const handTool = within(bottomToolbar).getByRole('button', {
name: '抓手工具',
});
const textTool = within(bottomToolbar).getByRole('button', {
name: '文字工具',
});
await user.click(handTool);
expect(handTool.getAttribute('aria-pressed')).toBe('true');
await user.click(textTool);
expect(textTool.getAttribute('aria-pressed')).toBe('true');
expect(handTool.getAttribute('aria-pressed')).toBe('false');
});
it('pans with the middle mouse button without leaving select mode', async () => {
render();
const viewport = screen.getByLabelText('画布工作区');
const middlePointerDown = new MouseEvent('pointerdown', {
bubbles: true,
cancelable: true,
button: 1,
buttons: 4,
clientX: 260,
clientY: 220,
});
Object.defineProperty(middlePointerDown, 'pointerId', { value: 11 });
fireEvent(viewport, middlePointerDown);
await waitFor(() => {
expect(viewport.className).toContain(
'image-canvas-editor__viewport--panning',
);
});
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
expect(
within(bottomToolbar)
.getByRole('button', { name: '选择工具' })
.getAttribute('aria-pressed'),
).toBe('true');
});
it('shows snap guides when dragging a layer near another layer alignment', async () => {
render();
const puzzleLayer = screen
.getByAltText('画布图片:拼图素材')
.closest('button')!;
dispatchPointerEvent(puzzleLayer, 'pointerdown', {
button: 0,
pointerId: 21,
clientX: 120,
clientY: 120,
});
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', {
pointerId: 21,
clientX: 499,
clientY: 169,
});
expect(
screen.getByTestId('image-canvas-editor-snap-guide-vertical'),
).toBeTruthy();
expect(
screen.getByTestId('image-canvas-editor-snap-guide-horizontal'),
).toBeTruthy();
});
it('can switch tools after a layer drag started without pointer release', async () => {
const user = userEvent.setup();
render();
fireEvent.pointerDown(
screen.getByAltText('画布图片:拼图素材').closest('button')!,
{
button: 0,
pointerId: 41,
clientX: 120,
clientY: 120,
},
);
fireEvent.pointerMove(screen.getByLabelText('画布工作区'), {
pointerId: 41,
clientX: 220,
clientY: 160,
});
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
const textTool = within(bottomToolbar).getByRole('button', {
name: '文字工具',
});
await user.click(textTool);
expect(textTool.getAttribute('aria-pressed')).toBe('true');
expect(
screen.queryByTestId('image-canvas-editor-snap-guide-vertical'),
).toBeNull();
});
it('opens generated image info from the corner button and creates a real right-side edit result', async () => {
generateEditorImageMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==',
width: 1024,
height: 1024,
sourceType: 'generated',
prompt: '一张可修改的生成图',
actualPrompt: '一张可修改的生成图',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'editor-real-task-2',
});
editEditorImageMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,ZWRpdGVkLWltYWdl',
width: 1024,
height: 1024,
sourceType: 'generated',
prompt: '把画面改成黄昏光线',
actualPrompt: '把画面改成黄昏光线',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'editor-real-edit-1',
});
render();
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '一张可修改的生成图' },
});
fireEvent.click(screen.getByRole('button', { name: '生成' }));
await waitFor(() => {
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
});
const generatedLayer = screen
.getByAltText(/画布图片:生成图片/)
.closest('button') as HTMLElement;
expect(Number.parseFloat(generatedLayer.style.width)).toBe(1024);
expect(Number.parseFloat(generatedLayer.style.height)).toBe(1024);
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
const metadataCornerButton = screen.getAllByRole('button', {
name: /查看生成图片 .*图片信息/,
})[0];
if (!metadataCornerButton) {
throw new Error('metadata corner button should exist');
}
expect(metadataCornerButton.className).toContain('bg-black/55');
expect(metadataCornerButton.className).toContain(
'image-canvas-editor__metadata-corner',
);
fireEvent.click(metadataCornerButton);
const metadataDialog = screen.getByRole('dialog', {
name: /生成图片 .*图片信息/,
});
expect(metadataDialog).toBeTruthy();
expect(within(metadataDialog).getByText('图片类型')).toBeTruthy();
expect(within(metadataDialog).getByText('生成图片')).toBeTruthy();
expect(within(metadataDialog).queryByText('Prompt')).toBeNull();
expect(
within(metadataDialog).queryByRole('button', { name: '复制Prompt' }),
).toBeNull();
expect(within(metadataDialog).getByText('生成输入')).toBeTruthy();
expect(within(metadataDialog).getByText('生成提示词')).toBeTruthy();
expect(within(metadataDialog).getByText('一张可修改的生成图')).toBeTruthy();
expect(within(metadataDialog).getByText('Model')).toBeTruthy();
expect(within(metadataDialog).getByText('gpt-image-2')).toBeTruthy();
expect(within(metadataDialog).queryByText('Size')).toBeNull();
expect(within(metadataDialog).getByText('Resolution')).toBeTruthy();
expect(within(metadataDialog).getByText('1024 x 1024 px')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '修改图片' }));
const editDialog = screen.getByRole('dialog', { name: '修改图片' });
expect(editDialog).toBeTruthy();
const editPrompt = screen.getByLabelText('生成提示词');
expect(editPrompt.className).toContain('platform-text-field');
expect(editPrompt.className).toContain(
'image-canvas-editor__generate-prompt',
);
fireEvent.change(editPrompt, {
target: { value: '把画面改成黄昏光线' },
});
fireEvent.click(screen.getByRole('button', { name: '修改' }));
expect(screen.getByRole('status').textContent).toContain('修改中');
await waitFor(() => {
expect(editEditorImageMock).toHaveBeenCalledWith({
prompt: '把画面改成黄昏光线',
sourceImageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==',
});
});
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '修改图片' })).toBeNull();
});
expect(screen.getByAltText(/画布图片:生成图片 .* 修改结果/)).toBeTruthy();
fireEvent.click(
screen.getAllByRole('button', {
name: /查看生成图片 .* 修改结果图片信息/u,
})[0]!,
);
const editedMetadataDialog = screen.getByRole('dialog', {
name: /生成图片 .* 修改结果图片信息/u,
});
expect(within(editedMetadataDialog).queryByText('Prompt')).toBeNull();
expect(within(editedMetadataDialog).getByText('修改要求')).toBeTruthy();
expect(within(editedMetadataDialog).getByText('把画面改成黄昏光线')).toBeTruthy();
expect(within(editedMetadataDialog).getByText('参考图')).toBeTruthy();
expect(
within(editedMetadataDialog).getByText(/^生成图片 \d+$/u),
).toBeTruthy();
expect(screen.getByRole('button', { name: /当前缩放比例 \d+%/u })).toBeTruthy();
});
it('hides the edit image panel after generation starts while keeping the source preview visible', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-edit-generating',
title: '修改图片生成中画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-edit-generating-source',
resourceId: 'resource-edit-generating-source',
title: '待修改图片',
src: 'data:image/png;base64,ZWRpdC1nZW5lcmF0aW5n',
x: 120,
y: 140,
width: 320,
height: 240,
originalWidth: 1024,
originalHeight: 768,
zIndex: 2,
sourceType: 'generated',
prompt: '原始提示词',
actualPrompt: '原始提示词',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'edit-generating-source-task',
},
],
resources: [],
updatedAt: '2026-06-16T00:00:00.000Z',
});
editEditorImageMock.mockReturnValueOnce(new Promise(() => undefined));
render();
const sourceImage = await screen.findByAltText('画布图片:待修改图片');
const sourceLayer = sourceImage.closest('button')!;
fireEvent.pointerDown(sourceLayer, {
button: 0,
pointerId: 171,
clientX: 180,
clientY: 180,
});
fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
pointerId: 171,
clientX: 180,
clientY: 180,
});
fireEvent.click(screen.getByRole('button', { name: '修改图片' }));
const editDialog = screen.getByRole('dialog', { name: '修改图片' });
fireEvent.change(within(editDialog).getByLabelText('生成提示词'), {
target: { value: '改成雨夜灯光' },
});
fireEvent.click(within(editDialog).getByRole('button', { name: '修改' }));
expect(screen.queryByRole('dialog', { name: '修改图片' })).toBeNull();
expect(screen.getByAltText('画布图片:待修改图片')).toBeTruthy();
expect(sourceLayer.className).toContain(
'image-canvas-editor__layer--generating',
);
expect(within(sourceLayer).getByRole('status').textContent).toContain(
'修改中',
);
});
it('hides the quick edit panel after generation starts while keeping the source preview visible', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-quick-edit-generating',
title: '快速编辑生成中画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-quick-edit-generating-source',
resourceId: 'resource-quick-edit-generating-source',
title: '快速编辑源图',
src: 'data:image/png;base64,cXVpY2stZWRpdC1nZW5lcmF0aW5n',
x: 120,
y: 140,
width: 320,
height: 240,
originalWidth: 1024,
originalHeight: 768,
zIndex: 2,
sourceType: 'uploaded',
model: 'gpt-image-2',
},
],
resources: [],
updatedAt: '2026-06-16T00:00:00.000Z',
});
generateEditorImageMock.mockReturnValueOnce(new Promise(() => undefined));
render();
const sourceImage = await screen.findByAltText('画布图片:快速编辑源图');
const sourceLayer = sourceImage.closest('button')!;
fireEvent.pointerDown(sourceLayer, {
button: 0,
pointerId: 172,
clientX: 180,
clientY: 180,
});
fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
pointerId: 172,
clientX: 180,
clientY: 180,
});
fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));
const quickPanel = screen.getByRole('dialog', { name: '快速编辑图片' });
fireEvent.change(within(quickPanel).getByLabelText('快速编辑提示词'), {
target: { value: '加一层暖光' },
});
fireEvent.click(within(quickPanel).getByRole('button', { name: '生成' }));
expect(screen.queryByRole('dialog', { name: '快速编辑图片' })).toBeNull();
expect(screen.getByAltText('画布图片:快速编辑源图')).toBeTruthy();
expect(sourceLayer.className).toContain(
'image-canvas-editor__layer--generating',
);
expect(within(sourceLayer).getByRole('status').textContent).toContain(
'生成中',
);
});
});