3a3cc89280
Remove backend-assembled raw Prompt and copy action from image info; render a lightweight generationInputs snapshot (user panel inputs + reference thumbnails) stored on canvas layers and shown in the image info dialog. Unify canvas display and info to use originalWidth/originalHeight (Resolution) instead of saved Size and hydrate legacy layout width/height only as fallback. Add model/aspectRatio/imageSize options for character/icon generation (frontend state, tests, and client payloads). Increase Axum JSON body limit for character animation endpoint to 12MB for compatibility and prefer submitting persisted objectKey over large Data URLs. Update tests, docs, and related server/frontend code to reflect these behaviors and validations.
3386 lines
118 KiB
TypeScript
3386 lines
118 KiB
TypeScript
/* @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(<ImageCanvasEditorView />);
|
||
|
||
await waitFor(() => {
|
||
expect(loadEditorProjectMock).toHaveBeenCalledWith(
|
||
'editor-project-query',
|
||
);
|
||
});
|
||
expect(loadOrCreateRecentEditorProjectMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('toggles the shared sidebar from canvas panel buttons', () => {
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
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(<ImageCanvasEditorView />);
|
||
|
||
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(<ImageCanvasEditorView />);
|
||
|
||
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(<ImageCanvasEditorView />);
|
||
|
||
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(<ImageCanvasEditorView />);
|
||
|
||
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(<ImageCanvasEditorView />);
|
||
|
||
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(<ImageCanvasEditorView />);
|
||
|
||
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(<ImageCanvasEditorView />);
|
||
|
||
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(<ImageCanvasEditorView />);
|
||
|
||
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(<ImageCanvasEditorView />);
|
||
|
||
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(<ImageCanvasEditorView />);
|
||
|
||
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(<ImageCanvasEditorView />);
|
||
|
||
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(<ImageCanvasEditorView />);
|
||
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(<ImageCanvasEditorView />);
|
||
|
||
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(<ImageCanvasEditorView />);
|
||
|
||
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(<ImageCanvasEditorView />);
|
||
|
||
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(<ImageCanvasEditorView />);
|
||
|
||
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(<ImageCanvasEditorView />);
|
||
|
||
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(<ImageCanvasEditorView />);
|
||
|
||
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(<ImageCanvasEditorView />);
|
||
|
||
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(<ImageCanvasEditorView />);
|
||
|
||
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(<ImageCanvasEditorView />);
|
||
|
||
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(<ImageCanvasEditorView />);
|
||
|
||
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(<ImageCanvasEditorView />);
|
||
|
||
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
|
||
fireEvent.click(
|
||
within(bottomToolbar).getByRole('button', { name: '生成工具' }),
|
||
);
|
||
|
||
const generateDialog = screen.getByRole('dialog', { name: '生成图片' });
|
||
const initialComposerTop = Number.parseFloat(
|
||
(generateDialog as HTMLElement).style.top,
|
||
);
|
||
expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
|
||
expect(within(generateDialog).getByText('参考图')).toBeTruthy();
|
||
expect(
|
||
within(generateDialog).getByRole('button', { name: '添加参考图' })
|
||
.className,
|
||
).toContain('bg-white/94');
|
||
expect(
|
||
within(generateDialog).getByRole('button', { name: '添加参考图' })
|
||
.className,
|
||
).toContain('image-canvas-editor__generation-ref');
|
||
const generatePrompt = screen.getByLabelText('生成提示词');
|
||
expect(generatePrompt.className).toContain('platform-text-field');
|
||
expect(generatePrompt.className).toContain(
|
||
'image-canvas-editor__generation-prompt',
|
||
);
|
||
expect(
|
||
within(generateDialog).getByRole('button', {
|
||
name: '生成比例 1:1 2k 1张',
|
||
}).className,
|
||
).toContain('platform-inline-option-button');
|
||
expect(
|
||
within(generateDialog).getByRole('button', {
|
||
name: '生成模型 GPT Image',
|
||
}).className,
|
||
).toContain('platform-inline-option-button');
|
||
expect(
|
||
within(generateDialog).getByRole('button', { name: '生成' }).className,
|
||
).toContain('platform-button');
|
||
expect(
|
||
within(generateDialog).getByRole('button', { name: '生成' }).className,
|
||
).toContain('image-canvas-editor__generation-submit');
|
||
expect(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).toBeTruthy();
|
||
|
||
fireEvent.change(screen.getByLabelText('生成提示词'), {
|
||
target: { value: '一张明亮的拼图主视觉' },
|
||
});
|
||
fireEvent.click(
|
||
within(generateDialog).getByRole('button', { name: '生成' }),
|
||
);
|
||
|
||
expect(screen.getByRole('status').textContent).toContain('生成中');
|
||
expect(generateEditorImageMock).toHaveBeenCalledWith({
|
||
prompt: '一张明亮的拼图主视觉',
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
|
||
});
|
||
const generatedLayer = screen
|
||
.getByAltText(/画布图片:生成图片/)
|
||
.closest('button')!;
|
||
const anchoredGenerateDialog = screen.getByRole('dialog', {
|
||
name: '生成图片',
|
||
});
|
||
expect(anchoredGenerateDialog).toBeTruthy();
|
||
expect(
|
||
Number.parseFloat((anchoredGenerateDialog as HTMLElement).style.top),
|
||
).toBeGreaterThan(
|
||
Number.parseFloat((generatedLayer as HTMLElement).style.top),
|
||
);
|
||
expect(
|
||
Number.parseFloat((generatedLayer as HTMLElement).style.top),
|
||
).toBeLessThan(initialComposerTop);
|
||
expect(screen.queryByLabelText('图像生成占位图')).toBeNull();
|
||
const metadataButtons = screen.getAllByRole('button', {
|
||
name: /查看生成图片 .*图片信息/,
|
||
});
|
||
expect(metadataButtons[0]).toBeTruthy();
|
||
fireEvent.click(metadataButtons[0]!);
|
||
|
||
const infoPanel = screen.getByRole('dialog', {
|
||
name: /生成图片 .*图片信息/,
|
||
});
|
||
expect(within(infoPanel).queryByText('Prompt')).toBeNull();
|
||
expect(
|
||
within(infoPanel).queryByRole('button', { name: '复制Prompt' }),
|
||
).toBeNull();
|
||
expect(within(infoPanel).getByText('生成输入')).toBeTruthy();
|
||
expect(within(infoPanel).getByText('生成提示词')).toBeTruthy();
|
||
expect(within(infoPanel).getByText('一张明亮的拼图主视觉')).toBeTruthy();
|
||
});
|
||
|
||
it('drags the generation placeholder and places the generated layer there', async () => {
|
||
generateEditorImageMock.mockResolvedValueOnce({
|
||
imageSrc: 'data:image/png;base64,ZHJhZ2dlZC1mcmFtZQ==',
|
||
width: 1024,
|
||
height: 1024,
|
||
sourceType: 'generated',
|
||
prompt: '拖拽后的生成图',
|
||
actualPrompt: '拖拽后的生成图',
|
||
model: 'gpt-image-2',
|
||
provider: 'VectorEngine',
|
||
taskId: 'editor-drag-frame-1',
|
||
});
|
||
render(<ImageCanvasEditorView />);
|
||
await waitFor(() => {
|
||
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
|
||
const initialComposerTop = Number.parseFloat(
|
||
(screen.getByRole('dialog', { name: '生成图片' }) as HTMLElement).style
|
||
.top,
|
||
);
|
||
const frame = screen.getByLabelText('图像生成占位图');
|
||
dispatchPointerEvent(frame, 'pointerdown', {
|
||
button: 0,
|
||
pointerId: 61,
|
||
clientX: 500,
|
||
clientY: 260,
|
||
});
|
||
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', {
|
||
pointerId: 61,
|
||
clientX: 582,
|
||
clientY: 342,
|
||
});
|
||
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointerup', {
|
||
pointerId: 61,
|
||
clientX: 582,
|
||
clientY: 342,
|
||
});
|
||
const draggedComposerTop = Number.parseFloat(
|
||
(screen.getByRole('dialog', { name: '生成图片' }) as HTMLElement).style
|
||
.top,
|
||
);
|
||
expect(draggedComposerTop).toBeGreaterThan(initialComposerTop);
|
||
const draggedFrame = screen.getByLabelText('图像生成占位图') as HTMLElement;
|
||
const draggedFrameCenterX =
|
||
Number.parseFloat(draggedFrame.style.left) +
|
||
Number.parseFloat(draggedFrame.style.width) / 2;
|
||
const draggedFrameCenterY =
|
||
Number.parseFloat(draggedFrame.style.top) +
|
||
Number.parseFloat(draggedFrame.style.height) / 2;
|
||
fireEvent.change(screen.getByLabelText('生成提示词'), {
|
||
target: { value: '拖拽后的生成图' },
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
|
||
});
|
||
|
||
const generatedLayer = screen
|
||
.getByAltText(/画布图片:生成图片/)
|
||
.closest('button')!;
|
||
const anchoredGenerateDialog = screen.getByRole('dialog', {
|
||
name: '生成图片',
|
||
});
|
||
expect(anchoredGenerateDialog).toBeTruthy();
|
||
expect(
|
||
Number.parseFloat((anchoredGenerateDialog as HTMLElement).style.top),
|
||
).toBeGreaterThan(
|
||
Number.parseFloat((generatedLayer as HTMLElement).style.top),
|
||
);
|
||
expect(screen.queryByLabelText('图像生成占位图')).toBeNull();
|
||
expect(
|
||
Number.parseFloat((generatedLayer as HTMLElement).style.left) +
|
||
Number.parseFloat((generatedLayer as HTMLElement).style.width) / 2,
|
||
).toBeCloseTo(draggedFrameCenterX, 1);
|
||
expect(
|
||
Number.parseFloat((generatedLayer as HTMLElement).style.top) +
|
||
Number.parseFloat((generatedLayer as HTMLElement).style.height) / 2,
|
||
).toBeCloseTo(draggedFrameCenterY, 1);
|
||
});
|
||
|
||
it('keeps the generation placeholder draggable while the image is generating', async () => {
|
||
let resolveGeneration!: (value: unknown) => void;
|
||
generateEditorImageMock.mockReturnValueOnce(
|
||
new Promise((resolve) => {
|
||
resolveGeneration = resolve;
|
||
}),
|
||
);
|
||
render(<ImageCanvasEditorView />);
|
||
await waitFor(() => {
|
||
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
|
||
fireEvent.change(screen.getByLabelText('生成提示词'), {
|
||
target: { value: '生成中继续拖动的图片' },
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||
|
||
const frame = screen.getByLabelText('图像生成占位图');
|
||
expect(frame.className).toContain(
|
||
'image-canvas-editor__generation-frame--generating',
|
||
);
|
||
const initialLeft = Number.parseFloat((frame as HTMLElement).style.left);
|
||
const initialTop = Number.parseFloat((frame as HTMLElement).style.top);
|
||
|
||
dispatchPointerEvent(frame, 'pointerdown', {
|
||
button: 0,
|
||
pointerId: 67,
|
||
clientX: 500,
|
||
clientY: 260,
|
||
});
|
||
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', {
|
||
pointerId: 67,
|
||
clientX: 620,
|
||
clientY: 360,
|
||
});
|
||
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointerup', {
|
||
pointerId: 67,
|
||
clientX: 620,
|
||
clientY: 360,
|
||
});
|
||
|
||
const draggedFrame = screen.getByLabelText('图像生成占位图');
|
||
expect(
|
||
Number.parseFloat((draggedFrame as HTMLElement).style.left),
|
||
).toBeGreaterThan(initialLeft);
|
||
expect(
|
||
Number.parseFloat((draggedFrame as HTMLElement).style.top),
|
||
).toBeGreaterThan(initialTop);
|
||
|
||
await act(async () => {
|
||
resolveGeneration({
|
||
imageSrc: 'data:image/png;base64,Z2VuZXJhdGluZy1kcmFn',
|
||
width: 1024,
|
||
height: 1024,
|
||
sourceType: 'generated',
|
||
prompt: '生成中继续拖动的图片',
|
||
actualPrompt: '生成中继续拖动的图片',
|
||
model: 'gpt-image-2',
|
||
provider: 'VectorEngine',
|
||
taskId: 'editor-generating-drag-1',
|
||
});
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
|
||
});
|
||
const generatedLayer = screen
|
||
.getByAltText(/画布图片:生成图片/)
|
||
.closest('button')!;
|
||
expect(
|
||
Number.parseFloat((generatedLayer as HTMLElement).style.left) +
|
||
Number.parseFloat((generatedLayer as HTMLElement).style.width) / 2,
|
||
).toBeCloseTo(
|
||
Number.parseFloat((draggedFrame as HTMLElement).style.left) +
|
||
Number.parseFloat((draggedFrame as HTMLElement).style.width) / 2,
|
||
1,
|
||
);
|
||
expect(
|
||
Number.parseFloat((generatedLayer as HTMLElement).style.top) +
|
||
Number.parseFloat((generatedLayer as HTMLElement).style.height) / 2,
|
||
).toBeCloseTo(
|
||
Number.parseFloat((draggedFrame as HTMLElement).style.top) +
|
||
Number.parseFloat((draggedFrame as HTMLElement).style.height) / 2,
|
||
1,
|
||
);
|
||
});
|
||
|
||
it('hides the generation composer when selecting another image but keeps the placeholder', () => {
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
|
||
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
|
||
|
||
fireEvent.pointerDown(
|
||
screen.getByAltText('画布图片:拼图素材').closest('button')!,
|
||
{
|
||
button: 0,
|
||
pointerId: 62,
|
||
clientX: 120,
|
||
clientY: 120,
|
||
},
|
||
);
|
||
|
||
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
|
||
expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
|
||
|
||
fireEvent.pointerDown(screen.getByLabelText('图像生成占位图'), {
|
||
button: 0,
|
||
pointerId: 64,
|
||
clientX: 300,
|
||
clientY: 180,
|
||
});
|
||
|
||
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
|
||
});
|
||
|
||
it('hides the generation composer when clicking the canvas outside generation controls', () => {
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
|
||
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
|
||
|
||
fireEvent.pointerDown(screen.getByLabelText('画布工作区'), {
|
||
button: 0,
|
||
pointerId: 63,
|
||
clientX: 260,
|
||
clientY: 180,
|
||
});
|
||
|
||
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
|
||
expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
|
||
});
|
||
|
||
it('closes the generation composer without removing the placeholder frame', () => {
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
|
||
fireEvent.click(screen.getByRole('button', { name: '关闭生成图片' }));
|
||
|
||
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
|
||
expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
|
||
});
|
||
|
||
it('shows generation errors instead of falling back to mock images', async () => {
|
||
generateEditorImageMock.mockRejectedValueOnce(
|
||
new Error('VectorEngine 未配置'),
|
||
);
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
|
||
fireEvent.change(screen.getByLabelText('生成提示词'), {
|
||
target: { value: '一张真实生成失败的图' },
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||
|
||
expect(screen.getByRole('status').textContent).toContain('生成中');
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByRole('alert').textContent).toContain(
|
||
'VectorEngine 未配置',
|
||
);
|
||
});
|
||
expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
|
||
expect(screen.queryByAltText(/画布图片:生成图片/)).toBeNull();
|
||
});
|
||
|
||
it('asks the user to log in when real generation is unauthorized', async () => {
|
||
generateEditorImageMock.mockRejectedValueOnce(
|
||
new ApiClientError({
|
||
message: '未授权访问(requestId: web-login-required)',
|
||
status: 401,
|
||
code: 'UNAUTHORIZED',
|
||
}),
|
||
);
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
|
||
fireEvent.change(screen.getByLabelText('生成提示词'), {
|
||
target: { value: '一张需要登录生成的图' },
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByRole('alert').textContent).toBe(
|
||
'请先登录后再生成图片',
|
||
);
|
||
});
|
||
expect(screen.queryByText(/requestId/u)).toBeNull();
|
||
});
|
||
|
||
it('hides image generation setting panels after generation starts while keeping the preview frame visible', async () => {
|
||
const cases = [
|
||
{
|
||
open: () => {
|
||
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
|
||
fireEvent.change(screen.getByLabelText('生成提示词'), {
|
||
target: { value: '生成中的普通图片' },
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||
},
|
||
dialogName: '生成图片',
|
||
frameLabel: '图像生成占位图',
|
||
},
|
||
{
|
||
open: () => {
|
||
fireEvent.click(
|
||
within(
|
||
screen.getByRole('toolbar', { name: 'AI画布工具栏' }),
|
||
).getByRole('button', { name: '生成规范' }),
|
||
);
|
||
fireEvent.click(
|
||
within(
|
||
screen.getByRole('menu', { name: '生成规范类型' }),
|
||
).getByRole('menuitem', { name: '自定义规范' }),
|
||
);
|
||
fireEvent.change(screen.getByLabelText('自定义规范提示词'), {
|
||
target: { value: '生成中的自定义规范图' },
|
||
});
|
||
fireEvent.click(
|
||
within(screen.getByRole('dialog', { name: '生成规范' })).getByRole(
|
||
'button',
|
||
{ name: '提交生成规范' },
|
||
),
|
||
);
|
||
},
|
||
dialogName: '生成规范',
|
||
frameLabel: '规范生成占位图',
|
||
},
|
||
{
|
||
open: () => {
|
||
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
|
||
fireEvent.change(screen.getByLabelText('角色设定'), {
|
||
target: { value: '生成中的角色形象' },
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||
},
|
||
dialogName: '生成角色形象',
|
||
frameLabel: '角色生成占位图',
|
||
},
|
||
] as const;
|
||
|
||
for (const testCase of cases) {
|
||
generateEditorImageMock.mockReturnValueOnce(new Promise(() => undefined));
|
||
const { unmount } = render(<ImageCanvasEditorView />);
|
||
await waitFor(() => {
|
||
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
|
||
});
|
||
|
||
testCase.open();
|
||
|
||
expect(
|
||
screen.queryByRole('dialog', { name: testCase.dialogName }),
|
||
).toBeNull();
|
||
const frame = screen.getByLabelText(testCase.frameLabel);
|
||
expect(frame.className).toContain(
|
||
'image-canvas-editor__generation-frame--generating',
|
||
);
|
||
expect(within(frame).getByRole('status').textContent).toContain('生成中');
|
||
|
||
unmount();
|
||
}
|
||
});
|
||
|
||
it('hides the icon material panel after generation starts while keeping the icon preview frame visible', async () => {
|
||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||
projectId: 'editor-project-icons-generating',
|
||
title: '图标素材生成中画布',
|
||
viewport: { x: 0, y: 0, scale: 1 },
|
||
layers: [
|
||
{
|
||
layerId: 'layer-icon-spec-generating',
|
||
resourceId: 'resource-icon-spec-generating',
|
||
title: '清爽按钮图标规范',
|
||
src: 'data:image/png;base64,icon-spec-generating',
|
||
x: 80,
|
||
y: 80,
|
||
width: 160,
|
||
height: 160,
|
||
originalWidth: 512,
|
||
originalHeight: 512,
|
||
zIndex: 10,
|
||
sourceType: 'generated',
|
||
assetKind: 'icon-spec',
|
||
},
|
||
],
|
||
resources: [],
|
||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||
});
|
||
generateEditorIconSpritesheetMock.mockReturnValueOnce(
|
||
new Promise(() => undefined),
|
||
);
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
await screen.findByAltText('画布图片:清爽按钮图标规范');
|
||
fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
|
||
const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
|
||
fireEvent.click(
|
||
within(iconPanel).getByRole('button', { name: '图标素材规范' }),
|
||
);
|
||
fireEvent.click(
|
||
within(screen.getByRole('menu', { name: '图标素材规范来源' })).getByRole(
|
||
'menuitem',
|
||
{ name: '从画布中选择' },
|
||
),
|
||
);
|
||
fireEvent.pointerDown(
|
||
screen.getByAltText('画布图片:清爽按钮图标规范').closest('button')!,
|
||
{
|
||
button: 0,
|
||
pointerId: 1260,
|
||
clientX: 120,
|
||
clientY: 120,
|
||
},
|
||
);
|
||
fireEvent.click(
|
||
within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole(
|
||
'button',
|
||
{ name: '生成' },
|
||
),
|
||
);
|
||
|
||
expect(screen.queryByRole('dialog', { name: '生成图标素材' })).toBeNull();
|
||
const frame = screen.getByLabelText('图标素材生成占位图');
|
||
expect(frame.className).toContain(
|
||
'image-canvas-editor__generation-frame--generating',
|
||
);
|
||
expect(within(frame).getByRole('status').textContent).toContain('生成中');
|
||
});
|
||
|
||
it('opens character spec generation form and creates a labeled spec layer', async () => {
|
||
generateEditorImageMock.mockResolvedValueOnce({
|
||
imageSrc: 'data:image/png;base64,c3BlYy1yb2xl',
|
||
width: 2048,
|
||
height: 1152,
|
||
sourceType: 'generated',
|
||
prompt: '角色规范提示词',
|
||
actualPrompt: '角色规范提示词',
|
||
model: 'gpt-image-2',
|
||
provider: 'VectorEngine',
|
||
taskId: 'editor-spec-role-1',
|
||
});
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
|
||
const generationToolLabels = within(bottomToolbar)
|
||
.getAllByRole('button')
|
||
.filter((button) => button.getAttribute('aria-label')?.startsWith('生成'))
|
||
.map((button) => button.getAttribute('aria-label'));
|
||
expect(generationToolLabels).toContain('生成工具');
|
||
expect(generationToolLabels).toContain('生成规范');
|
||
fireEvent.click(
|
||
within(bottomToolbar).getByRole('button', { name: '生成规范' }),
|
||
);
|
||
|
||
const specMenu = screen.getByRole('menu', { name: '生成规范类型' });
|
||
expect(
|
||
within(specMenu).getByRole('menuitem', { name: '角色形象规范' }),
|
||
).toBeTruthy();
|
||
expect(
|
||
within(specMenu).getByRole('menuitem', { name: 'UI素材规范' }),
|
||
).toBeTruthy();
|
||
expect(
|
||
within(specMenu).getByRole('menuitem', { name: '自定义规范' }),
|
||
).toBeTruthy();
|
||
|
||
fireEvent.click(
|
||
within(specMenu).getByRole('menuitem', { name: '角色形象规范' }),
|
||
);
|
||
|
||
const specDialog = screen.getByRole('dialog', { name: '生成规范' });
|
||
expect(screen.getByLabelText('规范生成占位图')).toBeTruthy();
|
||
expect(screen.getByText('2048 x 1152')).toBeTruthy();
|
||
expect((screen.getByLabelText('玩法设定') as HTMLInputElement).value).toBe(
|
||
'战棋类RPG玩法',
|
||
);
|
||
expect((screen.getByLabelText('美术风格') as HTMLInputElement).value).toBe(
|
||
'像素风',
|
||
);
|
||
expect((screen.getByLabelText('头身比') as HTMLSelectElement).value).toBe(
|
||
'3',
|
||
);
|
||
expect((screen.getByLabelText('角色视角') as HTMLInputElement).value).toBe(
|
||
'右向斜侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯右视图,也禁止生成正面立绘。',
|
||
);
|
||
expect(
|
||
within(specDialog).getByRole('button', { name: '提交生成规范' })
|
||
.textContent,
|
||
).toContain('消耗5泥点');
|
||
|
||
fireEvent.change(screen.getByLabelText('玩法设定'), {
|
||
target: { value: '平台跳跃玩法' },
|
||
});
|
||
fireEvent.change(screen.getByLabelText('美术风格'), {
|
||
target: { value: '低多边形卡通' },
|
||
});
|
||
fireEvent.change(screen.getByLabelText('头身比'), {
|
||
target: { value: '4' },
|
||
});
|
||
fireEvent.change(screen.getByLabelText('角色视角'), {
|
||
target: { value: '左向三分之二侧身站姿' },
|
||
});
|
||
fireEvent.click(
|
||
within(specDialog).getByRole('button', { name: '提交生成规范' }),
|
||
);
|
||
|
||
expect(generateEditorImageMock).toHaveBeenCalledWith({
|
||
kind: 'spec',
|
||
model: '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(<ImageCanvasEditorView />);
|
||
await screen.findByAltText('画布图片:拼图素材');
|
||
|
||
fireEvent.click(
|
||
within(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).getByRole(
|
||
'button',
|
||
{ name: '生成规范' },
|
||
),
|
||
);
|
||
fireEvent.click(screen.getByRole('menuitem', { name: '角色形象规范' }));
|
||
|
||
const characterSpecDialog = screen.getByRole('dialog', {
|
||
name: '生成规范',
|
||
});
|
||
['玩法设定', '美术风格', '头身比', '角色视角'].forEach((title) => {
|
||
expect(within(characterSpecDialog).getByText(title)).toBeTruthy();
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
|
||
|
||
const iconSpritesheetPanel = screen.getByRole('dialog', {
|
||
name: '生成图标素材',
|
||
});
|
||
expect(
|
||
within(iconSpritesheetPanel).getByRole('button', {
|
||
name: '图标素材规范',
|
||
}),
|
||
).toBeTruthy();
|
||
expect(within(iconSpritesheetPanel).getByText('素材描述')).toBeTruthy();
|
||
expect(within(iconSpritesheetPanel).getByText('素材描述 1')).toBeTruthy();
|
||
expect(within(iconSpritesheetPanel).getByText('素材描述 6')).toBeTruthy();
|
||
expect(within(iconSpritesheetPanel).getByText('模型')).toBeTruthy();
|
||
|
||
fireEvent.click(
|
||
within(iconSpritesheetPanel).getByRole('button', {
|
||
name: '图标素材规范',
|
||
}),
|
||
);
|
||
fireEvent.click(screen.getByRole('menuitem', { name: '新建图标素材规范' }));
|
||
|
||
const iconSpecDialog = screen.getByRole('dialog', { name: '生成规范' });
|
||
['玩法设定', '美术风格'].forEach((title) => {
|
||
expect(within(iconSpecDialog).getByText(title)).toBeTruthy();
|
||
});
|
||
});
|
||
|
||
it('defaults character and icon generation to nanobanana2 model options', async () => {
|
||
render(<ImageCanvasEditorView />);
|
||
await screen.findByAltText('画布图片:拼图素材');
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
|
||
const characterPanel = screen.getByRole('dialog', {
|
||
name: '生成角色形象',
|
||
});
|
||
expect(within(characterPanel).getByText('尺寸比例')).toBeTruthy();
|
||
expect(within(characterPanel).getByText('大小尺寸')).toBeTruthy();
|
||
expect(
|
||
(within(characterPanel).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(<ImageCanvasEditorView />);
|
||
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(<ImageCanvasEditorView />);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
|
||
|
||
expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy();
|
||
expect(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).toBeTruthy();
|
||
});
|
||
|
||
it('keeps existing generation placeholders when another bottom generation object is created', async () => {
|
||
render(<ImageCanvasEditorView />);
|
||
await act(async () => {});
|
||
|
||
const bottomToolbar = screen.getByRole('toolbar', {
|
||
name: 'AI画布工具栏',
|
||
});
|
||
fireEvent.click(
|
||
within(bottomToolbar).getByRole('button', { name: '生成规范' }),
|
||
);
|
||
fireEvent.click(screen.getByRole('menuitem', { name: '角色形象规范' }));
|
||
|
||
expect(screen.getByLabelText('规范生成占位图')).toBeTruthy();
|
||
expect(screen.getByRole('dialog', { name: '生成规范' })).toBeTruthy();
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
|
||
|
||
expect(screen.getByLabelText('规范生成占位图')).toBeTruthy();
|
||
expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
|
||
expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy();
|
||
|
||
fireEvent.pointerDown(screen.getByLabelText('规范生成占位图'), {
|
||
button: 0,
|
||
pointerId: 1701,
|
||
clientX: 180,
|
||
clientY: 180,
|
||
});
|
||
|
||
expect(screen.getByRole('dialog', { name: '生成规范' })).toBeTruthy();
|
||
expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
|
||
});
|
||
|
||
it('keeps archived generation logic using the latest placeholder when another object is active', async () => {
|
||
let resolveGeneration!: (value: unknown) => void;
|
||
generateEditorImageMock.mockReturnValueOnce(
|
||
new Promise((resolve) => {
|
||
resolveGeneration = resolve;
|
||
}),
|
||
);
|
||
render(<ImageCanvasEditorView />);
|
||
await waitFor(() => {
|
||
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
|
||
fireEvent.change(screen.getByLabelText('生成提示词'), {
|
||
target: { value: '生成中切换后仍保留位置' },
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||
|
||
const originalFrame = screen.getByLabelText('图像生成占位图');
|
||
const originalLeft = Number.parseFloat(
|
||
(originalFrame as HTMLElement).style.left,
|
||
);
|
||
const originalTop = Number.parseFloat(
|
||
(originalFrame as HTMLElement).style.top,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
|
||
expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
|
||
const characterFrame = screen.getByLabelText('角色生成占位图');
|
||
expect(characterFrame).toBeTruthy();
|
||
|
||
dispatchPointerEvent(screen.getByLabelText('图像生成占位图'), 'pointerdown', {
|
||
button: 0,
|
||
pointerId: 1702,
|
||
clientX: 500,
|
||
clientY: 260,
|
||
});
|
||
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', {
|
||
pointerId: 1702,
|
||
clientX: 650,
|
||
clientY: 390,
|
||
});
|
||
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointerup', {
|
||
pointerId: 1702,
|
||
clientX: 650,
|
||
clientY: 390,
|
||
});
|
||
const movedFrame = screen.getByLabelText('图像生成占位图');
|
||
const movedLeft = Number.parseFloat(
|
||
(movedFrame as HTMLElement).style.left,
|
||
);
|
||
const movedTop = Number.parseFloat((movedFrame as HTMLElement).style.top);
|
||
expect(movedLeft).toBeGreaterThan(originalLeft);
|
||
expect(movedTop).toBeGreaterThan(originalTop);
|
||
|
||
dispatchPointerEvent(characterFrame, 'pointerdown', {
|
||
button: 0,
|
||
pointerId: 1703,
|
||
clientX: 360,
|
||
clientY: 240,
|
||
});
|
||
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointerup', {
|
||
pointerId: 1703,
|
||
clientX: 360,
|
||
clientY: 240,
|
||
});
|
||
expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy();
|
||
|
||
await act(async () => {
|
||
resolveGeneration({
|
||
imageSrc: 'data:image/png;base64,YXJjaGl2ZWQtbG9naWM=',
|
||
width: 1024,
|
||
height: 1024,
|
||
sourceType: 'generated',
|
||
prompt: '生成中切换后仍保留位置',
|
||
actualPrompt: '生成中切换后仍保留位置',
|
||
model: 'gpt-image-2',
|
||
provider: 'VectorEngine',
|
||
taskId: 'editor-archived-generation-1',
|
||
});
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
|
||
});
|
||
const generatedLayer = screen
|
||
.getByAltText(/画布图片:生成图片/)
|
||
.closest('button') as HTMLElement;
|
||
const expectedLayerLeft =
|
||
movedLeft + Number.parseFloat((movedFrame as HTMLElement).style.width) / 2 - 512;
|
||
const expectedLayerTop =
|
||
movedTop + Number.parseFloat((movedFrame as HTMLElement).style.height) / 2 - 512;
|
||
expect(Number.parseFloat(generatedLayer.style.left)).toBeCloseTo(
|
||
expectedLayerLeft,
|
||
1,
|
||
);
|
||
expect(Number.parseFloat(generatedLayer.style.top)).toBeCloseTo(
|
||
expectedLayerTop,
|
||
1,
|
||
);
|
||
expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
|
||
});
|
||
|
||
it('renders editor popup menus outside clipped local containers', () => {
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
|
||
fireEvent.click(
|
||
within(bottomToolbar).getByRole('button', { name: '生成规范' }),
|
||
);
|
||
const specMenu = screen.getByRole('menu', { name: '生成规范类型' });
|
||
|
||
expect(bottomToolbar.contains(specMenu)).toBe(false);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
|
||
const characterPanel = screen.getByRole('dialog', { name: '生成角色形象' });
|
||
fireEvent.click(
|
||
within(characterPanel).getByRole('button', { name: '角色形象规范' }),
|
||
);
|
||
const referenceRow = characterPanel.querySelector(
|
||
'.image-canvas-editor__character-reference-row',
|
||
);
|
||
const sourceMenu = screen.getByRole('menu', { name: '角色形象规范来源' });
|
||
|
||
expect(referenceRow?.contains(sourceMenu)).toBe(false);
|
||
});
|
||
|
||
it('uses Lovart-style reference tiles in the character generation panel', () => {
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
|
||
const characterPanel = screen.getByRole('dialog', { name: '生成角色形象' });
|
||
const specTile = within(characterPanel).getByRole('button', {
|
||
name: '角色形象规范',
|
||
});
|
||
const uploadTile = within(characterPanel).getByRole('button', {
|
||
name: '上传常规参考图',
|
||
});
|
||
|
||
expect(specTile.className).toContain('image-canvas-editor__reference-tile');
|
||
expect(uploadTile.className).toContain(
|
||
'image-canvas-editor__reference-tile',
|
||
);
|
||
expect(
|
||
specTile.querySelector('.image-canvas-editor__reference-tile-visual'),
|
||
).toBeTruthy();
|
||
expect(
|
||
uploadTile.querySelector('.image-canvas-editor__reference-tile-visual'),
|
||
).toBeTruthy();
|
||
});
|
||
|
||
it('expands the icon panel width as new description items are added', async () => {
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
await waitFor(() => {
|
||
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
|
||
|
||
const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
|
||
expect(Number.parseFloat(iconPanel.style.width)).toBeCloseTo(52.8, 1);
|
||
expect(
|
||
iconPanel.querySelector('.image-canvas-editor__icon-description-list'),
|
||
).toBeTruthy();
|
||
expect(
|
||
iconPanel.querySelector('.image-canvas-editor__icon-description-card'),
|
||
).toBeTruthy();
|
||
expect(
|
||
iconPanel.querySelector('.image-canvas-editor__icon-spec-card'),
|
||
).toBeTruthy();
|
||
|
||
fireEvent.click(within(iconPanel).getByRole('button', { name: '添加素材描述' }));
|
||
|
||
expect(Number.parseFloat(iconPanel.style.width)).toBeCloseTo(61.2, 1);
|
||
expect(within(iconPanel).getAllByRole('textbox')).toHaveLength(7);
|
||
});
|
||
|
||
it('hides the active generation panel and clears image selection after canvas background focus', async () => {
|
||
generateEditorImageMock.mockResolvedValueOnce({
|
||
imageSrc: 'data:image/png;base64,Zm9jdXMtY2xlYXI=',
|
||
width: 1024,
|
||
height: 1024,
|
||
sourceType: 'generated',
|
||
prompt: '发光蘑菇角色',
|
||
actualPrompt: '发光蘑菇角色',
|
||
model: 'gpt-image-2',
|
||
provider: 'VectorEngine',
|
||
taskId: 'editor-focus-clear-1',
|
||
});
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
|
||
fireEvent.change(screen.getByLabelText('生成提示词'), {
|
||
target: { value: '发光蘑菇角色' },
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||
|
||
const generatedImage = await screen.findByAltText(/画布图片:生成图片/u);
|
||
const generatedLayerButton = generatedImage.closest('button')!;
|
||
expect(generatedLayerButton.className).toContain(
|
||
'image-canvas-editor__layer--selected',
|
||
);
|
||
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
|
||
|
||
fireEvent.pointerDown(screen.getByLabelText('画布工作区'), {
|
||
button: 0,
|
||
pointerId: 261,
|
||
clientX: 40,
|
||
clientY: 40,
|
||
});
|
||
|
||
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
|
||
expect(generatedLayerButton.className).not.toContain(
|
||
'image-canvas-editor__layer--selected',
|
||
);
|
||
});
|
||
|
||
it('hides a newly created placeholder panel after canvas background focus', () => {
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
|
||
expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy();
|
||
expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
|
||
|
||
fireEvent.pointerDown(screen.getByLabelText('画布工作区'), {
|
||
button: 0,
|
||
pointerId: 262,
|
||
clientX: 40,
|
||
clientY: 40,
|
||
});
|
||
|
||
expect(screen.queryByRole('dialog', { name: '生成角色形象' })).toBeNull();
|
||
expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
|
||
});
|
||
|
||
it('builds UI spec prompts from two fields and uses 2K landscape generation', async () => {
|
||
generateEditorImageMock.mockResolvedValueOnce({
|
||
imageSrc: 'data:image/png;base64,c3BlYy11aQ==',
|
||
width: 2048,
|
||
height: 1152,
|
||
sourceType: 'generated',
|
||
prompt: 'UI规范提示词',
|
||
actualPrompt: 'UI规范提示词',
|
||
model: 'gpt-image-2',
|
||
provider: 'VectorEngine',
|
||
taskId: 'editor-spec-ui-1',
|
||
});
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
fireEvent.click(
|
||
within(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).getByRole(
|
||
'button',
|
||
{ name: '生成规范' },
|
||
),
|
||
);
|
||
fireEvent.click(screen.getByRole('menuitem', { name: 'UI素材规范' }));
|
||
|
||
expect((screen.getByLabelText('玩法设定') as HTMLInputElement).value).toBe(
|
||
'抓娃娃题材的抓大鹅玩法',
|
||
);
|
||
expect((screen.getByLabelText('美术风格') as HTMLInputElement).value).toBe(
|
||
'毛茸茸',
|
||
);
|
||
fireEvent.change(screen.getByLabelText('玩法设定'), {
|
||
target: { value: '消除类派对玩法' },
|
||
});
|
||
fireEvent.change(screen.getByLabelText('美术风格'), {
|
||
target: { value: '糖果玻璃拟物' },
|
||
});
|
||
fireEvent.click(
|
||
within(screen.getByRole('dialog', { name: '生成规范' })).getByRole(
|
||
'button',
|
||
{ name: '提交生成规范' },
|
||
),
|
||
);
|
||
|
||
expect(generateEditorImageMock).toHaveBeenCalledWith({
|
||
kind: 'spec',
|
||
model: '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(<ImageCanvasEditorView />);
|
||
|
||
fireEvent.click(
|
||
within(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).getByRole(
|
||
'button',
|
||
{ name: '生成规范' },
|
||
),
|
||
);
|
||
fireEvent.click(screen.getByRole('menuitem', { name: '自定义规范' }));
|
||
fireEvent.change(screen.getByLabelText('自定义规范提示词'), {
|
||
target: { value: ' 生成一张武器图标规范展板 ' },
|
||
});
|
||
fireEvent.click(
|
||
within(screen.getByRole('dialog', { name: '生成规范' })).getByRole(
|
||
'button',
|
||
{ name: '提交生成规范' },
|
||
),
|
||
);
|
||
|
||
expect(generateEditorImageMock).toHaveBeenCalledWith({
|
||
kind: 'spec',
|
||
model: '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(<ImageCanvasEditorView />);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
|
||
|
||
const characterPanel = screen.getByRole('dialog', { name: '生成角色形象' });
|
||
expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
|
||
expect(
|
||
within(characterPanel).getByRole('button', { name: '角色形象规范' }),
|
||
).toBeTruthy();
|
||
|
||
fireEvent.click(
|
||
within(characterPanel).getByRole('button', { name: '角色形象规范' }),
|
||
);
|
||
const specSourceMenu = screen.getByRole('menu', {
|
||
name: '角色形象规范来源',
|
||
});
|
||
fireEvent.click(
|
||
within(specSourceMenu).getByRole('menuitem', { name: '从画布中选择' }),
|
||
);
|
||
expect(
|
||
screen.getByText('请选择画布中的图片作为角色形象规范,按 Esc 退出'),
|
||
).toBeTruthy();
|
||
|
||
fireEvent.pointerDown(
|
||
screen.getByAltText('画布图片:拼图素材').closest('button')!,
|
||
{
|
||
button: 0,
|
||
pointerId: 170,
|
||
clientX: 120,
|
||
clientY: 120,
|
||
},
|
||
);
|
||
expect(within(characterPanel).getByText('拼图素材')).toBeTruthy();
|
||
expect(
|
||
screen.queryByText('请选择画布中的图片作为角色形象规范,按 Esc 退出'),
|
||
).toBeNull();
|
||
|
||
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(<ImageCanvasEditorView />);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
|
||
|
||
expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
|
||
expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy();
|
||
|
||
await act(async () => {
|
||
fireEvent.keyDown(window, { key: 'Backspace', code: 'Backspace' });
|
||
});
|
||
|
||
expect(screen.queryByLabelText('角色生成占位图')).toBeNull();
|
||
expect(screen.queryByRole('dialog', { name: '生成角色形象' })).toBeNull();
|
||
expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy();
|
||
});
|
||
|
||
it('opens icon asset generation panel, only picks icon specs, and lays generated icons on canvas', async () => {
|
||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||
projectId: 'editor-project-icons',
|
||
title: '图标素材画布',
|
||
viewport: { x: 0, y: 0, scale: 1 },
|
||
layers: [
|
||
{
|
||
layerId: 'layer-plain',
|
||
resourceId: 'resource-plain',
|
||
title: '普通参考图',
|
||
src: 'data:image/png;base64,plain',
|
||
x: 80,
|
||
y: 80,
|
||
width: 120,
|
||
height: 120,
|
||
originalWidth: 512,
|
||
originalHeight: 512,
|
||
zIndex: 10,
|
||
sourceType: 'uploaded',
|
||
},
|
||
{
|
||
layerId: 'layer-icon-spec',
|
||
resourceId: 'resource-icon-spec',
|
||
title: '清爽按钮图标规范',
|
||
src: 'data:image/png;base64,icon-spec',
|
||
x: 240,
|
||
y: 80,
|
||
width: 160,
|
||
height: 120,
|
||
originalWidth: 2048,
|
||
originalHeight: 1152,
|
||
zIndex: 11,
|
||
sourceType: 'generated',
|
||
assetKind: 'icon-spec',
|
||
},
|
||
],
|
||
resources: [],
|
||
updatedAt: '2026-06-15T00:00:00.000Z',
|
||
});
|
||
generateEditorIconSpritesheetMock.mockResolvedValueOnce({
|
||
spritesheetImageSrc: 'data:image/png;base64,sheet',
|
||
spritesheetWidth: 512,
|
||
spritesheetHeight: 512,
|
||
iconImageSrcs: [
|
||
{
|
||
name: '返回按钮',
|
||
imageSrc: 'data:image/png;base64,back-icon',
|
||
width: 96,
|
||
height: 96,
|
||
},
|
||
{
|
||
name: '设置按钮',
|
||
imageSrc: 'data:image/png;base64,setting-icon',
|
||
width: 96,
|
||
height: 96,
|
||
},
|
||
],
|
||
prompt: '图标 prompt',
|
||
actualPrompt: '图标 prompt',
|
||
model: 'gemini-3.1-flash-image-preview',
|
||
provider: 'VectorEngine',
|
||
taskId: 'icon-task-1',
|
||
});
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByAltText('画布图片:普通参考图')).toBeTruthy();
|
||
expect(screen.getByAltText('画布图片:清爽按钮图标规范')).toBeTruthy();
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
|
||
|
||
const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
|
||
expect(screen.getByLabelText('图标素材生成占位图')).toBeTruthy();
|
||
expect(
|
||
within(iconPanel).getByRole('button', { name: '图标素材规范' }),
|
||
).toBeTruthy();
|
||
expect(
|
||
(within(iconPanel).getAllByRole('textbox')[0] as HTMLInputElement).value,
|
||
).toBe('返回按钮');
|
||
expect(
|
||
(within(iconPanel).getAllByRole('textbox')[5] as HTMLInputElement).value,
|
||
).toBe('冻结按钮');
|
||
|
||
fireEvent.click(
|
||
within(iconPanel).getByRole('button', { name: '图标素材规范' }),
|
||
);
|
||
fireEvent.click(screen.getByRole('menuitem', { name: '从画布中选择' }));
|
||
expect(
|
||
screen.getByText('请选择画布中的图标素材规范,按 Esc 退出'),
|
||
).toBeTruthy();
|
||
|
||
fireEvent.pointerDown(
|
||
screen.getByAltText('画布图片:普通参考图').closest('button')!,
|
||
{
|
||
button: 0,
|
||
pointerId: 180,
|
||
clientX: 100,
|
||
clientY: 100,
|
||
},
|
||
);
|
||
expect(
|
||
within(iconPanel).getByRole('button', { name: '图标素材规范' }),
|
||
).toBeTruthy();
|
||
|
||
fireEvent.pointerDown(
|
||
screen.getByAltText('画布图片:清爽按钮图标规范').closest('button')!,
|
||
{
|
||
button: 0,
|
||
pointerId: 181,
|
||
clientX: 260,
|
||
clientY: 100,
|
||
},
|
||
);
|
||
expect(
|
||
within(iconPanel).getByRole('button', { name: '清爽按钮图标规范' }),
|
||
).toBeTruthy();
|
||
expect(
|
||
screen.queryByText('请选择画布中的图标素材规范,按 Esc 退出'),
|
||
).toBeNull();
|
||
|
||
const iconDescriptionInputs = within(iconPanel).getAllByRole('textbox');
|
||
const [
|
||
,
|
||
,
|
||
iconDescription3,
|
||
iconDescription4,
|
||
iconDescription5,
|
||
iconDescription6,
|
||
] = iconDescriptionInputs;
|
||
expect(iconDescription3).toBeTruthy();
|
||
expect(iconDescription4).toBeTruthy();
|
||
expect(iconDescription5).toBeTruthy();
|
||
expect(iconDescription6).toBeTruthy();
|
||
|
||
fireEvent.change(iconDescription3!, {
|
||
target: { value: '' },
|
||
});
|
||
fireEvent.change(iconDescription4!, {
|
||
target: { value: '' },
|
||
});
|
||
fireEvent.change(iconDescription5!, {
|
||
target: { value: '' },
|
||
});
|
||
fireEvent.change(iconDescription6!, {
|
||
target: { value: '' },
|
||
});
|
||
fireEvent.click(within(iconPanel).getByRole('button', { name: '生成' }));
|
||
|
||
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith({
|
||
referenceImageSrc: 'data:image/png;base64,icon-spec',
|
||
iconDescriptions: ['返回按钮', '设置按钮'],
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByAltText('画布图片:返回按钮')).toBeTruthy();
|
||
expect(screen.getByAltText('画布图片:设置按钮')).toBeTruthy();
|
||
});
|
||
expect(screen.queryByLabelText('图标素材生成占位图')).toBeNull();
|
||
expect(screen.getAllByText('图标')).toHaveLength(2);
|
||
fireEvent.click(
|
||
screen.getAllByRole('button', { name: '查看返回按钮图片信息' })[0]!,
|
||
);
|
||
const iconInfoPanel = screen.getByRole('dialog', {
|
||
name: '返回按钮图片信息',
|
||
});
|
||
expect(within(iconInfoPanel).queryByText('Prompt')).toBeNull();
|
||
expect(within(iconInfoPanel).getByText('生成输入')).toBeTruthy();
|
||
expect(within(iconInfoPanel).getByText('素材描述 1')).toBeTruthy();
|
||
expect(within(iconInfoPanel).getByText('素材描述 2')).toBeTruthy();
|
||
expect(within(iconInfoPanel).getByText('返回按钮')).toBeTruthy();
|
||
expect(within(iconInfoPanel).getByText('设置按钮')).toBeTruthy();
|
||
expect(within(iconInfoPanel).getByText('图标素材规范')).toBeTruthy();
|
||
expect(within(iconInfoPanel).getByText('清爽按钮图标规范')).toBeTruthy();
|
||
await waitFor(() => {
|
||
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
||
'editor-project-icons',
|
||
expect.objectContaining({
|
||
layers: expect.arrayContaining([
|
||
expect.objectContaining({
|
||
title: '返回按钮',
|
||
assetKind: 'icon',
|
||
}),
|
||
expect.objectContaining({
|
||
title: '设置按钮',
|
||
assetKind: 'icon',
|
||
}),
|
||
]),
|
||
}),
|
||
);
|
||
});
|
||
});
|
||
|
||
it('exits character generation canvas picking with Escape', () => {
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
|
||
const characterPanel = screen.getByRole('dialog', { name: '生成角色形象' });
|
||
fireEvent.click(
|
||
within(characterPanel).getByRole('button', { name: '角色形象规范' }),
|
||
);
|
||
fireEvent.click(screen.getByRole('menuitem', { name: '从画布中选择' }));
|
||
|
||
expect(
|
||
screen.getByText('请选择画布中的图片作为角色形象规范,按 Esc 退出'),
|
||
).toBeTruthy();
|
||
|
||
fireEvent.keyDown(window, { key: 'Escape' });
|
||
|
||
expect(
|
||
screen.queryByText('请选择画布中的图片作为角色形象规范,按 Esc 退出'),
|
||
).toBeNull();
|
||
expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy();
|
||
});
|
||
|
||
it('only exposes character animation generation for character layers and submits the panel payload', async () => {
|
||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||
projectId: 'editor-project-character-animation',
|
||
title: '角色动画画布',
|
||
viewport: { x: 0, y: 0, scale: 1 },
|
||
layers: [
|
||
{
|
||
layerId: 'layer-character',
|
||
resourceId: 'resource-character',
|
||
title: '市场老妇人',
|
||
src: 'data:image/png;base64,character',
|
||
x: 160,
|
||
y: 140,
|
||
width: 320,
|
||
height: 320,
|
||
originalWidth: 1024,
|
||
originalHeight: 1024,
|
||
zIndex: 2,
|
||
sourceType: 'generated',
|
||
objectKey:
|
||
'generated-character-drafts/editor/character-images/source/image.png',
|
||
assetKind: 'character',
|
||
},
|
||
{
|
||
layerId: 'layer-prop',
|
||
resourceId: 'resource-prop',
|
||
title: '普通道具',
|
||
src: 'data:image/png;base64,prop',
|
||
x: 520,
|
||
y: 140,
|
||
width: 280,
|
||
height: 220,
|
||
originalWidth: 700,
|
||
originalHeight: 550,
|
||
zIndex: 1,
|
||
sourceType: 'uploaded',
|
||
},
|
||
],
|
||
resources: [],
|
||
updatedAt: '2026-06-15T00:00:00.000Z',
|
||
});
|
||
generateEditorCharacterAnimationMock.mockResolvedValueOnce({
|
||
taskId: 'character-animation-task-1',
|
||
model: 'seedance2.0',
|
||
prompt: '生成游戏角色动画\n动作描述:\n待机',
|
||
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
|
||
frames: Array.from({ length: 48 }, (_, index) => ({
|
||
frameIndex: index + 1,
|
||
imageSrc: `/generated-character-drafts/editor/frame${index + 1}.png`,
|
||
width: 1024,
|
||
height: 1024,
|
||
})),
|
||
frameCount: 48,
|
||
durationSeconds: 6,
|
||
fps: 8,
|
||
priceMudPoints: 120,
|
||
});
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
const propLayer = await screen.findByAltText('画布图片:普通道具');
|
||
fireEvent.click(propLayer.closest('button')!);
|
||
expect(screen.queryByRole('button', { name: '生成动画' })).toBeNull();
|
||
fireEvent.contextMenu(propLayer.closest('button')!, {
|
||
clientX: 220,
|
||
clientY: 180,
|
||
});
|
||
expect(screen.queryByRole('menuitem', { name: '生成动画' })).toBeNull();
|
||
|
||
const characterLayer = screen.getByAltText('画布图片:市场老妇人');
|
||
fireEvent.click(characterLayer.closest('button')!);
|
||
expect(screen.getByText('角色')).toBeTruthy();
|
||
expect(screen.getByRole('button', { name: '生成动画' })).toBeTruthy();
|
||
fireEvent.contextMenu(characterLayer.closest('button')!, {
|
||
clientX: 260,
|
||
clientY: 220,
|
||
});
|
||
expect(screen.getByRole('menuitem', { name: '生成动画' })).toBeTruthy();
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成动画' }));
|
||
const panel = screen.getByRole('dialog', { name: '角色动画生成面板' });
|
||
expect(within(panel).getByText('40泥点')).toBeTruthy();
|
||
expect(
|
||
(within(panel).getByLabelText('分辨率') as HTMLSelectElement).value,
|
||
).toBe('480p');
|
||
expect(
|
||
(within(panel).getByLabelText('画面比例') as HTMLSelectElement).value,
|
||
).toBe('same');
|
||
expect(
|
||
(within(panel).getByLabelText('时长') as HTMLSelectElement).value,
|
||
).toBe('32');
|
||
for (const actionLabel of [
|
||
'待机',
|
||
'行走',
|
||
'奔跑',
|
||
'跳跃',
|
||
'攻击',
|
||
'受击',
|
||
'倒下',
|
||
]) {
|
||
expect(
|
||
within(panel).getByRole('button', { name: actionLabel }),
|
||
).toBeTruthy();
|
||
}
|
||
fireEvent.click(within(panel).getByRole('button', { name: '待机' }));
|
||
expect(
|
||
(within(panel).getByLabelText('动画描述') as HTMLTextAreaElement).value,
|
||
).toContain('待机');
|
||
const longPrompt = '走'.repeat(4100);
|
||
fireEvent.change(within(panel).getByLabelText('动画描述'), {
|
||
target: { value: longPrompt },
|
||
});
|
||
expect(
|
||
(within(panel).getByLabelText('动画描述') as HTMLTextAreaElement).value,
|
||
).toHaveLength(4000);
|
||
const precisePrompt =
|
||
'The elderly market woman gently shifts weight while the basket sways.';
|
||
fireEvent.change(within(panel).getByLabelText('动画描述'), {
|
||
target: { value: precisePrompt },
|
||
});
|
||
expect(
|
||
within(panel).getByLabelText(`生成文本:${precisePrompt}`),
|
||
).toBeTruthy();
|
||
fireEvent.change(within(panel).getByLabelText('分辨率'), {
|
||
target: { value: '720p' },
|
||
});
|
||
fireEvent.change(within(panel).getByLabelText('画面比例'), {
|
||
target: { value: '16:9' },
|
||
});
|
||
fireEvent.change(within(panel).getByLabelText('时长'), {
|
||
target: { value: '48' },
|
||
});
|
||
expect(within(panel).getByText('120泥点')).toBeTruthy();
|
||
fireEvent.click(within(panel).getByRole('button', { name: '生成' }));
|
||
|
||
expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
sourceLayerId: 'layer-character',
|
||
sourceImageSrc:
|
||
'generated-character-drafts/editor/character-images/source/image.png',
|
||
sourceWidth: 1024,
|
||
sourceHeight: 1024,
|
||
resolution: '720p',
|
||
ratio: '16:9',
|
||
frameCount: 48,
|
||
durationSeconds: 6,
|
||
priceMudPoints: 120,
|
||
model: 'seedance2.0',
|
||
}),
|
||
);
|
||
expect(
|
||
generateEditorCharacterAnimationMock.mock.calls[0]?.[0]?.promptText,
|
||
).toBe(precisePrompt);
|
||
await waitFor(() => {
|
||
expect(within(panel).getByText('已生成 48 帧')).toBeTruthy();
|
||
});
|
||
});
|
||
|
||
it('opens quick edit from the floating toolbar with original image as first reference and generates beside the source', async () => {
|
||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||
projectId: 'editor-project-quick-edit',
|
||
title: '快速编辑画布',
|
||
viewport: { x: 0, y: 0, scale: 1 },
|
||
layers: [
|
||
{
|
||
layerId: 'layer-quick-source',
|
||
resourceId: 'resource-quick-source',
|
||
title: '魔法森林',
|
||
src: 'data:image/png;base64,c291cmNl',
|
||
x: 120,
|
||
y: 140,
|
||
width: 320,
|
||
height: 240,
|
||
originalWidth: 1536,
|
||
originalHeight: 1024,
|
||
zIndex: 2,
|
||
sourceType: 'generated',
|
||
prompt: '魔法森林原始提示词',
|
||
actualPrompt: '魔法森林原始提示词',
|
||
model: 'gpt-image-2',
|
||
provider: 'VectorEngine',
|
||
taskId: 'source-task-1',
|
||
assetKind: 'spec',
|
||
},
|
||
],
|
||
resources: [],
|
||
updatedAt: '2026-06-15T00:00:00.000Z',
|
||
});
|
||
generateEditorImageMock.mockResolvedValueOnce({
|
||
imageSrc: 'data:image/png;base64,cXVpY2stZWRpdA==',
|
||
width: 1536,
|
||
height: 1024,
|
||
sourceType: 'generated',
|
||
prompt: '增加萤火虫',
|
||
actualPrompt: '增加萤火虫',
|
||
model: 'gpt-image-2',
|
||
provider: 'VectorEngine',
|
||
taskId: 'quick-edit-task-1',
|
||
});
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
const sourceImage = await screen.findByAltText('画布图片:魔法森林');
|
||
fireEvent.pointerDown(sourceImage.closest('button')!, {
|
||
button: 0,
|
||
pointerId: 151,
|
||
clientX: 180,
|
||
clientY: 180,
|
||
});
|
||
fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
|
||
pointerId: 151,
|
||
clientX: 180,
|
||
clientY: 180,
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));
|
||
|
||
const quickPanel = screen.getByRole('dialog', { name: '快速编辑图片' });
|
||
expect(quickPanel.className).toContain(
|
||
'image-canvas-editor__quick-edit-panel',
|
||
);
|
||
expect(within(quickPanel).getByText('魔法森林')).toBeTruthy();
|
||
expect(
|
||
(within(quickPanel).getByLabelText('快速编辑尺寸') as HTMLSelectElement)
|
||
.value,
|
||
).toBe('1536x1024');
|
||
expect(
|
||
(within(quickPanel).getByLabelText('快速编辑模型') as HTMLSelectElement)
|
||
.value,
|
||
).toBe('gpt-image-2');
|
||
const references = within(quickPanel).getAllByRole('img');
|
||
expect(references[0]?.getAttribute('src')).toBe(
|
||
'data:image/png;base64,c291cmNl',
|
||
);
|
||
|
||
fireEvent.change(within(quickPanel).getByLabelText('快速编辑提示词'), {
|
||
target: { value: '增加萤火虫' },
|
||
});
|
||
fireEvent.click(within(quickPanel).getByRole('button', { name: '生成' }));
|
||
|
||
await waitFor(() => {
|
||
expect(generateEditorImageMock).toHaveBeenCalledWith({
|
||
prompt: '增加萤火虫',
|
||
size: '1536x1024',
|
||
kind: 'quick-edit',
|
||
model: 'gpt-image-2',
|
||
referenceImageSrcs: ['data:image/png;base64,c291cmNl'],
|
||
});
|
||
});
|
||
await waitFor(() => {
|
||
expect(screen.getByAltText('画布图片:魔法森林 快速编辑')).toBeTruthy();
|
||
});
|
||
const generatedLayer = screen
|
||
.getByAltText('画布图片:魔法森林 快速编辑')
|
||
.closest('button') as HTMLElement;
|
||
expect(Number.parseFloat(generatedLayer.style.left)).toBe(1688);
|
||
expect(Number.parseFloat(generatedLayer.style.top)).toBe(140);
|
||
expect(Number.parseFloat(generatedLayer.style.width)).toBe(1536);
|
||
expect(Number.parseFloat(generatedLayer.style.height)).toBe(1024);
|
||
await waitFor(() => {
|
||
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
||
'editor-project-quick-edit',
|
||
expect.objectContaining({
|
||
layers: expect.arrayContaining([
|
||
expect.objectContaining({
|
||
title: '魔法森林 快速编辑',
|
||
assetKind: 'spec',
|
||
width: 1536,
|
||
height: 1024,
|
||
originalWidth: 1536,
|
||
originalHeight: 1024,
|
||
x: 1688,
|
||
y: 140,
|
||
}),
|
||
]),
|
||
}),
|
||
);
|
||
});
|
||
});
|
||
|
||
it('opens quick edit from the image context menu', async () => {
|
||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||
projectId: 'editor-project-context-quick-edit',
|
||
title: '右键快速编辑画布',
|
||
viewport: { x: 0, y: 0, scale: 1 },
|
||
layers: [
|
||
{
|
||
layerId: 'layer-context-source',
|
||
resourceId: 'resource-context-source',
|
||
title: '右键图片',
|
||
src: 'data:image/png;base64,Y29udGV4dA==',
|
||
x: 80,
|
||
y: 90,
|
||
width: 260,
|
||
height: 260,
|
||
originalWidth: 1024,
|
||
originalHeight: 1024,
|
||
zIndex: 1,
|
||
sourceType: 'uploaded',
|
||
model: 'gpt-image-2',
|
||
},
|
||
],
|
||
resources: [],
|
||
updatedAt: '2026-06-15T00:00:00.000Z',
|
||
});
|
||
generateEditorImageMock.mockResolvedValueOnce({
|
||
imageSrc: 'data:image/png;base64,Y29udGV4dC1xdWljaw==',
|
||
width: 1024,
|
||
height: 1024,
|
||
sourceType: 'generated',
|
||
prompt: '换成夜晚',
|
||
actualPrompt: '换成夜晚',
|
||
model: 'gpt-image-2',
|
||
provider: 'VectorEngine',
|
||
taskId: 'context-quick-task-1',
|
||
});
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
const contextImage = await screen.findByAltText('画布图片:右键图片');
|
||
fireEvent.contextMenu(contextImage.closest('button')!, {
|
||
clientX: 260,
|
||
clientY: 220,
|
||
});
|
||
|
||
const menu = screen.getByRole('menu', { name: '图片功能面板' });
|
||
expect(
|
||
within(menu).getByRole('menuitem', { name: '快速编辑' }),
|
||
).toBeTruthy();
|
||
fireEvent.click(within(menu).getByRole('menuitem', { name: '快速编辑' }));
|
||
|
||
const panel = screen.getByRole('dialog', { name: '快速编辑图片' });
|
||
expect(within(panel).getByText('右键图片')).toBeTruthy();
|
||
fireEvent.change(within(panel).getByLabelText('快速编辑提示词'), {
|
||
target: { value: '换成夜晚' },
|
||
});
|
||
fireEvent.click(within(panel).getByRole('button', { name: '生成' }));
|
||
|
||
await waitFor(() => {
|
||
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
prompt: '换成夜晚',
|
||
referenceImageSrcs: ['data:image/png;base64,Y29udGV4dA=='],
|
||
size: '1024x1024',
|
||
model: 'gpt-image-2',
|
||
kind: 'quick-edit',
|
||
}),
|
||
);
|
||
});
|
||
await waitFor(() => {
|
||
expect(screen.getByAltText('画布图片:右键图片 快速编辑')).toBeTruthy();
|
||
});
|
||
});
|
||
|
||
it('converts non-data-url quick edit source images before submitting references', async () => {
|
||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||
projectId: 'editor-project-public-quick-edit',
|
||
title: '公开素材快速编辑画布',
|
||
viewport: { x: 0, y: 0, scale: 1 },
|
||
layers: [
|
||
{
|
||
layerId: 'layer-public-source',
|
||
resourceId: 'resource-public-source',
|
||
title: '公开拼图素材',
|
||
src: '/creation-type-references/puzzle.webp',
|
||
x: 120,
|
||
y: 140,
|
||
width: 320,
|
||
height: 240,
|
||
originalWidth: 640,
|
||
originalHeight: 640,
|
||
zIndex: 2,
|
||
sourceType: 'uploaded',
|
||
},
|
||
],
|
||
resources: [],
|
||
updatedAt: '2026-06-16T00:00:00.000Z',
|
||
});
|
||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
||
new Response(new Uint8Array([104, 101, 108, 108, 111]), {
|
||
status: 200,
|
||
headers: {
|
||
'Content-Type': 'image/webp',
|
||
},
|
||
}),
|
||
);
|
||
generateEditorImageMock.mockResolvedValueOnce({
|
||
imageSrc: 'data:image/png;base64,cHVibGljLXF1aWNr',
|
||
width: 640,
|
||
height: 640,
|
||
sourceType: 'generated',
|
||
prompt: '改成陶泥风格',
|
||
actualPrompt: '改成陶泥风格',
|
||
model: 'gpt-image-2',
|
||
provider: 'VectorEngine',
|
||
taskId: 'public-quick-edit-task-1',
|
||
});
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
const sourceImage = await screen.findByAltText('画布图片:公开拼图素材');
|
||
fireEvent.pointerDown(sourceImage.closest('button')!, {
|
||
button: 0,
|
||
pointerId: 161,
|
||
clientX: 180,
|
||
clientY: 180,
|
||
});
|
||
fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
|
||
pointerId: 161,
|
||
clientX: 180,
|
||
clientY: 180,
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));
|
||
|
||
const quickPanel = screen.getByRole('dialog', { name: '快速编辑图片' });
|
||
fireEvent.change(within(quickPanel).getByLabelText('快速编辑提示词'), {
|
||
target: { value: '改成陶泥风格' },
|
||
});
|
||
fireEvent.click(within(quickPanel).getByRole('button', { name: '生成' }));
|
||
|
||
await waitFor(() => {
|
||
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
prompt: '改成陶泥风格',
|
||
kind: 'quick-edit',
|
||
referenceImageSrcs: ['data:image/webp;base64,aGVsbG8='],
|
||
}),
|
||
);
|
||
});
|
||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||
'/creation-type-references/puzzle.webp',
|
||
expect.objectContaining({
|
||
signal: undefined,
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('switches tools and restores the previous tool after holding Space', async () => {
|
||
const user = userEvent.setup();
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
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(<ImageCanvasEditorView />);
|
||
|
||
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(<ImageCanvasEditorView />);
|
||
|
||
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(<ImageCanvasEditorView />);
|
||
|
||
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(<ImageCanvasEditorView />);
|
||
|
||
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(<ImageCanvasEditorView />);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
|
||
fireEvent.change(screen.getByLabelText('生成提示词'), {
|
||
target: { value: '一张可修改的生成图' },
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
|
||
});
|
||
const generatedLayer = screen
|
||
.getByAltText(/画布图片:生成图片/)
|
||
.closest('button') as HTMLElement;
|
||
expect(Number.parseFloat(generatedLayer.style.width)).toBe(1024);
|
||
expect(Number.parseFloat(generatedLayer.style.height)).toBe(1024);
|
||
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
|
||
|
||
const metadataCornerButton = screen.getAllByRole('button', {
|
||
name: /查看生成图片 .*图片信息/,
|
||
})[0];
|
||
if (!metadataCornerButton) {
|
||
throw new Error('metadata corner button should exist');
|
||
}
|
||
expect(metadataCornerButton.className).toContain('bg-black/55');
|
||
expect(metadataCornerButton.className).toContain(
|
||
'image-canvas-editor__metadata-corner',
|
||
);
|
||
fireEvent.click(metadataCornerButton);
|
||
|
||
const metadataDialog = screen.getByRole('dialog', {
|
||
name: /生成图片 .*图片信息/,
|
||
});
|
||
expect(metadataDialog).toBeTruthy();
|
||
expect(within(metadataDialog).getByText('图片类型')).toBeTruthy();
|
||
expect(within(metadataDialog).getByText('生成图片')).toBeTruthy();
|
||
expect(within(metadataDialog).queryByText('Prompt')).toBeNull();
|
||
expect(
|
||
within(metadataDialog).queryByRole('button', { name: '复制Prompt' }),
|
||
).toBeNull();
|
||
expect(within(metadataDialog).getByText('生成输入')).toBeTruthy();
|
||
expect(within(metadataDialog).getByText('生成提示词')).toBeTruthy();
|
||
expect(within(metadataDialog).getByText('一张可修改的生成图')).toBeTruthy();
|
||
expect(within(metadataDialog).getByText('Model')).toBeTruthy();
|
||
expect(within(metadataDialog).getByText('gpt-image-2')).toBeTruthy();
|
||
expect(within(metadataDialog).queryByText('Size')).toBeNull();
|
||
expect(within(metadataDialog).getByText('Resolution')).toBeTruthy();
|
||
expect(within(metadataDialog).getByText('1024 x 1024 px')).toBeTruthy();
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '修改图片' }));
|
||
const editDialog = screen.getByRole('dialog', { name: '修改图片' });
|
||
expect(editDialog).toBeTruthy();
|
||
const editPrompt = screen.getByLabelText('生成提示词');
|
||
expect(editPrompt.className).toContain('platform-text-field');
|
||
expect(editPrompt.className).toContain(
|
||
'image-canvas-editor__generate-prompt',
|
||
);
|
||
fireEvent.change(editPrompt, {
|
||
target: { value: '把画面改成黄昏光线' },
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '修改' }));
|
||
|
||
expect(screen.getByRole('status').textContent).toContain('修改中');
|
||
await waitFor(() => {
|
||
expect(editEditorImageMock).toHaveBeenCalledWith({
|
||
prompt: '把画面改成黄昏光线',
|
||
sourceImageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==',
|
||
});
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(screen.queryByRole('dialog', { name: '修改图片' })).toBeNull();
|
||
});
|
||
expect(screen.getByAltText(/画布图片:生成图片 .* 修改结果/)).toBeTruthy();
|
||
fireEvent.click(
|
||
screen.getAllByRole('button', {
|
||
name: /查看生成图片 .* 修改结果图片信息/u,
|
||
})[0]!,
|
||
);
|
||
const editedMetadataDialog = screen.getByRole('dialog', {
|
||
name: /生成图片 .* 修改结果图片信息/u,
|
||
});
|
||
expect(within(editedMetadataDialog).queryByText('Prompt')).toBeNull();
|
||
expect(within(editedMetadataDialog).getByText('修改要求')).toBeTruthy();
|
||
expect(within(editedMetadataDialog).getByText('把画面改成黄昏光线')).toBeTruthy();
|
||
expect(within(editedMetadataDialog).getByText('参考图')).toBeTruthy();
|
||
expect(
|
||
within(editedMetadataDialog).getByText(/^生成图片 \d+$/u),
|
||
).toBeTruthy();
|
||
expect(screen.getByRole('button', { name: /当前缩放比例 \d+%/u })).toBeTruthy();
|
||
});
|
||
|
||
it('hides the edit image panel after generation starts while keeping the source preview visible', async () => {
|
||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||
projectId: 'editor-project-edit-generating',
|
||
title: '修改图片生成中画布',
|
||
viewport: { x: 0, y: 0, scale: 1 },
|
||
layers: [
|
||
{
|
||
layerId: 'layer-edit-generating-source',
|
||
resourceId: 'resource-edit-generating-source',
|
||
title: '待修改图片',
|
||
src: 'data:image/png;base64,ZWRpdC1nZW5lcmF0aW5n',
|
||
x: 120,
|
||
y: 140,
|
||
width: 320,
|
||
height: 240,
|
||
originalWidth: 1024,
|
||
originalHeight: 768,
|
||
zIndex: 2,
|
||
sourceType: 'generated',
|
||
prompt: '原始提示词',
|
||
actualPrompt: '原始提示词',
|
||
model: 'gpt-image-2',
|
||
provider: 'VectorEngine',
|
||
taskId: 'edit-generating-source-task',
|
||
},
|
||
],
|
||
resources: [],
|
||
updatedAt: '2026-06-16T00:00:00.000Z',
|
||
});
|
||
editEditorImageMock.mockReturnValueOnce(new Promise(() => undefined));
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
const sourceImage = await screen.findByAltText('画布图片:待修改图片');
|
||
const sourceLayer = sourceImage.closest('button')!;
|
||
fireEvent.pointerDown(sourceLayer, {
|
||
button: 0,
|
||
pointerId: 171,
|
||
clientX: 180,
|
||
clientY: 180,
|
||
});
|
||
fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
|
||
pointerId: 171,
|
||
clientX: 180,
|
||
clientY: 180,
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '修改图片' }));
|
||
const editDialog = screen.getByRole('dialog', { name: '修改图片' });
|
||
fireEvent.change(within(editDialog).getByLabelText('生成提示词'), {
|
||
target: { value: '改成雨夜灯光' },
|
||
});
|
||
fireEvent.click(within(editDialog).getByRole('button', { name: '修改' }));
|
||
|
||
expect(screen.queryByRole('dialog', { name: '修改图片' })).toBeNull();
|
||
expect(screen.getByAltText('画布图片:待修改图片')).toBeTruthy();
|
||
expect(sourceLayer.className).toContain(
|
||
'image-canvas-editor__layer--generating',
|
||
);
|
||
expect(within(sourceLayer).getByRole('status').textContent).toContain(
|
||
'修改中',
|
||
);
|
||
});
|
||
|
||
it('hides the quick edit panel after generation starts while keeping the source preview visible', async () => {
|
||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||
projectId: 'editor-project-quick-edit-generating',
|
||
title: '快速编辑生成中画布',
|
||
viewport: { x: 0, y: 0, scale: 1 },
|
||
layers: [
|
||
{
|
||
layerId: 'layer-quick-edit-generating-source',
|
||
resourceId: 'resource-quick-edit-generating-source',
|
||
title: '快速编辑源图',
|
||
src: 'data:image/png;base64,cXVpY2stZWRpdC1nZW5lcmF0aW5n',
|
||
x: 120,
|
||
y: 140,
|
||
width: 320,
|
||
height: 240,
|
||
originalWidth: 1024,
|
||
originalHeight: 768,
|
||
zIndex: 2,
|
||
sourceType: 'uploaded',
|
||
model: 'gpt-image-2',
|
||
},
|
||
],
|
||
resources: [],
|
||
updatedAt: '2026-06-16T00:00:00.000Z',
|
||
});
|
||
generateEditorImageMock.mockReturnValueOnce(new Promise(() => undefined));
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
const sourceImage = await screen.findByAltText('画布图片:快速编辑源图');
|
||
const sourceLayer = sourceImage.closest('button')!;
|
||
fireEvent.pointerDown(sourceLayer, {
|
||
button: 0,
|
||
pointerId: 172,
|
||
clientX: 180,
|
||
clientY: 180,
|
||
});
|
||
fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
|
||
pointerId: 172,
|
||
clientX: 180,
|
||
clientY: 180,
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));
|
||
const quickPanel = screen.getByRole('dialog', { name: '快速编辑图片' });
|
||
fireEvent.change(within(quickPanel).getByLabelText('快速编辑提示词'), {
|
||
target: { value: '加一层暖光' },
|
||
});
|
||
fireEvent.click(within(quickPanel).getByRole('button', { name: '生成' }));
|
||
|
||
expect(screen.queryByRole('dialog', { name: '快速编辑图片' })).toBeNull();
|
||
expect(screen.getByAltText('画布图片:快速编辑源图')).toBeTruthy();
|
||
expect(sourceLayer.className).toContain(
|
||
'image-canvas-editor__layer--generating',
|
||
);
|
||
expect(within(sourceLayer).getByRole('status').textContent).toContain(
|
||
'生成中',
|
||
);
|
||
});
|
||
});
|