Files
Genarrative/src/components/image-editor/ImageCanvasEditorView.test.tsx
kdletters d8b935317d 拆分编辑器前端画布视图
抽出素材栏、生成器、舞台工具栏和画布世界视图

补充各拆分视图的聚焦测试

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

4818 lines
162 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* @vitest-environment jsdom */
import {
act,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import JSZip from 'jszip';
import type { ContextType } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiClientError } from '../../services/apiClient';
import { AuthUiContext } from '../auth/AuthUiContext';
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 updateEditorAssetMock = vi.hoisted(() => vi.fn());
const updateEditorAssetFolderMock = vi.hoisted(() => vi.fn());
const deleteEditorAssetFolderMock = vi.hoisted(() => vi.fn());
const deleteEditorAssetMock = vi.hoisted(() => vi.fn());
const loadEditorAssetLibraryMock = vi.hoisted(() => vi.fn());
const loadEditorProjectMock = vi.hoisted(() => vi.fn());
const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn());
const renameEditorProjectMock = vi.hoisted(() => vi.fn());
const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn());
type AuthValue = NonNullable<ContextType<typeof AuthUiContext>>;
function createAuthValue(overrides: Partial<AuthValue> = {}): AuthValue {
return {
user: null,
canAccessProtectedData: false,
openLoginModal: vi.fn(),
requireAuth: vi.fn((action: () => void) => action()),
openSettingsModal: vi.fn(),
openAccountModal: vi.fn(),
setCurrentUser: vi.fn(),
logout: vi.fn(),
musicVolume: 0.5,
setMusicVolume: vi.fn(),
platformTheme: 'light',
setPlatformTheme: vi.fn(),
isHydratingSettings: false,
isPersistingSettings: false,
settingsError: null,
...overrides,
};
}
const defaultEditorProjectResources = [
{
resourceId: 'resource-puzzle',
projectId: 'editor-project-default',
imageSrc: '/creation-type-references/puzzle.webp',
width: 640,
height: 640,
sourceType: 'uploaded',
},
{
resourceId: 'resource-big-fish',
projectId: 'editor-project-default',
imageSrc: '/creation-type-references/big-fish.webp',
width: 720,
height: 405,
sourceType: 'uploaded',
},
];
const defaultEditorProjectLayers = [
{
layerId: 'layer-puzzle',
resourceId: 'resource-puzzle',
title: '拼图素材',
x: 470,
y: 300,
width: 640,
height: 640,
originalWidth: 640,
originalHeight: 640,
zIndex: 1,
sourceType: 'uploaded',
},
{
layerId: 'layer-big-fish',
resourceId: 'resource-big-fish',
title: '大鱼素材',
x: 930,
y: 360,
width: 720,
height: 405,
originalWidth: 720,
originalHeight: 405,
zIndex: 2,
sourceType: 'uploaded',
},
];
const defaultEditorAssetLibraryAssets = [
{
assetId: 'asset-puzzle',
folderId: 'project',
label: '拼图素材',
imageSrc: '/creation-type-references/puzzle.webp',
width: 640,
height: 640,
sourceType: 'uploaded',
},
{
assetId: 'asset-match3d',
folderId: 'project',
label: '抓大鹅素材',
imageSrc: '/creation-type-references/match3d.webp',
width: 640,
height: 640,
sourceType: 'uploaded',
},
{
assetId: 'asset-big-fish',
folderId: 'project',
label: '大鱼素材',
imageSrc: '/creation-type-references/big-fish.webp',
width: 720,
height: 405,
sourceType: 'uploaded',
},
{
assetId: 'asset-bark-battle',
folderId: 'project',
label: '声浪素材',
imageSrc: '/creation-type-references/bark-battle.webp',
width: 640,
height: 900,
sourceType: 'uploaded',
},
{
assetId: 'asset-visual-novel',
folderId: 'project',
label: '视觉小说素材',
imageSrc: '/creation-type-references/visual-novel.webp',
width: 720,
height: 405,
sourceType: 'uploaded',
},
];
vi.mock('../../services/image-editor/editorProjectClient', async () => {
const actual = await vi.importActual<
typeof import('../../services/image-editor/editorProjectClient')
>('../../services/image-editor/editorProjectClient');
return {
...actual,
editEditorImage: editEditorImageMock,
createEditorAsset: createEditorAssetMock,
createEditorAssetFolder: createEditorAssetFolderMock,
createEditorProjectResource: createEditorProjectResourceMock,
deleteEditorAsset: deleteEditorAssetMock,
deleteEditorAssetFolder: deleteEditorAssetFolderMock,
generateEditorCharacterAnimation: generateEditorCharacterAnimationMock,
generateEditorIconSpritesheet: generateEditorIconSpritesheetMock,
generateEditorImage: generateEditorImageMock,
loadEditorAssetLibrary: loadEditorAssetLibraryMock,
loadEditorProject: loadEditorProjectMock,
loadOrCreateRecentEditorProject: loadOrCreateRecentEditorProjectMock,
renameEditorProject: renameEditorProjectMock,
saveEditorProjectLayout: saveEditorProjectLayoutMock,
updateEditorAsset: updateEditorAssetMock,
updateEditorAssetFolder: updateEditorAssetFolderMock,
};
});
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);
}
function immediateAsync<T>(value: T) {
return {
then(onFulfilled: (value: T) => unknown) {
onFulfilled(value);
return {
catch() {},
};
},
};
}
function createDataTransferStub() {
const store = new Map<string, string>();
return {
files: [],
types: [] as string[],
dropEffect: 'none',
effectAllowed: 'all',
setData(type: string, value: string) {
store.set(type, value);
if (!this.types.includes(type)) {
this.types.push(type);
}
},
getData(type: string) {
return store.get(type) ?? '';
},
};
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
async function readZipText(zip: JSZip, path: string) {
const file = zip.file(path);
expect(file).toBeTruthy();
return file!.async('string');
}
describe('ImageCanvasEditorView', () => {
beforeEach(() => {
loadOrCreateRecentEditorProjectMock.mockImplementation(() =>
immediateAsync({
projectId: 'editor-project-default',
title: '默认项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: defaultEditorProjectLayers,
resources: defaultEditorProjectResources,
updatedAt: '2026-06-12T00:00:00.000Z',
}),
);
loadEditorAssetLibraryMock.mockImplementation(() =>
immediateAsync({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: defaultEditorAssetLibraryAssets,
}),
);
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,
});
updateEditorAssetMock.mockImplementation(async (assetId, input) => ({
assetId,
folderId: input.folderId ?? 'project',
label: input.label ?? '拼图素材',
imageSrc: '/creation-type-references/puzzle.webp',
width: 640,
height: 640,
sourceType: 'uploaded',
}));
renameEditorProjectMock.mockImplementation(async (projectId, title) => ({
projectId,
title,
viewport: { x: 0, y: 0, scale: 1 },
layers: defaultEditorProjectLayers,
resources: defaultEditorProjectResources,
updatedAt: '2026-06-12T00:00:00.000Z',
}));
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();
updateEditorAssetMock.mockReset();
updateEditorAssetFolderMock.mockReset();
deleteEditorAssetFolderMock.mockReset();
deleteEditorAssetMock.mockReset();
loadEditorAssetLibraryMock.mockReset();
loadEditorProjectMock.mockReset();
loadOrCreateRecentEditorProjectMock.mockReset();
renameEditorProjectMock.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('shows the loaded project title and a topbar entry back to projects', async () => {
render(<ImageCanvasEditorView />);
expect(
await screen.findByRole('heading', { name: '默认项目' }),
).toBeTruthy();
const projectLink = screen.getByRole('link', { name: '返回项目页面' });
expect(projectLink.getAttribute('href')).toBe('/project');
expect(screen.queryByRole('heading', { name: '图片编辑器' })).toBeNull();
});
it('opens login modal when the asset library is unauthorized', async () => {
const openLoginModal = vi.fn();
loadEditorAssetLibraryMock.mockRejectedValueOnce(
new ApiClientError({
message: '未授权访问',
status: 401,
code: 'UNAUTHORIZED',
}),
);
render(
<AuthUiContext.Provider
value={createAuthValue({
user: {
id: 'user-1',
publicUserCode: 'U001',
displayName: '测试用户',
avatarUrl: null,
phoneNumberMasked: '138****0000',
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
},
canAccessProtectedData: true,
openLoginModal,
})}
>
<ImageCanvasEditorView />
</AuthUiContext.Provider>,
);
await waitFor(() => {
expect(openLoginModal).toHaveBeenCalledTimes(1);
});
});
it('opens the login modal immediately when entering the editor while logged out', async () => {
const openLoginModal = vi.fn();
render(
<AuthUiContext.Provider value={createAuthValue({ openLoginModal })}>
<ImageCanvasEditorView />
</AuthUiContext.Provider>,
);
await waitFor(() => {
expect(openLoginModal).toHaveBeenCalledTimes(1);
});
expect(typeof openLoginModal.mock.calls[0]?.[0]).toBe('function');
expect(loadEditorAssetLibraryMock).not.toHaveBeenCalled();
expect(loadEditorProjectMock).not.toHaveBeenCalled();
expect(loadOrCreateRecentEditorProjectMock).not.toHaveBeenCalled();
});
it('renames the current project from the canvas topbar', async () => {
render(<ImageCanvasEditorView />);
await screen.findByRole('heading', { name: '默认项目' });
fireEvent.click(screen.getByRole('button', { name: '编辑项目名称' }));
fireEvent.change(screen.getByLabelText('项目名称'), {
target: { value: '新画布项目' },
});
fireEvent.click(screen.getByRole('button', { name: '保存项目名称' }));
await waitFor(() => {
expect(renameEditorProjectMock).toHaveBeenCalledWith(
'editor-project-default',
'新画布项目',
);
});
expect(
await screen.findByRole('heading', { name: '新画布项目' }),
).toBeTruthy();
});
it('does not inject built-in mock assets when the persisted library is empty', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-empty',
title: '空画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
});
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [],
});
render(<ImageCanvasEditorView />);
expect(
await screen.findByRole('region', { name: '项目素材' }),
).toBeTruthy();
expect(screen.queryByRole('button', { name: '添加拼图素材' })).toBeNull();
expect(screen.queryByRole('button', { name: '添加大鱼素材' })).toBeNull();
expect(screen.queryByAltText(//u)).toBeNull();
});
it('exports valid canvas assets as a zip from the topbar with metadata', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-export',
title: '导出项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-data-a',
resourceId: 'resource-data-a',
title: '素材/A',
src: 'data:image/png;base64,YQ==',
x: 12,
y: 24,
width: 320,
height: 220,
originalWidth: 640,
originalHeight: 440,
zIndex: 1,
sourceType: 'uploaded',
sourceAssetId: 'asset-data-a',
groupId: 'group-a',
hidden: true,
locked: true,
flipX: true,
},
{
layerId: 'layer-data-a-copy',
resourceId: 'resource-data-a-copy',
title: '素材/A 副本',
src: 'data:image/png;base64,YQ==',
x: 42,
y: 54,
width: 320,
height: 220,
originalWidth: 640,
originalHeight: 440,
zIndex: 2,
sourceType: 'uploaded',
sourceAssetId: 'asset-data-a',
},
{
layerId: 'layer-generated',
resourceId: 'resource-generated',
title: '生成图',
src: '/generated-ok.png',
x: 70,
y: 80,
width: 360,
height: 360,
originalWidth: 1024,
originalHeight: 1024,
zIndex: 3,
sourceType: 'generated',
prompt: '明亮主视觉',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-1',
},
{
layerId: 'layer-failed',
resourceId: 'resource-failed',
title: '失败图',
src: '/missing.png',
x: 90,
y: 100,
width: 120,
height: 120,
originalWidth: 120,
originalHeight: 120,
zIndex: 4,
sourceType: 'generated',
},
],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
});
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [
{
assetId: 'asset-data-a',
folderId: 'project',
label: '素材/A',
imageSrc: 'data:image/png;base64,YQ==',
width: 640,
height: 440,
sourceType: 'uploaded',
},
],
});
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (url: string) => {
if (url === '/generated-ok.png') {
return new Response(new Blob(['generated'], { type: 'image/png' }));
}
return new Response(null, { status: 404 });
});
globalThis.fetch = fetchMock as typeof fetch;
const originalCreateObjectUrl = URL.createObjectURL;
const originalRevokeObjectUrl = URL.revokeObjectURL;
const originalAnchorClick = HTMLAnchorElement.prototype.click;
let exportedBlob: Blob | null = null;
let downloadName = '';
URL.createObjectURL = vi.fn((blob: Blob) => {
exportedBlob = blob;
return 'blob:editor-export';
});
URL.revokeObjectURL = vi.fn();
HTMLAnchorElement.prototype.click = vi.fn(function click(
this: HTMLAnchorElement,
) {
downloadName = this.download;
});
try {
render(<ImageCanvasEditorView />);
await screen.findByRole('heading', { name: '导出项目' });
await waitFor(() => {
expect(
(
screen.getByRole('button', {
name: '下载画布素材',
}) as HTMLButtonElement
).disabled,
).toBe(false);
});
fireEvent.click(screen.getByRole('button', { name: '下载画布素材' }));
await waitFor(() => {
expect(exportedBlob).toBeTruthy();
});
expect(downloadName).toMatch(/^--\d{8}\.zip$/u);
const zip = await JSZip.loadAsync(exportedBlob!);
expect(zip.file('导出项目-画布素材/images/001-素材 A.png')).toBeTruthy();
expect(zip.file('导出项目-画布素材/images/002-生成图.png')).toBeTruthy();
expect(zip.file('导出项目-画布素材/images/003-失败图.png')).toBeNull();
const metadata = JSON.parse(
await readZipText(zip, '导出项目-画布素材/metadata.json'),
);
expect(metadata.projectId).toBe('editor-project-export');
expect(metadata.layers).toHaveLength(4);
expect(metadata.layers[0].file).toBe('images/001-素材 A.png');
expect(metadata.layers[1].file).toBe('images/001-素材 A.png');
expect(metadata.layers[0].canvas.hidden).toBe(true);
expect(metadata.layers[0].canvas.locked).toBe(true);
expect(metadata.layers[0].canvas.flipX).toBe(true);
expect(metadata.layers[0].canvas.groupId).toBe('group-a');
expect(metadata.layers[2].sourceType).toBe('generated');
expect(metadata.layers[2].prompt).toBe('明亮主视觉');
expect(metadata.layers[3].file).toBeNull();
expect(metadata.layers[3].exportError).toContain('404');
expect(metadata.failedImages).toHaveLength(1);
expect(
await readZipText(zip, '导出项目-画布素材/manifest.txt'),
).toContain('失败素材数量1');
expect(screen.getByText('部分素材未能导出')).toBeTruthy();
} finally {
globalThis.fetch = originalFetch;
URL.createObjectURL = originalCreateObjectUrl;
URL.revokeObjectURL = originalRevokeObjectUrl;
HTMLAnchorElement.prototype.click = originalAnchorClick;
}
});
it('disables the canvas asset export entry when there are no valid layers', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-empty-export',
title: '空导出项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
});
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [],
});
render(<ImageCanvasEditorView />);
await screen.findByRole('heading', { name: '空导出项目' });
expect(
(
screen.getByRole('button', {
name: '下载画布素材',
}) as HTMLButtonElement
).disabled,
).toBe(true);
});
it('keeps only one default asset folder when the persisted library returns duplicated defaults', async () => {
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
{
folderId: 'legacy-project',
label: '旧项目素材',
sortOrder: 1,
collapsed: false,
systemDefault: true,
},
],
assets: [],
});
render(<ImageCanvasEditorView />);
expect(
await screen.findByRole('region', { name: '项目素材' }),
).toBeTruthy();
expect(screen.queryByRole('region', { name: '旧项目素材' })).toBeNull();
expect(screen.getAllByRole('button', { name: //u })).toHaveLength(1);
});
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('重命名素材拼图素材');
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('素材文件夹名称');
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('重命名文件夹角色');
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('moves an asset to another folder when dragging inside the asset library', async () => {
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
{
folderId: 'folder-role',
label: '角色',
sortOrder: 100,
collapsed: false,
systemDefault: false,
},
],
assets: [
{
assetId: 'asset-puzzle',
folderId: 'project',
label: '拼图素材',
imageSrc: '/creation-type-references/puzzle.webp',
width: 640,
height: 640,
sourceType: 'uploaded',
},
],
});
render(<ImageCanvasEditorView />);
const sourceAsset = await screen.findByRole('button', {
name: '添加拼图素材',
});
const sourceAssetRow = sourceAsset.closest(
'.image-canvas-editor__asset-row',
);
const projectFolder = screen.getByRole('region', { name: '项目素材' });
const roleFolder = screen.getByRole('region', { name: '角色' });
const dataTransfer = createDataTransferStub();
if (!sourceAssetRow) {
throw new Error('asset row should exist');
}
fireEvent.dragStart(sourceAssetRow, { dataTransfer });
fireEvent.dragOver(roleFolder, { dataTransfer });
await waitFor(() => {
expect(screen.queryByText('添加到素材')).toBeNull();
expect(roleFolder.className).toContain(
'image-canvas-editor__asset-folder--move-target',
);
});
fireEvent.drop(roleFolder, { dataTransfer });
expect(updateEditorAssetMock).toHaveBeenCalledWith('asset-puzzle', {
folderId: 'folder-role',
});
expect(
within(projectFolder).queryByRole('button', { name: '添加拼图素材' }),
).toBeNull();
expect(
within(roleFolder).getByRole('button', { name: '添加拼图素材' }),
).toBeTruthy();
expect(createEditorAssetMock).not.toHaveBeenCalled();
});
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('opens login before uploading assets while logged out and resumes after login', async () => {
const openLoginModal = vi.fn();
const authValue = createAuthValue({ openLoginModal });
const { rerender } = render(
<AuthUiContext.Provider value={authValue}>
<ImageCanvasEditorView />
</AuthUiContext.Provider>,
);
await userEvent.upload(screen.getByLabelText('上传图片文件'), [
new File(['image'], '登录后上传.png', { type: 'image/png' }),
]);
expect(openLoginModal).toHaveBeenCalled();
expect(createEditorAssetMock).not.toHaveBeenCalled();
expect(
screen.queryByRole('button', { name: '上传失败登录后上传.png' }),
).toBeNull();
const resumeUpload =
openLoginModal.mock.calls[openLoginModal.mock.calls.length - 1]?.[0];
expect(typeof resumeUpload).toBe('function');
rerender(
<AuthUiContext.Provider
value={createAuthValue({
user: {
id: 'user-1',
publicUserCode: 'U001',
displayName: '测试用户',
avatarUrl: null,
phoneNumberMasked: '138****0000',
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
},
canAccessProtectedData: true,
openLoginModal,
})}
>
<ImageCanvasEditorView />
</AuthUiContext.Provider>,
);
act(() => {
(resumeUpload as () => void)();
});
await waitFor(() => {
expect(createEditorAssetMock).toHaveBeenCalledTimes(1);
});
});
it('shows an uploading placeholder card before restoring the normal asset card', async () => {
const deferredAsset = createDeferred<{
assetId: string;
folderId: string;
label: string;
imageSrc: string;
width: number;
height: number;
sourceType: 'uploaded';
}>();
createEditorAssetMock.mockReturnValueOnce(deferredAsset.promise);
render(<ImageCanvasEditorView />);
await userEvent.upload(screen.getByLabelText('上传图片文件'), [
new File(['image'], '素材上传进度.png', { type: 'image/png' }),
]);
expect(
await screen.findByLabelText('素材素材上传进度.png上传进度'),
).toBeTruthy();
expect(
screen.getByRole('button', { name: '上传中素材上传进度.png' }),
).toBeTruthy();
deferredAsset.resolve({
assetId: 'asset-upload-progress',
folderId: 'project',
label: '素材上传进度.png',
imageSrc: 'data:image/png;base64,cHJvZ3Jlc3M=',
width: 420,
height: 315,
sourceType: 'uploaded',
});
await waitFor(() => {
expect(
screen.getByRole('button', { name: '添加素材上传进度.png' }),
).toBeTruthy();
});
expect(screen.queryByLabelText('素材素材上传进度.png上传进度')).toBeNull();
});
it('opens login when asset creation returns unauthorized during upload', async () => {
const openLoginModal = vi.fn();
createEditorAssetMock.mockRejectedValueOnce(
new ApiClientError({
message: '未授权访问',
status: 401,
code: 'UNAUTHORIZED',
}),
);
render(
<AuthUiContext.Provider
value={createAuthValue({
user: {
id: 'user-1',
publicUserCode: 'U001',
displayName: '测试用户',
avatarUrl: null,
phoneNumberMasked: '138****0000',
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
},
canAccessProtectedData: true,
openLoginModal,
})}
>
<ImageCanvasEditorView />
</AuthUiContext.Provider>,
);
await userEvent.upload(screen.getByLabelText('上传图片文件'), [
new File(['image'], '过期登录.png', { type: 'image/png' }),
]);
await waitFor(() => {
expect(openLoginModal).toHaveBeenCalledTimes(1);
});
expect(screen.getByText('请先登录')).toBeTruthy();
});
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('removes canvas layers linked to deleted assets', 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 user.click(
await screen.findByRole('button', { name: '添加账号素材A' }),
);
await user.click(screen.getByRole('button', { name: '添加账号素材B' }));
expect(screen.getByAltText('画布图片账号素材A')).toBeTruthy();
expect(screen.getByAltText('画布图片账号素材B')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '素材选择模式' }));
await user.click(
within(screen.getByRole('toolbar', { name: '素材批量操作' })).getByRole(
'button',
{ name: '全选' },
),
);
await waitFor(() => {
expect(
within(screen.getByRole('toolbar', { name: '素材批量操作' })).getByText(
/ 2/u,
),
).toBeTruthy();
});
await user.click(
within(screen.getByRole('toolbar', { name: '素材批量操作' })).getByRole(
'button',
{ name: '删除' },
),
);
await waitFor(() => {
expect(screen.queryByAltText('画布图片账号素材A')).toBeNull();
expect(screen.queryByAltText('画布图片账号素材B')).toBeNull();
});
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-a');
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-b');
});
it('saves a library asset layer right after creating its canvas resource', async () => {
const user = userEvent.setup();
createEditorProjectResourceMock.mockResolvedValueOnce({
resourceId: 'resource-added-asset-a',
projectId: 'editor-project-default',
imageSrc: 'data:image/png;base64,YQ==',
width: 320,
height: 240,
sourceType: 'uploaded',
});
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-default',
title: '空画布项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
});
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',
},
],
});
render(<ImageCanvasEditorView />);
await user.click(
await screen.findByRole('button', { name: '添加账号素材A' }),
);
expect(await screen.findByAltText('画布图片账号素材A')).toBeTruthy();
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
layers: expect.arrayContaining([
expect.objectContaining({
title: '账号素材A',
resourceId: 'resource-added-asset-a',
sourceAssetId: 'asset-a',
}),
]),
}),
);
});
});
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('heading', { name: '素材' })).toBeTruthy();
expect(
screen.getByRole('button', { name: '打开素材' }).getAttribute(
'aria-pressed',
),
).toBe('true');
expect(
screen
.getByRole('button', { name: '选择测试上传.png' })
.className.includes('image-canvas-editor__layer--selected'),
).toBe(true);
});
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('adds an asset library image to the canvas by dragging it onto the viewport', async () => {
render(<ImageCanvasEditorView />);
const sourceAsset = await screen.findByRole('button', {
name: '添加抓大鹅素材',
});
const sourceAssetRow = sourceAsset.closest(
'.image-canvas-editor__asset-row',
);
const viewport = screen.getByLabelText('画布工作区');
const dataTransfer = createDataTransferStub();
if (!sourceAssetRow) {
throw new Error('asset row should exist');
}
fireEvent.dragStart(sourceAssetRow, { dataTransfer });
fireEvent.dragOver(viewport, {
clientX: 520,
clientY: 300,
dataTransfer,
});
await waitFor(() => {
expect(screen.getByText('添加到画布')).toBeTruthy();
});
fireEvent.drop(viewport, {
clientX: 520,
clientY: 300,
dataTransfer,
});
await waitFor(() => {
expect(screen.queryByText('添加到画布')).toBeNull();
});
expect(screen.getByAltText('画布图片:抓大鹅素材')).toBeTruthy();
expect(screen.getByRole('button', { name: '选择抓大鹅素材' })).toBeTruthy();
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
imageSrc: '/creation-type-references/match3d.webp',
sourceType: 'uploaded',
}),
);
expect(createEditorAssetMock).not.toHaveBeenCalled();
});
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('shows the blank canvas context menu with paste disabled, zoom, and fit all', () => {
render(<ImageCanvasEditorView />);
const viewport = screen.getByLabelText('画布工作区');
fireEvent.contextMenu(viewport, {
clientX: 320,
clientY: 220,
});
const menu = screen.getByRole('menu', { name: '画布右键菜单' });
expect(
(
within(menu).getByRole('menuitem', {
name: '粘贴',
}) as HTMLButtonElement
).disabled,
).toBe(true);
expect(within(menu).getByRole('menuitem', { name: '放大' })).toBeTruthy();
expect(
within(menu).getByRole('menuitem', { name: '显示画布所有元素' }),
).toBeTruthy();
});
it('keeps right-clicking a canvas layer from falling through to blank pan menu handling', () => {
render(<ImageCanvasEditorView />);
const layerButton = screen
.getByAltText('画布图片:拼图素材')
.closest('button')!;
const rightPointerDown = new MouseEvent('pointerdown', {
bubbles: true,
cancelable: true,
button: 2,
clientX: 510,
clientY: 330,
});
const wasNotCanceled = layerButton.dispatchEvent(rightPointerDown);
expect(wasNotCanceled).toBe(true);
expect(rightPointerDown.defaultPrevented).toBe(false);
fireEvent.contextMenu(layerButton, {
clientX: 510,
clientY: 330,
});
expect(screen.getByRole('menu', { name: '图片功能面板' })).toBeTruthy();
expect(screen.getByRole('menuitem', { name: '创建副本' })).toBeTruthy();
});
it('copies, cuts, and pastes layers from the context menus', () => {
render(<ImageCanvasEditorView />);
fireEvent.contextMenu(
screen.getByAltText('画布图片:拼图素材').closest('button')!,
{
clientX: 510,
clientY: 330,
},
);
fireEvent.click(screen.getByRole('menuitem', { name: '复制' }));
fireEvent.contextMenu(screen.getByLabelText('画布工作区'), {
clientX: 360,
clientY: 240,
});
const copyPasteMenu = screen.getByRole('menu', { name: '画布右键菜单' });
expect(
(
within(copyPasteMenu).getByRole('menuitem', {
name: '粘贴',
}) as HTMLButtonElement
).disabled,
).toBe(false);
fireEvent.click(
within(copyPasteMenu).getByRole('menuitem', { name: '粘贴' }),
);
expect(screen.getAllByAltText(//u)).toHaveLength(2);
fireEvent.contextMenu(
screen.getByAltText('画布图片:大鱼素材').closest('button')!,
{
clientX: 950,
clientY: 380,
},
);
fireEvent.click(screen.getByRole('menuitem', { name: '剪切' }));
expect(screen.queryByAltText('画布图片:大鱼素材')).toBeNull();
fireEvent.contextMenu(screen.getByLabelText('画布工作区'), {
clientX: 420,
clientY: 260,
});
fireEvent.click(screen.getByRole('menuitem', { name: '粘贴' }));
expect(screen.getByAltText('画布图片:大鱼素材')).toBeTruthy();
});
it('handles layer context menu duplicate, ordering, hide, lock, flip, group, ungroup, and delete', async () => {
render(<ImageCanvasEditorView />);
const firstLayer = screen
.getByAltText('画布图片:拼图素材')
.closest('button')!;
fireEvent.contextMenu(firstLayer, { clientX: 510, clientY: 330 });
fireEvent.click(screen.getByRole('menuitem', { name: '创建副本' }));
expect(screen.getAllByAltText(//u)).toHaveLength(2);
const copiedLayer = screen
.getAllByAltText(//u)[1]!
.closest('button')!;
fireEvent.contextMenu(copiedLayer, { clientX: 540, clientY: 360 });
fireEvent.click(screen.getByRole('menuitem', { name: '水平翻转' }));
expect(
(screen.getAllByAltText(//u)[1] as HTMLElement).style
.transform,
).toBe('scale(-1, 1)');
fireEvent.contextMenu(copiedLayer, { clientX: 540, clientY: 360 });
fireEvent.click(screen.getByRole('menuitem', { name: '锁定' }));
await waitFor(() => {
expect(copiedLayer.className).toContain(
'image-canvas-editor__layer--locked',
);
});
fireEvent.contextMenu(copiedLayer, { clientX: 540, clientY: 360 });
fireEvent.click(screen.getByRole('menuitem', { name: '隐藏' }));
expect(screen.getAllByAltText(//u)).toHaveLength(1);
fireEvent.click(screen.getByRole('button', { name: '打开图层' }));
expect(screen.getByText(//u)).toBeTruthy();
fireEvent.contextMenu(
screen.getByText(//u).closest('.image-canvas-editor__layer-row')!,
{
clientX: 80,
clientY: 220,
},
);
fireEvent.click(screen.getByRole('menuitem', { name: '显示' }));
expect(screen.getAllByAltText(//u)).toHaveLength(2);
const bigFishLayer = screen
.getByAltText('画布图片:大鱼素材')
.closest('button')!;
fireEvent.contextMenu(bigFishLayer, { clientX: 950, clientY: 380 });
fireEvent.click(screen.getByRole('menuitem', { name: '置于顶层' }));
expect(Number.parseInt(bigFishLayer.style.zIndex, 10)).toBeGreaterThan(2);
fireEvent.contextMenu(bigFishLayer, { clientX: 950, clientY: 380 });
fireEvent.click(screen.getByRole('menuitem', { name: '下移一层' }));
expect(Number.parseInt(bigFishLayer.style.zIndex, 10)).toBeGreaterThan(0);
fireEvent.keyDown(window, { key: 'Shift', code: 'ShiftLeft' });
fireEvent.pointerDown(
screen.getByAltText('画布图片:拼图素材').closest('button')!,
{
button: 0,
pointerId: 181,
clientX: 520,
clientY: 380,
shiftKey: true,
},
);
fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
pointerId: 181,
clientX: 520,
clientY: 380,
});
fireEvent.keyUp(window, { key: 'Shift', code: 'ShiftLeft' });
fireEvent.contextMenu(bigFishLayer, { clientX: 950, clientY: 380 });
fireEvent.click(screen.getByRole('menuitem', { name: '创建组' }));
await waitFor(() => {
expect(screen.getAllByText(//u).length).toBeGreaterThan(0);
});
fireEvent.contextMenu(bigFishLayer, { clientX: 950, clientY: 380 });
fireEvent.click(screen.getByRole('menuitem', { name: '解除组' }));
await waitFor(() => {
expect(screen.queryByText(//u)).toBeNull();
});
fireEvent.contextMenu(bigFishLayer, { clientX: 950, clientY: 380 });
fireEvent.click(screen.getByRole('menuitem', { name: '删除' }));
expect(screen.queryByAltText('画布图片:大鱼素材')).toBeNull();
});
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: '当前缩放比例 100%' }).className,
).toContain('platform-inline-option-button');
fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 100%' }));
fireEvent.click(screen.getByRole('menuitem', { name: '放大' }));
expect(
screen.getByRole('button', { name: '当前缩放比例 116%' }),
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' }));
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
expect(
screen.getByRole('complementary', { name: '图片资源栏' }),
).toBeTruthy();
});
it('saves canvas layout without embedding image payloads in layer snapshots', async () => {
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [
{
assetId: 'asset-data-heavy',
folderId: 'project',
label: '大图素材',
imageSrc: 'data:image/png;base64,'.concat('a'.repeat(4000)),
width: 1024,
height: 768,
sourceType: 'uploaded',
},
],
});
render(<ImageCanvasEditorView />);
await screen.findByRole('button', { name: '添加大图素材' });
fireEvent.click(screen.getByRole('button', { name: '添加大图素材' }));
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalled();
});
const lastLayout = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1];
expect(lastLayout.layers).toEqual(
expect.arrayContaining([
expect.not.objectContaining({
src: expect.stringMatching(/^data:image/u),
}),
]),
);
expect(lastLayout.layers).toEqual(
expect.arrayContaining([
expect.objectContaining({
sourceAssetId: 'asset-data-heavy',
}),
]),
);
});
it('offers Lovart-style zoom menu commands', async () => {
render(<ImageCanvasEditorView />);
fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 100%' }));
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 settings panel', () => {
render(<ImageCanvasEditorView />);
const viewport = screen.getByLabelText('画布工作区');
const panelToolbar = screen.getByRole('toolbar', { name: '画布面板入口' });
const backgroundButton = within(panelToolbar).getByRole('button', {
name: '画布背景色',
});
expect(screen.getByRole('button', { name: '画布小地图' })).toBeTruthy();
expect(backgroundButton.className).toContain('platform-icon-button');
expect(
within(panelToolbar).getByRole('button', { name: '切换小地图' }),
).toBeTruthy();
fireEvent.click(backgroundButton);
const settingsPanel = screen.getByRole('dialog', {
name: '画布背景设置',
});
expect(within(settingsPanel).getByText('画布背景')).toBeTruthy();
expect(within(settingsPanel).getByLabelText('画布背景色相')).toBeTruthy();
expect(
within(settingsPanel).getByLabelText('画布背景十六进制颜色'),
).toBeTruthy();
fireEvent.click(
within(settingsPanel).getByRole('button', { name: '暖灰' }),
);
expect((viewport as HTMLElement).style.backgroundColor).toBe(
'rgb(243, 240, 234)',
);
fireEvent.change(within(settingsPanel).getByLabelText('自定义画布背景色'), {
target: { value: '#ffffff' },
});
expect((viewport as HTMLElement).style.backgroundColor).toBe(
'rgb(255, 255, 255)',
);
const hexInput =
within(settingsPanel).getByLabelText('画布背景十六进制颜色');
fireEvent.change(hexInput, { target: { value: '#abc' } });
expect((hexInput as HTMLInputElement).value).toBe('#aabbcc');
expect((viewport as HTMLElement).style.backgroundColor).toBe(
'rgb(170, 187, 204)',
);
fireEvent.change(hexInput, { target: { value: '#not-a-color' } });
expect((hexInput as HTMLInputElement).value).toBe('#not-a-color');
expect((viewport as HTMLElement).style.backgroundColor).toBe(
'rgb(170, 187, 204)',
);
fireEvent.click(
within(settingsPanel).getByRole('button', { name: '恢复默认' }),
);
expect((viewport as HTMLElement).style.backgroundColor).toBe(
'rgb(248, 250, 252)',
);
fireEvent.keyDown(window, { key: 'Escape' });
expect(screen.queryByRole('dialog', { name: '画布背景设置' })).toBeNull();
fireEvent.click(
within(panelToolbar).getByRole('button', { name: '切换小地图' }),
);
expect(screen.queryByRole('button', { name: '画布小地图' })).toBeNull();
});
it('resets the canvas view without forwarding the click event to fit layers', () => {
render(<ImageCanvasEditorView />);
expect(() => {
fireEvent.click(screen.getByRole('button', { name: '重置画布视图' }));
}).not.toThrow();
});
it('uses normal wheel for vertical canvas scroll and ctrl wheel for zoom', () => {
render(<ImageCanvasEditorView />);
const viewport = screen.getByLabelText('画布工作区');
expect(
screen.getByRole('button', { name: '当前缩放比例 100%' }),
).toBeTruthy();
fireEvent.wheel(viewport, { deltaY: 120, clientX: 400, clientY: 280 });
expect(
screen.getByRole('button', { name: '当前缩放比例 100%' }),
).toBeTruthy();
fireEvent.wheel(viewport, {
deltaY: -120,
ctrlKey: true,
clientX: 400,
clientY: 280,
});
expect(
screen.getByRole('button', { name: '当前缩放比例 110%' }),
).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('keeps minimap drag direction stable after pausing and reversing', () => {
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: () => ({}),
});
const world = screen
.getByLabelText('画布工作区')
.querySelector('.image-canvas-editor__world') as HTMLElement;
const readTranslateX = () => {
const match = /translate\(([-\d.]+)px,/u.exec(world.style.transform);
return match ? Number(match[1]) : 0;
};
dispatchPointerEvent(minimap, 'pointerdown', {
button: 0,
pointerId: 72,
clientX: 60,
clientY: 42,
});
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', {
button: 0,
pointerId: 72,
clientX: 120,
clientY: 42,
});
const translateAfterRightDrag = readTranslateX();
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', {
button: 0,
pointerId: 72,
clientX: 90,
clientY: 42,
});
expect(readTranslateX()).toBeGreaterThan(translateAfterRightDrag);
});
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: 'gemini-3.1-flash-image-preview',
size: '2048x1152',
prompt: expect.stringContaining('玩法设计:平台跳跃玩法'),
});
const prompt = generateEditorImageMock.mock.calls[0]?.[0]?.prompt ?? '';
expect(prompt).toContain('生成2D 角色美术视觉规范设定图');
expect(prompt).toContain('美术风格:低多边形卡通');
expect(prompt).toContain('头身比4');
expect(prompt).toContain('视角要求:左向三分之二侧身站姿');
await waitFor(() => {
expect(screen.getByAltText(/画布图片:角色形象规范/)).toBeTruthy();
});
expect(screen.getByText('规范')).toBeTruthy();
await waitFor(() => {
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
sourceType: 'generated',
width: 2048,
height: 1152,
}),
);
});
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
layers: expect.arrayContaining([
expect.objectContaining({
title: expect.stringMatching(//u),
assetKind: 'spec',
}),
]),
}),
);
});
});
it('shows visible titles for character spec, icon spec, and icon spritesheet generation fields', async () => {
render(<ImageCanvasEditorView />);
await screen.findByAltText('画布图片:拼图素材');
fireEvent.click(
within(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).getByRole(
'button',
{ name: '生成规范' },
),
);
fireEvent.click(screen.getByRole('menuitem', { name: '角色形象规范' }));
const characterSpecDialog = screen.getByRole('dialog', {
name: '生成规范',
});
['玩法设定', '美术风格', '头身比', '角色视角'].forEach((title) => {
expect(within(characterSpecDialog).getByText(title)).toBeTruthy();
});
fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
const iconSpritesheetPanel = screen.getByRole('dialog', {
name: '生成图标素材',
});
expect(
within(iconSpritesheetPanel).getByRole('button', {
name: '图标素材规范',
}),
).toBeTruthy();
expect(within(iconSpritesheetPanel).getByText('素材描述')).toBeTruthy();
expect(within(iconSpritesheetPanel).getByText('素材描述 1')).toBeTruthy();
expect(within(iconSpritesheetPanel).getByText('素材描述 6')).toBeTruthy();
expect(within(iconSpritesheetPanel).getByText('模型')).toBeTruthy();
fireEvent.click(
within(iconSpritesheetPanel).getByRole('button', {
name: '图标素材规范',
}),
);
fireEvent.click(screen.getByRole('menuitem', { name: '新建图标素材规范' }));
const iconSpecDialog = screen.getByRole('dialog', { name: '生成规范' });
['玩法设定', '美术风格'].forEach((title) => {
expect(within(iconSpecDialog).getByText(title)).toBeTruthy();
});
});
it('defaults character and icon generation to nanobanana2 model options', async () => {
render(<ImageCanvasEditorView />);
await screen.findByAltText('画布图片:拼图素材');
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
const characterPanel = screen.getByRole('dialog', {
name: '生成角色形象',
});
expect(within(characterPanel).getByText('画面比例')).toBeTruthy();
expect(within(characterPanel).getByText('大小尺寸')).toBeTruthy();
expect(within(characterPanel).getByText('模型')).toBeTruthy();
expect(
within(characterPanel).getByRole('button', { name: '1:1' }),
).toBeTruthy();
expect(
within(characterPanel).getByRole('button', { name: '1K' }),
).toBeTruthy();
expect(
within(characterPanel).getByRole('button', { name: 'nanobanana2' }),
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
expect(within(iconPanel).getByText('画面比例')).toBeTruthy();
expect(within(iconPanel).getByText('大小尺寸')).toBeTruthy();
expect(within(iconPanel).getByText('模型')).toBeTruthy();
expect(
within(iconPanel).getByRole('button', { name: 'nanobanana2' }),
).toBeTruthy();
});
it('submits character generation with default model and dimension options', async () => {
generateEditorImageMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,character-model-options',
width: 1024,
height: 1536,
sourceType: 'generated',
prompt: '高个子游侠',
actualPrompt: '高个子游侠',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'character-model-options-1',
});
render(<ImageCanvasEditorView />);
await screen.findByAltText('画布图片:拼图素材');
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
const characterPanel = screen.getByRole('dialog', {
name: '生成角色形象',
});
fireEvent.change(within(characterPanel).getByLabelText('角色设定'), {
target: { value: '高个子游侠' },
});
fireEvent.click(
within(characterPanel).getByRole('button', { name: '生成' }),
);
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'character',
prompt: '高个子游侠',
model: 'gemini-3.1-flash-image-preview',
aspectRatio: '1:1',
imageSize: '1K',
}),
);
});
});
it('remembers the last selected image model for character and icon generation', async () => {
generateEditorImageMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,character-gpt-model',
width: 1024,
height: 1024,
sourceType: 'generated',
prompt: '蓝衣剑士',
actualPrompt: '蓝衣剑士',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'character-gpt-model-1',
});
generateEditorIconSpritesheetMock.mockResolvedValueOnce({
spritesheetImageSrc: 'data:image/png;base64,sheet-gpt-model',
spritesheetWidth: 1024,
spritesheetHeight: 1024,
iconImageSrcs: [
{
name: '返回按钮',
imageSrc: 'data:image/png;base64,back',
width: 128,
height: 128,
},
],
prompt: '图标 prompt',
actualPrompt: '图标 prompt',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'icon-gpt-model-1',
});
render(<ImageCanvasEditorView />);
await screen.findByAltText('画布图片:拼图素材');
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
const characterPanel = screen.getByRole('dialog', {
name: '生成角色形象',
});
fireEvent.click(
within(characterPanel).getByRole('button', { name: 'gpt-image-2' }),
);
fireEvent.click(within(characterPanel).getByRole('button', { name: '2:3' }));
fireEvent.click(within(characterPanel).getByRole('button', { name: '2K' }));
fireEvent.change(within(characterPanel).getByLabelText('角色设定'), {
target: { value: '蓝衣剑士' },
});
fireEvent.click(
within(characterPanel).getByRole('button', { name: '生成' }),
);
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'character',
prompt: '蓝衣剑士',
model: 'gpt-image-2',
aspectRatio: '2:3',
imageSize: '2K',
}),
);
});
fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
expect(
within(iconPanel).getByRole('button', { name: 'gpt-image-2' }),
).toBeTruthy();
fireEvent.click(
within(iconPanel).getByRole('button', { name: '图标素材规范' }),
);
fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' }));
await userEvent.upload(
screen.getByLabelText('上传图片文件'),
new File(['icon-spec'], '图标规范.png', { type: 'image/png' }),
);
fireEvent.click(
within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole(
'button',
{ name: '生成' },
),
);
await waitFor(() => {
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
expect.objectContaining({
model: 'gpt-image-2',
aspectRatio: '1:1',
imageSize: '1K',
}),
);
});
});
it('keeps the bottom AI toolbar visible while generation panels are open', () => {
render(<ImageCanvasEditorView />);
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy();
expect(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).toBeTruthy();
});
it('keeps existing generation placeholders when another bottom generation object is created', async () => {
render(<ImageCanvasEditorView />);
await act(async () => {});
const bottomToolbar = screen.getByRole('toolbar', {
name: 'AI画布工具栏',
});
fireEvent.click(
within(bottomToolbar).getByRole('button', { name: '生成规范' }),
);
fireEvent.click(screen.getByRole('menuitem', { name: '角色形象规范' }));
expect(screen.getByLabelText('规范生成占位图')).toBeTruthy();
expect(screen.getByRole('dialog', { name: '生成规范' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
expect(screen.getByLabelText('规范生成占位图')).toBeTruthy();
expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy();
fireEvent.pointerDown(screen.getByLabelText('规范生成占位图'), {
button: 0,
pointerId: 1701,
clientX: 180,
clientY: 180,
});
expect(screen.getByRole('dialog', { name: '生成规范' })).toBeTruthy();
expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
});
it('keeps archived generation logic using the latest placeholder when another object is active', async () => {
let resolveGeneration!: (value: unknown) => void;
generateEditorImageMock.mockReturnValueOnce(
new Promise((resolve) => {
resolveGeneration = resolve;
}),
);
render(<ImageCanvasEditorView />);
await waitFor(() => {
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
});
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '生成中切换后仍保留位置' },
});
fireEvent.click(screen.getByRole('button', { name: '生成' }));
const originalFrame = screen.getByLabelText('图像生成占位图');
const originalLeft = Number.parseFloat(
(originalFrame as HTMLElement).style.left,
);
const originalTop = Number.parseFloat(
(originalFrame as HTMLElement).style.top,
);
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
const characterFrame = screen.getByLabelText('角色生成占位图');
expect(characterFrame).toBeTruthy();
dispatchPointerEvent(
screen.getByLabelText('图像生成占位图'),
'pointerdown',
{
button: 0,
pointerId: 1702,
clientX: 500,
clientY: 260,
},
);
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', {
pointerId: 1702,
clientX: 650,
clientY: 390,
});
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointerup', {
pointerId: 1702,
clientX: 650,
clientY: 390,
});
const movedFrame = screen.getByLabelText('图像生成占位图');
const movedLeft = Number.parseFloat((movedFrame as HTMLElement).style.left);
const movedTop = Number.parseFloat((movedFrame as HTMLElement).style.top);
expect(movedLeft).toBeGreaterThan(originalLeft);
expect(movedTop).toBeGreaterThan(originalTop);
dispatchPointerEvent(characterFrame, 'pointerdown', {
button: 0,
pointerId: 1703,
clientX: 360,
clientY: 240,
});
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointerup', {
pointerId: 1703,
clientX: 360,
clientY: 240,
});
expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy();
await act(async () => {
resolveGeneration({
imageSrc: 'data:image/png;base64,YXJjaGl2ZWQtbG9naWM=',
width: 1024,
height: 1024,
sourceType: 'generated',
prompt: '生成中切换后仍保留位置',
actualPrompt: '生成中切换后仍保留位置',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'editor-archived-generation-1',
});
});
await waitFor(() => {
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
});
const generatedLayer = screen
.getByAltText(/画布图片:生成图片/)
.closest('button') as HTMLElement;
const expectedLayerLeft =
movedLeft +
Number.parseFloat((movedFrame as HTMLElement).style.width) / 2 -
512;
const expectedLayerTop =
movedTop +
Number.parseFloat((movedFrame as HTMLElement).style.height) / 2 -
512;
expect(Number.parseFloat(generatedLayer.style.left)).toBeCloseTo(
expectedLayerLeft,
1,
);
expect(Number.parseFloat(generatedLayer.style.top)).toBeCloseTo(
expectedLayerTop,
1,
);
expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
});
it('renders editor popup menus outside clipped local containers', () => {
render(<ImageCanvasEditorView />);
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
fireEvent.click(
within(bottomToolbar).getByRole('button', { name: '生成规范' }),
);
const specMenu = screen.getByRole('menu', { name: '生成规范类型' });
expect(bottomToolbar.contains(specMenu)).toBe(false);
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
const characterPanel = screen.getByRole('dialog', { name: '生成角色形象' });
fireEvent.click(
within(characterPanel).getByRole('button', { name: '角色形象规范' }),
);
const referenceRow = characterPanel.querySelector(
'.image-canvas-editor__character-reference-row',
);
const sourceMenu = screen.getByRole('menu', { name: '角色形象规范来源' });
expect(referenceRow?.contains(sourceMenu)).toBe(false);
expect(sourceMenu.className).toContain('platform-floating-menu--top-start');
fireEvent.click(
within(characterPanel).getByRole('button', { name: '上传常规参考图' }),
);
const regularReferenceMenu = screen.getByRole('menu', {
name: '常规参考图来源',
});
expect(referenceRow?.contains(regularReferenceMenu)).toBe(false);
expect(regularReferenceMenu.className).toContain(
'platform-floating-menu--top-start',
);
});
it('uses Lovart-style reference tiles in the character generation panel', () => {
render(<ImageCanvasEditorView />);
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
const characterPanel = screen.getByRole('dialog', { name: '生成角色形象' });
const specTile = within(characterPanel).getByRole('button', {
name: '角色形象规范',
});
const uploadTile = within(characterPanel).getByRole('button', {
name: '上传常规参考图',
});
expect(specTile.className).toContain('image-canvas-editor__reference-tile');
expect(uploadTile.className).toContain(
'image-canvas-editor__reference-tile',
);
expect(
specTile.querySelector('.image-canvas-editor__reference-tile-visual'),
).toBeTruthy();
expect(
uploadTile.querySelector('.image-canvas-editor__reference-tile-visual'),
).toBeTruthy();
});
it('expands the icon panel width as new description items are added', async () => {
render(<ImageCanvasEditorView />);
await waitFor(() => {
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
});
fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
expect(Number.parseFloat(iconPanel.style.width)).toBeCloseTo(52.8, 1);
expect(
iconPanel.querySelector('.image-canvas-editor__icon-description-list'),
).toBeTruthy();
expect(
iconPanel.querySelector('.image-canvas-editor__icon-description-card'),
).toBeTruthy();
expect(
iconPanel.querySelector('.image-canvas-editor__icon-spec-card'),
).toBeTruthy();
fireEvent.click(
within(iconPanel).getByRole('button', { name: '添加素材描述' }),
);
expect(Number.parseFloat(iconPanel.style.width)).toBeCloseTo(61.2, 1);
expect(within(iconPanel).getAllByRole('textbox')).toHaveLength(7);
});
it('hides the active generation panel and clears image selection after canvas background focus', async () => {
generateEditorImageMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,Zm9jdXMtY2xlYXI=',
width: 1024,
height: 1024,
sourceType: 'generated',
prompt: '发光蘑菇角色',
actualPrompt: '发光蘑菇角色',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'editor-focus-clear-1',
});
render(<ImageCanvasEditorView />);
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '发光蘑菇角色' },
});
fireEvent.click(screen.getByRole('button', { name: '生成' }));
const generatedImage = await screen.findByAltText(//u);
const generatedLayerButton = generatedImage.closest('button')!;
expect(generatedLayerButton.className).toContain(
'image-canvas-editor__layer--selected',
);
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
fireEvent.pointerDown(screen.getByLabelText('画布工作区'), {
button: 0,
pointerId: 261,
clientX: 40,
clientY: 40,
});
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
expect(generatedLayerButton.className).not.toContain(
'image-canvas-editor__layer--selected',
);
});
it('hides a newly created placeholder panel after canvas background focus', () => {
render(<ImageCanvasEditorView />);
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy();
expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
fireEvent.pointerDown(screen.getByLabelText('画布工作区'), {
button: 0,
pointerId: 262,
clientX: 40,
clientY: 40,
});
expect(screen.queryByRole('dialog', { name: '生成角色形象' })).toBeNull();
expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
});
it('builds UI spec prompts from two fields and uses 2K landscape generation', async () => {
generateEditorImageMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,c3BlYy11aQ==',
width: 2048,
height: 1152,
sourceType: 'generated',
prompt: 'UI规范提示词',
actualPrompt: 'UI规范提示词',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'editor-spec-ui-1',
});
render(<ImageCanvasEditorView />);
fireEvent.click(
within(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).getByRole(
'button',
{ name: '生成规范' },
),
);
fireEvent.click(screen.getByRole('menuitem', { name: 'UI素材规范' }));
expect((screen.getByLabelText('玩法设定') as HTMLInputElement).value).toBe(
'抓娃娃题材的抓大鹅玩法',
);
expect((screen.getByLabelText('美术风格') as HTMLInputElement).value).toBe(
'毛茸茸',
);
fireEvent.change(screen.getByLabelText('玩法设定'), {
target: { value: '消除类派对玩法' },
});
fireEvent.change(screen.getByLabelText('美术风格'), {
target: { value: '糖果玻璃拟物' },
});
fireEvent.click(
within(screen.getByRole('dialog', { name: '生成规范' })).getByRole(
'button',
{ name: '提交生成规范' },
),
);
expect(generateEditorImageMock).toHaveBeenCalledWith({
kind: 'spec',
model: 'gemini-3.1-flash-image-preview',
size: '2048x1152',
prompt: expect.stringContaining('生成一张完整游戏UI规范汇总设定展板'),
});
const prompt = generateEditorImageMock.mock.calls[0]?.[0]?.prompt ?? '';
expect(prompt).toContain('玩法设定:消除类派对玩法');
expect(prompt).toContain('美术风格:糖果玻璃拟物');
await waitFor(() => {
expect(screen.getByAltText(/画布图片UI素材规范/)).toBeTruthy();
});
expect(screen.getByText('规范')).toBeTruthy();
});
it('uses the custom spec prompt without template rewriting', async () => {
generateEditorImageMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,c3BlYy1jdXN0b20=',
width: 2048,
height: 1152,
sourceType: 'generated',
prompt: '自定义规范提示词',
actualPrompt: '自定义规范提示词',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'editor-spec-custom-1',
});
render(<ImageCanvasEditorView />);
fireEvent.click(
within(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).getByRole(
'button',
{ name: '生成规范' },
),
);
fireEvent.click(screen.getByRole('menuitem', { name: '自定义规范' }));
fireEvent.change(screen.getByLabelText('自定义规范提示词'), {
target: { value: ' 生成一张武器图标规范展板 ' },
});
fireEvent.click(
within(screen.getByRole('dialog', { name: '生成规范' })).getByRole(
'button',
{ name: '提交生成规范' },
),
);
expect(generateEditorImageMock).toHaveBeenCalledWith({
kind: 'spec',
model: 'gemini-3.1-flash-image-preview',
size: '2048x1152',
prompt: '生成一张武器图标规范展板',
});
await waitFor(() => {
expect(screen.getByAltText(/画布图片:自定义规范/)).toBeTruthy();
});
expect(screen.getByText('规范')).toBeTruthy();
});
it('supports character generation from a picked canvas spec and numbered references', async () => {
generateEditorImageMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,Y2hhcmFjdGVy',
objectKey:
'generated-character-drafts/editor/character-images/editor-character-1/image.png',
assetObjectId: 'asset-object-editor-character-1',
width: 2048,
height: 2048,
sourceType: 'generated',
prompt: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。',
actualPrompt: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'editor-character-1',
});
render(<ImageCanvasEditorView />);
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
const characterPanel = screen.getByRole('dialog', { name: '生成角色形象' });
expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
expect(
within(characterPanel).getByRole('button', { name: '角色形象规范' }),
).toBeTruthy();
fireEvent.click(
within(characterPanel).getByRole('button', { name: '角色形象规范' }),
);
const specSourceMenu = screen.getByRole('menu', {
name: '角色形象规范来源',
});
fireEvent.click(
within(specSourceMenu).getByRole('menuitem', { name: '从画布中选择' }),
);
expect(
screen.getByText('请选择画布中的图片作为角色形象规范,按 Esc 退出'),
).toBeTruthy();
fireEvent.pointerDown(
screen.getByAltText('画布图片:拼图素材').closest('button')!,
{
button: 0,
pointerId: 170,
clientX: 120,
clientY: 120,
},
);
expect(within(characterPanel).getByText('拼图素材')).toBeTruthy();
expect(
screen.queryByText('请选择画布中的图片作为角色形象规范,按 Esc 退出'),
).toBeNull();
const canvasReferenceLayer = screen
.getByAltText('画布图片:大鱼素材')
.closest('button')!;
expect(canvasReferenceLayer.className).not.toContain(
'image-canvas-editor__layer--selected',
);
fireEvent.click(
within(characterPanel).getByRole('button', { name: '上传常规参考图' }),
);
const regularReferenceMenu = screen.getByRole('menu', {
name: '常规参考图来源',
});
fireEvent.click(
within(regularReferenceMenu).getByRole('menuitem', {
name: '从画布中选择',
}),
);
expect(
screen.getByText('请选择画布中的图片作为常规参考图,按 Esc 退出'),
).toBeTruthy();
fireEvent.pointerDown(canvasReferenceLayer, {
button: 0,
pointerId: 171,
clientX: 180,
clientY: 120,
});
expect(
screen.queryByText('请选择画布中的图片作为常规参考图,按 Esc 退出'),
).toBeNull();
expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy();
expect(canvasReferenceLayer.className).not.toContain(
'image-canvas-editor__layer--selected',
);
expect(within(characterPanel).getByText('1')).toBeTruthy();
fireEvent.click(
within(characterPanel).getByRole('button', { name: '上传常规参考图' }),
);
fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' }));
await userEvent.upload(
screen.getByLabelText('上传图片文件'),
new File(['reference'], '常规参考.png', { type: 'image/png' }),
);
await waitFor(() => {
expect(within(characterPanel).getByText('2')).toBeTruthy();
});
fireEvent.change(within(characterPanel).getByLabelText('角色设定'), {
target: { value: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。' },
});
fireEvent.click(
within(characterPanel).getByRole('button', { name: '生成' }),
);
expect(generateEditorImageMock).toHaveBeenCalledWith({
kind: 'character',
prompt: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。',
model: 'gemini-3.1-flash-image-preview',
aspectRatio: '1:1',
imageSize: '1K',
referenceImageSrcs: [
'/creation-type-references/puzzle.webp',
'/creation-type-references/big-fish.webp',
expect.stringMatching(/^data:image\/png;base64,/u),
],
});
await waitFor(() => {
expect(screen.getByAltText(//u)).toBeTruthy();
});
expect(screen.getByText('角色')).toBeTruthy();
fireEvent.click(
screen.getAllByRole('button', {
name: / .*/u,
})[0]!,
);
const characterInfoPanel = screen.getByRole('dialog', {
name: / .*/u,
});
expect(within(characterInfoPanel).queryByText('Prompt')).toBeNull();
expect(within(characterInfoPanel).getByText('生成输入')).toBeTruthy();
expect(within(characterInfoPanel).getByText('角色设定')).toBeTruthy();
expect(
within(characterInfoPanel).getByText(
'银发游侠,蓝色披风,弓箭手,适合像素风战棋。',
),
).toBeTruthy();
expect(within(characterInfoPanel).getByText('角色形象规范')).toBeTruthy();
expect(within(characterInfoPanel).getByText('拼图素材')).toBeTruthy();
expect(within(characterInfoPanel).getByText('常规参考图 1')).toBeTruthy();
expect(within(characterInfoPanel).getByText('大鱼素材')).toBeTruthy();
expect(within(characterInfoPanel).getByText('常规参考图 2')).toBeTruthy();
expect(within(characterInfoPanel).getByText('常规参考.png')).toBeTruthy();
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
layers: expect.arrayContaining([
expect.objectContaining({
title: expect.stringMatching(//u),
assetKind: 'character',
objectKey:
'generated-character-drafts/editor/character-images/editor-character-1/image.png',
assetObjectId: 'asset-object-editor-character-1',
}),
]),
}),
);
});
await waitFor(() => {
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
objectKey:
'generated-character-drafts/editor/character-images/editor-character-1/image.png',
assetObjectId: 'asset-object-editor-character-1',
}),
);
});
});
it('removes the active character generation placeholder with Backspace', async () => {
render(<ImageCanvasEditorView />);
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy();
await act(async () => {
fireEvent.keyDown(window, { key: 'Backspace', code: 'Backspace' });
});
expect(screen.queryByLabelText('角色生成占位图')).toBeNull();
expect(screen.queryByRole('dialog', { name: '生成角色形象' })).toBeNull();
expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy();
});
it('opens icon asset generation panel, only picks icon specs, and lays generated icons on canvas', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-icons',
title: '图标素材画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-plain',
resourceId: 'resource-plain',
title: '普通参考图',
src: 'data:image/png;base64,plain',
x: 80,
y: 80,
width: 120,
height: 120,
originalWidth: 512,
originalHeight: 512,
zIndex: 10,
sourceType: 'uploaded',
},
{
layerId: 'layer-icon-spec',
resourceId: 'resource-icon-spec',
title: '清爽按钮图标规范',
src: 'data:image/png;base64,icon-spec',
x: 240,
y: 80,
width: 160,
height: 120,
originalWidth: 2048,
originalHeight: 1152,
zIndex: 11,
sourceType: 'generated',
assetKind: 'icon-spec',
},
],
resources: [],
updatedAt: '2026-06-15T00:00:00.000Z',
});
generateEditorIconSpritesheetMock.mockResolvedValueOnce({
spritesheetImageSrc: 'data:image/png;base64,sheet',
spritesheetWidth: 512,
spritesheetHeight: 512,
iconImageSrcs: [
{
name: '返回按钮',
imageSrc: 'data:image/png;base64,back-icon',
width: 96,
height: 96,
},
{
name: '设置按钮',
imageSrc: 'data:image/png;base64,setting-icon',
width: 96,
height: 96,
},
],
prompt: '图标 prompt',
actualPrompt: '图标 prompt',
model: 'gemini-3.1-flash-image-preview',
provider: 'VectorEngine',
taskId: 'icon-task-1',
});
render(<ImageCanvasEditorView />);
await waitFor(() => {
expect(screen.getByAltText('画布图片:普通参考图')).toBeTruthy();
expect(screen.getByAltText('画布图片:清爽按钮图标规范')).toBeTruthy();
});
fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
expect(screen.getByLabelText('图标素材生成占位图')).toBeTruthy();
expect(
within(iconPanel).getByRole('button', { name: '图标素材规范' }),
).toBeTruthy();
expect(
(within(iconPanel).getAllByRole('textbox')[0] as HTMLInputElement).value,
).toBe('返回按钮');
expect(
(within(iconPanel).getAllByRole('textbox')[5] as HTMLInputElement).value,
).toBe('冻结按钮');
fireEvent.click(
within(iconPanel).getByRole('button', { name: '图标素材规范' }),
);
fireEvent.click(screen.getByRole('menuitem', { name: '从画布中选择' }));
expect(
screen.getByText('请选择画布中的图标素材规范,按 Esc 退出'),
).toBeTruthy();
fireEvent.pointerDown(
screen.getByAltText('画布图片:普通参考图').closest('button')!,
{
button: 0,
pointerId: 180,
clientX: 100,
clientY: 100,
},
);
expect(
within(iconPanel).getByRole('button', { name: '图标素材规范' }),
).toBeTruthy();
fireEvent.pointerDown(
screen.getByAltText('画布图片:清爽按钮图标规范').closest('button')!,
{
button: 0,
pointerId: 181,
clientX: 260,
clientY: 100,
},
);
expect(
within(iconPanel).getByRole('button', { name: '清爽按钮图标规范' }),
).toBeTruthy();
expect(
screen.queryByText('请选择画布中的图标素材规范,按 Esc 退出'),
).toBeNull();
const iconDescriptionInputs = within(iconPanel).getAllByRole('textbox');
const [
,
,
iconDescription3,
iconDescription4,
iconDescription5,
iconDescription6,
] = iconDescriptionInputs;
expect(iconDescription3).toBeTruthy();
expect(iconDescription4).toBeTruthy();
expect(iconDescription5).toBeTruthy();
expect(iconDescription6).toBeTruthy();
fireEvent.change(iconDescription3!, {
target: { value: '' },
});
fireEvent.change(iconDescription4!, {
target: { value: '' },
});
fireEvent.change(iconDescription5!, {
target: { value: '' },
});
fireEvent.change(iconDescription6!, {
target: { value: '' },
});
fireEvent.click(within(iconPanel).getByRole('button', { name: '生成' }));
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith({
referenceImageSrc: 'data:image/png;base64,icon-spec',
iconDescriptions: ['返回按钮', '设置按钮'],
model: 'gemini-3.1-flash-image-preview',
aspectRatio: '1:1',
imageSize: '1K',
});
await waitFor(() => {
expect(screen.getByAltText('画布图片:返回按钮')).toBeTruthy();
expect(screen.getByAltText('画布图片:设置按钮')).toBeTruthy();
});
expect(screen.queryByLabelText('图标素材生成占位图')).toBeNull();
expect(screen.getAllByText('图标')).toHaveLength(2);
fireEvent.click(
screen.getAllByRole('button', { name: '查看返回按钮图片信息' })[0]!,
);
const iconInfoPanel = screen.getByRole('dialog', {
name: '返回按钮图片信息',
});
expect(within(iconInfoPanel).queryByText('Prompt')).toBeNull();
expect(within(iconInfoPanel).getByText('生成输入')).toBeTruthy();
expect(within(iconInfoPanel).getByText('素材描述 1')).toBeTruthy();
expect(within(iconInfoPanel).getByText('素材描述 2')).toBeTruthy();
expect(within(iconInfoPanel).getByText('返回按钮')).toBeTruthy();
expect(within(iconInfoPanel).getByText('设置按钮')).toBeTruthy();
expect(within(iconInfoPanel).getByText('图标素材规范')).toBeTruthy();
expect(within(iconInfoPanel).getByText('清爽按钮图标规范')).toBeTruthy();
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
'editor-project-icons',
expect.objectContaining({
layers: expect.arrayContaining([
expect.objectContaining({
title: '返回按钮',
assetKind: 'icon',
}),
expect.objectContaining({
title: '设置按钮',
assetKind: 'icon',
}),
]),
}),
);
});
});
it('exits character generation canvas picking with Escape', () => {
render(<ImageCanvasEditorView />);
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
const characterPanel = screen.getByRole('dialog', { name: '生成角色形象' });
fireEvent.click(
within(characterPanel).getByRole('button', { name: '角色形象规范' }),
);
fireEvent.click(screen.getByRole('menuitem', { name: '从画布中选择' }));
expect(
screen.getByText('请选择画布中的图片作为角色形象规范,按 Esc 退出'),
).toBeTruthy();
fireEvent.keyDown(window, { key: 'Escape' });
expect(
screen.queryByText('请选择画布中的图片作为角色形象规范,按 Esc 退出'),
).toBeNull();
expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy();
});
it('only exposes character animation generation for character layers and submits the panel payload', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-character-animation',
title: '角色动画画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-character',
resourceId: 'resource-character',
title: '市场老妇人',
src: 'data:image/png;base64,character',
x: 160,
y: 140,
width: 320,
height: 320,
originalWidth: 1024,
originalHeight: 1024,
zIndex: 2,
sourceType: 'generated',
objectKey:
'generated-character-drafts/editor/character-images/source/image.png',
assetKind: 'character',
},
{
layerId: 'layer-prop',
resourceId: 'resource-prop',
title: '普通道具',
src: 'data:image/png;base64,prop',
x: 520,
y: 140,
width: 280,
height: 220,
originalWidth: 700,
originalHeight: 550,
zIndex: 1,
sourceType: 'uploaded',
},
],
resources: [],
updatedAt: '2026-06-15T00:00:00.000Z',
});
generateEditorCharacterAnimationMock.mockResolvedValueOnce({
taskId: 'character-animation-task-1',
model: 'seedance2.0',
prompt: '生成游戏角色动画\n动作描述\n待机',
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
frames: Array.from({ length: 48 }, (_, index) => ({
frameIndex: index + 1,
imageSrc: `/generated-character-drafts/editor/frame${index + 1}.png`,
width: 1024,
height: 1024,
})),
frameCount: 48,
durationSeconds: 6,
fps: 8,
priceMudPoints: 120,
});
render(<ImageCanvasEditorView />);
const propLayer = await screen.findByAltText('画布图片:普通道具');
fireEvent.click(propLayer.closest('button')!);
expect(screen.queryByRole('button', { name: '生成动画' })).toBeNull();
fireEvent.contextMenu(propLayer.closest('button')!, {
clientX: 220,
clientY: 180,
});
expect(screen.queryByRole('menuitem', { name: '生成动画' })).toBeNull();
const characterLayer = screen.getByAltText('画布图片:市场老妇人');
fireEvent.click(characterLayer.closest('button')!);
expect(screen.getByText('角色')).toBeTruthy();
expect(screen.getByRole('button', { name: '生成动画' })).toBeTruthy();
fireEvent.contextMenu(characterLayer.closest('button')!, {
clientX: 260,
clientY: 220,
});
expect(screen.getByRole('menuitem', { name: '生成动画' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '生成动画' }));
const panel = screen.getByRole('dialog', { name: '角色动画生成面板' });
expect(within(panel).getByText('40泥点')).toBeTruthy();
expect(
(within(panel).getByLabelText('分辨率') as HTMLSelectElement).value,
).toBe('480p');
expect(
(within(panel).getByLabelText('画面比例') as HTMLSelectElement).value,
).toBe('same');
expect(
(within(panel).getByLabelText('时长') as HTMLSelectElement).value,
).toBe('32');
for (const actionLabel of [
'待机',
'行走',
'奔跑',
'跳跃',
'攻击',
'受击',
'倒下',
]) {
expect(
within(panel).getByRole('button', { name: actionLabel }),
).toBeTruthy();
}
fireEvent.click(within(panel).getByRole('button', { name: '待机' }));
expect(
(within(panel).getByLabelText('动画描述') as HTMLTextAreaElement).value,
).toContain('待机');
const longPrompt = '走'.repeat(4100);
fireEvent.change(within(panel).getByLabelText('动画描述'), {
target: { value: longPrompt },
});
expect(
(within(panel).getByLabelText('动画描述') as HTMLTextAreaElement).value,
).toHaveLength(4000);
const precisePrompt =
'The elderly market woman gently shifts weight while the basket sways.';
fireEvent.change(within(panel).getByLabelText('动画描述'), {
target: { value: precisePrompt },
});
expect(
within(panel).getByLabelText(`生成文本:${precisePrompt}`),
).toBeTruthy();
fireEvent.change(within(panel).getByLabelText('分辨率'), {
target: { value: '720p' },
});
fireEvent.change(within(panel).getByLabelText('画面比例'), {
target: { value: '16:9' },
});
fireEvent.change(within(panel).getByLabelText('时长'), {
target: { value: '48' },
});
expect(within(panel).getByText('120泥点')).toBeTruthy();
fireEvent.click(within(panel).getByRole('button', { name: '生成' }));
expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith(
expect.objectContaining({
sourceLayerId: 'layer-character',
sourceImageSrc:
'generated-character-drafts/editor/character-images/source/image.png',
sourceWidth: 1024,
sourceHeight: 1024,
resolution: '720p',
ratio: '16:9',
frameCount: 48,
durationSeconds: 6,
priceMudPoints: 120,
model: 'seedance2.0',
}),
);
expect(
generateEditorCharacterAnimationMock.mock.calls[0]?.[0]?.promptText,
).toBe(precisePrompt);
await waitFor(() => {
expect(within(panel).getByText('已生成 48 帧')).toBeTruthy();
});
});
it('opens quick edit from the floating toolbar with original image as first reference and generates beside the source', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-quick-edit',
title: '快速编辑画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-quick-source',
resourceId: 'resource-quick-source',
title: '魔法森林',
src: 'data:image/png;base64,c291cmNl',
x: 120,
y: 140,
width: 320,
height: 240,
originalWidth: 1536,
originalHeight: 1024,
zIndex: 2,
sourceType: 'generated',
prompt: '魔法森林原始提示词',
actualPrompt: '魔法森林原始提示词',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'source-task-1',
assetKind: 'spec',
},
],
resources: [],
updatedAt: '2026-06-15T00:00:00.000Z',
});
generateEditorImageMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,cXVpY2stZWRpdA==',
width: 1536,
height: 1024,
sourceType: 'generated',
prompt: '增加萤火虫',
actualPrompt: '增加萤火虫',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'quick-edit-task-1',
});
render(<ImageCanvasEditorView />);
const sourceImage = await screen.findByAltText('画布图片:魔法森林');
fireEvent.pointerDown(sourceImage.closest('button')!, {
button: 0,
pointerId: 151,
clientX: 180,
clientY: 180,
});
fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
pointerId: 151,
clientX: 180,
clientY: 180,
});
fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));
const quickPanel = screen.getByRole('dialog', { name: '快速编辑图片' });
expect(quickPanel.className).toContain(
'image-canvas-editor__quick-edit-panel',
);
expect(within(quickPanel).getByText('魔法森林')).toBeTruthy();
expect(
(within(quickPanel).getByLabelText('快速编辑尺寸') as HTMLSelectElement)
.value,
).toBe('1536x1024');
expect(
(within(quickPanel).getByLabelText('快速编辑模型') as HTMLSelectElement)
.value,
).toBe('gpt-image-2');
const references = within(quickPanel).getAllByRole('img');
expect(references[0]?.getAttribute('src')).toBe(
'data:image/png;base64,c291cmNl',
);
fireEvent.change(within(quickPanel).getByLabelText('快速编辑提示词'), {
target: { value: '增加萤火虫' },
});
fireEvent.click(within(quickPanel).getByRole('button', { name: '生成' }));
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith({
prompt: '增加萤火虫',
size: '1536x1024',
kind: 'quick-edit',
model: 'gpt-image-2',
referenceImageSrcs: ['data:image/png;base64,c291cmNl'],
});
});
await waitFor(() => {
expect(screen.getByAltText('画布图片:魔法森林 快速编辑')).toBeTruthy();
});
const generatedLayer = screen
.getByAltText('画布图片:魔法森林 快速编辑')
.closest('button') as HTMLElement;
expect(Number.parseFloat(generatedLayer.style.left)).toBe(1688);
expect(Number.parseFloat(generatedLayer.style.top)).toBe(140);
expect(Number.parseFloat(generatedLayer.style.width)).toBe(1536);
expect(Number.parseFloat(generatedLayer.style.height)).toBe(1024);
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
'editor-project-quick-edit',
expect.objectContaining({
layers: expect.arrayContaining([
expect.objectContaining({
title: '魔法森林 快速编辑',
assetKind: 'spec',
width: 1536,
height: 1024,
originalWidth: 1536,
originalHeight: 1024,
x: 1688,
y: 140,
}),
]),
}),
);
});
});
it('opens quick edit from the image context menu', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-context-quick-edit',
title: '右键快速编辑画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-context-source',
resourceId: 'resource-context-source',
title: '右键图片',
src: 'data:image/png;base64,Y29udGV4dA==',
x: 80,
y: 90,
width: 260,
height: 260,
originalWidth: 1024,
originalHeight: 1024,
zIndex: 1,
sourceType: 'uploaded',
model: 'gpt-image-2',
},
],
resources: [],
updatedAt: '2026-06-15T00:00:00.000Z',
});
generateEditorImageMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,Y29udGV4dC1xdWljaw==',
width: 1024,
height: 1024,
sourceType: 'generated',
prompt: '换成夜晚',
actualPrompt: '换成夜晚',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'context-quick-task-1',
});
render(<ImageCanvasEditorView />);
const contextImage = await screen.findByAltText('画布图片:右键图片');
fireEvent.contextMenu(contextImage.closest('button')!, {
clientX: 260,
clientY: 220,
});
const menu = screen.getByRole('menu', { name: '图片功能面板' });
expect(
within(menu).getByRole('menuitem', { name: '快速编辑' }),
).toBeTruthy();
fireEvent.click(within(menu).getByRole('menuitem', { name: '快速编辑' }));
const panel = screen.getByRole('dialog', { name: '快速编辑图片' });
expect(within(panel).getByText('右键图片')).toBeTruthy();
fireEvent.change(within(panel).getByLabelText('快速编辑提示词'), {
target: { value: '换成夜晚' },
});
fireEvent.click(within(panel).getByRole('button', { name: '生成' }));
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '换成夜晚',
referenceImageSrcs: ['data:image/png;base64,Y29udGV4dA=='],
size: '1024x1024',
model: 'gpt-image-2',
kind: 'quick-edit',
}),
);
});
await waitFor(() => {
expect(screen.getByAltText('画布图片:右键图片 快速编辑')).toBeTruthy();
});
});
it('converts non-data-url quick edit source images before submitting references', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-public-quick-edit',
title: '公开素材快速编辑画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-public-source',
resourceId: 'resource-public-source',
title: '公开拼图素材',
src: '/creation-type-references/puzzle.webp',
x: 120,
y: 140,
width: 320,
height: 240,
originalWidth: 640,
originalHeight: 640,
zIndex: 2,
sourceType: 'uploaded',
},
],
resources: [],
updatedAt: '2026-06-16T00:00:00.000Z',
});
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
new Response(new Uint8Array([104, 101, 108, 108, 111]), {
status: 200,
headers: {
'Content-Type': 'image/webp',
},
}),
);
generateEditorImageMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,cHVibGljLXF1aWNr',
width: 640,
height: 640,
sourceType: 'generated',
prompt: '改成陶泥风格',
actualPrompt: '改成陶泥风格',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'public-quick-edit-task-1',
});
render(<ImageCanvasEditorView />);
const sourceImage = await screen.findByAltText('画布图片:公开拼图素材');
fireEvent.pointerDown(sourceImage.closest('button')!, {
button: 0,
pointerId: 161,
clientX: 180,
clientY: 180,
});
fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
pointerId: 161,
clientX: 180,
clientY: 180,
});
fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));
const quickPanel = screen.getByRole('dialog', { name: '快速编辑图片' });
fireEvent.change(within(quickPanel).getByLabelText('快速编辑提示词'), {
target: { value: '改成陶泥风格' },
});
fireEvent.click(within(quickPanel).getByRole('button', { name: '生成' }));
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '改成陶泥风格',
kind: 'quick-edit',
referenceImageSrcs: ['data:image/webp;base64,aGVsbG8='],
}),
);
});
expect(globalThis.fetch).toHaveBeenCalledWith(
'/creation-type-references/puzzle.webp',
expect.objectContaining({
signal: undefined,
}),
);
});
it('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: -60,
clientY: 180,
});
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(
'生成中',
);
});
it('undoes and redoes canvas layer changes from the panel controls', () => {
render(<ImageCanvasEditorView />);
expect(screen.getByRole('button', { name: '撤销' })).toHaveProperty(
'disabled',
true,
);
expect(screen.getByRole('button', { name: '重做' })).toHaveProperty(
'disabled',
true,
);
fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' }));
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
expect(screen.getByRole('button', { name: '撤销' })).toHaveProperty(
'disabled',
false,
);
fireEvent.click(screen.getByRole('button', { name: '撤销' }));
expect(screen.queryByAltText('画布图片:声浪素材')).toBeNull();
expect(screen.getByRole('button', { name: '重做' })).toHaveProperty(
'disabled',
false,
);
fireEvent.click(screen.getByRole('button', { name: '重做' }));
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
});
it('supports undo and redo keyboard shortcuts inside the editor', () => {
render(<ImageCanvasEditorView />);
fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' }));
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
fireEvent.keyDown(window, { key: 'z', code: 'KeyZ', ctrlKey: true });
expect(screen.queryByAltText('画布图片:声浪素材')).toBeNull();
fireEvent.keyDown(window, {
key: 'Z',
code: 'KeyZ',
ctrlKey: true,
shiftKey: true,
});
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
});
});