4818 lines
162 KiB
TypeScript
4818 lines
162 KiB
TypeScript
/* @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();
|
||
});
|
||
});
|