Files
Genarrative/src/components/image-editor/ImageCanvasEditorView.test.tsx
T
kdletters ab61390121 修复画布 Agent 默认被任务列表隐藏
打开画布时 Agent 开关启用则保持 Agent 默认展开。

恢复任务自动打开任务列表时不再抢占 Agent 默认态。

用户手动打开任务列表时仍按右侧面板互斥关闭 Agent。

补充恢复任务场景下 Agent 默认显示的回归测试。
2026-07-06 19:15:03 +08:00

2338 lines
76 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { EditorAgentConversationClient } from './useEditorAgentConversation';
import {
ApiClientError,
AuthUiContext,
createAuthValue,
defaultEditorAssetLibraryAssets,
defaultEditorProjectLayers,
defaultEditorProjectResources,
dispatchPointerEvent,
ImageCanvasEditorView,
readZipText,
setupImageCanvasEditorViewTestLifecycle,
} from './ImageCanvasEditorView.test-utils';
type EditorAgentListConversations =
EditorAgentConversationClient['listConversations'];
type EditorAgentCreateConversation =
EditorAgentConversationClient['createConversation'];
type EditorAgentGetConversation =
EditorAgentConversationClient['getConversation'];
type EditorAgentDeleteConversation =
EditorAgentConversationClient['deleteConversation'];
type EditorAgentStreamMessage = EditorAgentConversationClient['streamMessage'];
type EditorAgentConversationSummary = Awaited<
ReturnType<EditorAgentListConversations>
>[number];
type EditorAgentConversationDetail = Awaited<
ReturnType<EditorAgentGetConversation>
>;
const EDITOR_AGENT_SIDEBAR_ENV = 'VITE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR';
const listEditorAgentConversationsMock = vi.hoisted(() =>
vi.fn<
Parameters<EditorAgentListConversations>,
ReturnType<EditorAgentListConversations>
>(async () => []),
);
const createEditorAgentConversationMock = vi.hoisted(() =>
vi.fn<
Parameters<EditorAgentCreateConversation>,
ReturnType<EditorAgentCreateConversation>
>(async () => ({
conversationId: 'editor-agent-conv-created',
projectId: 'editor-project-default',
title: '画布 Agent',
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:00.000Z',
messages: [],
})),
);
const getEditorAgentConversationMock = vi.hoisted(() =>
vi.fn<
Parameters<EditorAgentGetConversation>,
ReturnType<EditorAgentGetConversation>
>(async () => ({
conversationId: 'editor-agent-conv-test',
projectId: 'editor-project-default',
title: '画布 Agent',
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:00.000Z',
messages: [],
})),
);
const deleteEditorAgentConversationMock = vi.hoisted(() =>
vi.fn<
Parameters<EditorAgentDeleteConversation>,
ReturnType<EditorAgentDeleteConversation>
>(async () => ({
conversationId: 'editor-agent-conv-test',
projectId: 'editor-project-default',
title: '画布 Agent',
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:00.000Z',
})),
);
const streamEditorAgentMessageMock = vi.hoisted(() =>
vi.fn<
Parameters<EditorAgentStreamMessage>,
ReturnType<EditorAgentStreamMessage>
>(async () => undefined),
);
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());
const getPlatformProfileDashboardMock = vi.hoisted(() => vi.fn());
vi.mock('../../services/image-editor/editorProjectClient', async () => {
const actual = await vi.importActual<
typeof import('../../services/image-editor/editorProjectClient')
>('../../services/image-editor/editorProjectClient');
return {
...actual,
editEditorImage: editEditorImageMock,
createEditorAsset: createEditorAssetMock,
createEditorAssetFolder: createEditorAssetFolderMock,
createEditorProjectResource: createEditorProjectResourceMock,
deleteEditorAsset: deleteEditorAssetMock,
deleteEditorAssetFolder: deleteEditorAssetFolderMock,
generateEditorCharacterAnimation: generateEditorCharacterAnimationMock,
generateEditorIconSpritesheet: generateEditorIconSpritesheetMock,
generateEditorImage: generateEditorImageMock,
loadEditorAssetLibrary: loadEditorAssetLibraryMock,
loadEditorProject: loadEditorProjectMock,
loadOrCreateRecentEditorProject: loadOrCreateRecentEditorProjectMock,
renameEditorProject: renameEditorProjectMock,
saveEditorProjectLayout: saveEditorProjectLayoutMock,
updateEditorAsset: updateEditorAssetMock,
updateEditorAssetFolder: updateEditorAssetFolderMock,
};
});
vi.mock('../../services/platform-entry/platformProfileClient', () => ({
getPlatformProfileDashboard: getPlatformProfileDashboardMock,
}));
vi.mock('../../services/image-editor/editorAgentClient', () => ({
createEditorAgentConversation: createEditorAgentConversationMock,
deleteEditorAgentConversation: deleteEditorAgentConversationMock,
getEditorAgentConversation: getEditorAgentConversationMock,
listEditorAgentConversations: listEditorAgentConversationsMock,
streamEditorAgentMessage: streamEditorAgentMessageMock,
}));
function createEditorAgentConversationSummary(
overrides: Partial<EditorAgentConversationSummary> = {},
): EditorAgentConversationSummary {
return {
conversationId: 'editor-agent-conv-test',
projectId: 'editor-project-default',
title: '画布 Agent',
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:00.000Z',
...overrides,
};
}
function createEditorAgentDetailWithGeneration(
resourceId: string,
): EditorAgentConversationDetail {
const summary = createEditorAgentConversationSummary();
return {
...summary,
messages: [
{
id: 'agent-message-result',
role: 'assistant',
kind: 'chat',
text: '生成完成',
attachments: [],
generations: [
{
toolCallId: 'agent-tool-call-1',
toolName: 'generate_image',
taskId: 'task-agent-1',
status: 'completed',
model: 'gpt-image-2',
images: [
{
resourceId,
imageSrc: '/agent-result.png',
thumbnailSrc: '/agent-result-thumb.png',
width: 512,
height: 512,
},
],
error: null,
},
],
status: 'completed',
createdAt: '2026-07-03T00:00:01.000Z',
},
],
};
}
function openAssetSidebar() {
fireEvent.click(screen.getByRole('button', { name: '打开素材' }));
return screen.getByRole('complementary', { name: '图片资源栏' });
}
function openMinimap() {
const panelToolbar = screen.getByRole('toolbar', { name: '画布面板入口' });
fireEvent.click(
within(panelToolbar).getByRole('button', { name: '切换小地图' }),
);
return screen.getByRole('button', { name: '画布小地图' });
}
async function ensureAgentConversationOpen() {
const agentButton = screen.getByRole('button', { name: '画布 Agent' });
if (agentButton.getAttribute('aria-pressed') !== 'true') {
fireEvent.click(agentButton);
}
return screen.findByLabelText('发送给画布 Agent');
}
function enableEditorAgentSidebarForTest() {
vi.stubEnv(EDITOR_AGENT_SIDEBAR_ENV, 'true');
}
describe('ImageCanvasEditorView', () => {
setupImageCanvasEditorViewTestLifecycle({
generateEditorImageMock,
generateEditorIconSpritesheetMock,
generateEditorCharacterAnimationMock,
editEditorImageMock,
createEditorAssetMock,
createEditorProjectResourceMock,
createEditorAssetFolderMock,
updateEditorAssetMock,
updateEditorAssetFolderMock,
deleteEditorAssetFolderMock,
deleteEditorAssetMock,
loadEditorAssetLibraryMock,
loadEditorProjectMock,
loadOrCreateRecentEditorProjectMock,
renameEditorProjectMock,
saveEditorProjectLayoutMock,
});
beforeEach(() => {
vi.stubEnv(EDITOR_AGENT_SIDEBAR_ENV, 'false');
listEditorAgentConversationsMock.mockResolvedValue([]);
createEditorAgentConversationMock.mockResolvedValue({
...createEditorAgentConversationSummary({
conversationId: 'editor-agent-conv-created',
}),
messages: [],
});
getEditorAgentConversationMock.mockResolvedValue({
...createEditorAgentConversationSummary(),
messages: [],
});
deleteEditorAgentConversationMock.mockResolvedValue(
createEditorAgentConversationSummary(),
);
streamEditorAgentMessageMock.mockResolvedValue(undefined);
getPlatformProfileDashboardMock.mockResolvedValue({
walletBalance: 1234,
totalPlayTimeMs: 0,
playedWorldCount: 0,
updatedAt: null,
});
});
afterEach(() => {
listEditorAgentConversationsMock.mockReset();
createEditorAgentConversationMock.mockReset();
getEditorAgentConversationMock.mockReset();
deleteEditorAgentConversationMock.mockReset();
streamEditorAgentMessageMock.mockReset();
getPlatformProfileDashboardMock.mockReset();
vi.unstubAllEnvs();
});
it('loads the project from projectid query before falling back to recent project', async () => {
listEditorAgentConversationsMock.mockResolvedValue([]);
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 toolbar guide for a newly created blank project until a generator opens', async () => {
loadEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-guide',
title: '空白项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
});
window.history.replaceState(
null,
'',
'/editor/canvas?projectid=editor-project-guide&guide=toolbar',
);
render(<ImageCanvasEditorView />);
expect(
await screen.findByText('工具栏在这里,快试着随便做点什么'),
).toBeTruthy();
expect(
document
.querySelector('.image-canvas-editor__toolbar-guide-art')
?.getAttribute('src'),
).toBe('/branding/taonier-toolbar-guide-bubble.png');
const bottomToolbar = screen.getByRole('toolbar', {
name: 'AI画布工具栏',
});
expect(bottomToolbar.className).toContain(
'image-canvas-editor__bottom-toolbar--guided',
);
expect(window.location.search).toBe('?projectid=editor-project-guide');
fireEvent.click(
within(bottomToolbar).getByRole('button', { name: '生成图片' }),
);
expect(
await screen.findByRole('dialog', { name: '生成图片' }),
).toBeTruthy();
await waitFor(() => {
expect(screen.queryByText('工具栏在这里,快试着随便做点什么')).toBeNull();
});
expect(bottomToolbar.className).not.toContain(
'image-canvas-editor__bottom-toolbar--guided',
);
});
it('opens the requested canvas startup tool from the project query once', async () => {
loadEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-music',
title: '音乐项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
});
window.history.replaceState(
null,
'',
'/editor/canvas?projectid=editor-project-music&tool=background-music',
);
render(<ImageCanvasEditorView />);
expect(
await screen.findByRole('dialog', { name: '生成游戏背景音乐' }),
).toBeTruthy();
expect(screen.queryByText('工具栏在这里,快试着随便做点什么')).toBeNull();
expect(window.location.search).toBe('?projectid=editor-project-music');
});
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('shows the live mud point balance in the canvas topbar when logged in', async () => {
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,
})}
>
<ImageCanvasEditorView />
</AuthUiContext.Provider>,
);
expect(await screen.findByLabelText('泥点余额 1,234泥点')).toBeTruthy();
expect(getPlatformProfileDashboardMock).toHaveBeenCalledWith({
authImpact: 'local',
skipRefresh: true,
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
});
});
it('opens the account modal from the canvas topbar avatar entry', async () => {
const openAccountModal = vi.fn();
render(
<AuthUiContext.Provider
value={createAuthValue({
user: {
id: 'user-1',
publicUserCode: 'U001',
displayName: '测试用户',
avatarUrl: 'data:image/png;base64,AVATAR',
phoneNumberMasked: '138****0000',
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
},
canAccessProtectedData: true,
openAccountModal,
})}
>
<ImageCanvasEditorView />
</AuthUiContext.Provider>,
);
const accountButton = await screen.findByRole('button', {
name: '账号 测试用户',
});
expect(accountButton.querySelector('img')?.getAttribute('src')).toBe(
'data:image/png;base64,AVATAR',
);
fireEvent.click(accountButton);
expect(openAccountModal).toHaveBeenCalledTimes(1);
});
it('opens the same account wallet entry from the canvas topbar mud point button', async () => {
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,
})}
>
<ImageCanvasEditorView />
</AuthUiContext.Provider>,
);
const walletButton = await screen.findByRole('button', {
name: '泥点余额 1,234泥点',
});
fireEvent.click(walletButton);
expect(await screen.findByRole('dialog', { name: '兑换码' })).toBeTruthy();
expect(screen.getByPlaceholderText('输入兑换码')).toBeTruthy();
});
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 () => {
listEditorAgentConversationsMock.mockResolvedValue([]);
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 />);
openAssetSidebar();
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',
sourceResourceId: 'resource-generated',
prompt: '明亮主视觉',
actualPrompt: '后端拼接提示词',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-1',
assetObjectId: 'asset-object-visible',
generationInputs: {
fields: [{ title: '生成提示词', value: '明亮主视觉' }],
references: [],
},
},
{
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',
sourceResourceId: 'resource-failed',
},
],
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) => {
const pathname = new URL(url, 'http://localhost').pathname;
if (pathname === '/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).toBeUndefined();
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).toBeUndefined();
expect(metadata.layers[0].visible.type).toBe('上传图片');
expect(metadata.layers[2].visible).toEqual({
type: '生成图片',
generationInputs: {
fields: [{ title: '生成提示词', value: '明亮主视觉' }],
references: [],
},
model: 'gpt-image-2',
task: '1',
object: 'asset-object-visible',
resolution: '1024 x 1024 px',
});
expect(metadata.layers[2].sourceType).toBeUndefined();
expect(metadata.layers[2].prompt).toBeUndefined();
expect(metadata.layers[2].actualPrompt).toBeUndefined();
expect(metadata.layers[2].provider).toBeUndefined();
expect(metadata.layers[2].sourceResourceId).toBeUndefined();
expect(metadata.layers[3].file).toBeNull();
expect(metadata.layers[3].exportError).toContain('404');
expect(metadata.failedImages).toEqual([
{ title: '失败图', error: expect.stringContaining('404') },
]);
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('shows image resolution on hover and opens the crop-expand panel after selecting a layer', () => {
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(screen.getByRole('dialog', { name: '裁扩图片' })).toBeTruthy();
expect(
screen
.getByRole('button', { name: '自由比例' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(screen.queryByLabelText('裁扩左')).toBeNull();
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.getByRole('button', { name: '改造' })).toBeTruthy();
});
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('downloads the selected layer from the floating toolbar', async () => {
let downloadName = '';
let downloadHref = '';
const originalFetch = globalThis.fetch;
const originalCreateObjectUrl = URL.createObjectURL;
const originalRevokeObjectUrl = URL.revokeObjectURL;
globalThis.fetch = vi.fn(async () => {
return new Response(new Blob(['puzzle'], { type: 'image/webp' }));
}) as typeof fetch;
URL.createObjectURL = vi.fn(() => 'blob:layer-download');
URL.revokeObjectURL = vi.fn();
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(
function click(this: HTMLAnchorElement) {
downloadName = this.download;
downloadHref = this.href;
},
);
try {
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: '下载按钮' }));
await waitFor(() => {
expect(downloadName).toBe('拼图素材.webp');
});
expect(downloadHref).toBe('blob:layer-download');
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/creation-type-references/puzzle.webp'),
expect.any(Object),
);
expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy();
expect(screen.getByAltText('画布图片:大鱼素材')).toBeTruthy();
} finally {
globalThis.fetch = originalFetch;
URL.createObjectURL = originalCreateObjectUrl;
URL.revokeObjectURL = originalRevokeObjectUrl;
}
});
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('moves focus from the Agent input back to the canvas layer before handling shortcuts', async () => {
enableEditorAgentSidebarForTest();
render(<ImageCanvasEditorView />);
const agentInput = await ensureAgentConversationOpen();
agentInput.focus();
expect(document.activeElement).toBe(agentInput);
const layerButton = screen
.getByAltText('画布图片:拼图素材')
.closest('button')!;
fireEvent.pointerDown(layerButton, {
button: 0,
pointerId: 53,
clientX: 120,
clientY: 120,
});
expect(document.activeElement).toBe(layerButton);
await act(async () => {
fireEvent.keyDown(window, { key: 'Backspace', code: 'Backspace' });
});
expect(screen.queryByAltText('画布图片:拼图素材')).toBeNull();
expect(screen.getByAltText('画布图片:大鱼素材')).toBeTruthy();
});
it('opens the matching audio generation panel when remodeling an existing sound effect layer', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-audio-remodel',
title: '音频改造画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-sound-effect',
resourceId: 'resource-sound-effect',
title: '游戏音效',
src: '/generated-character-drafts/editor-audios/sound.wav',
mediaType: 'audio',
assetKind: 'sound-effect',
x: 120,
y: 140,
width: 420,
height: 120,
originalWidth: 420,
originalHeight: 120,
zIndex: 2,
sourceType: 'generated',
prompt: '金币跳出音',
model: 'audio1.0',
generationInputs: {
fields: [
{ title: '音效提示词', value: '金币跳出音' },
{ title: 'model', value: 'audio1.0' },
{ title: 'duration', value: '8秒' },
],
references: [
{
label: '历史参考图',
src: '/creation-type-references/puzzle.webp',
},
],
},
},
],
resources: [],
updatedAt: '2026-06-19T00:00:00.000Z',
});
render(<ImageCanvasEditorView />);
const audioLayer = await screen.findByRole('button', {
name: '选择游戏音效',
});
fireEvent.pointerDown(audioLayer, {
button: 0,
pointerId: 54,
clientX: 160,
clientY: 180,
});
fireEvent.click(screen.getByRole('button', { name: '改造' }));
const panel = screen.getByRole('dialog', { name: '生成游戏音效' });
expect(within(panel).getByDisplayValue('金币跳出音')).toBeTruthy();
expect(
within(panel).getByRole('button', { name: '音效时长 8秒' }),
).toBeTruthy();
expect(
panel.querySelector('.image-canvas-editor__reference-strip'),
).toBeNull();
});
it('deletes the selected audio layer with Backspace from the audio control focus', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-audio-delete',
title: '音频删除画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-audio',
resourceId: 'resource-audio',
title: '背景音乐',
src: '/generated-character-drafts/editor-audios/bgm.mp3',
mediaType: 'audio',
assetKind: 'background-music',
x: 120,
y: 140,
width: 420,
height: 120,
originalWidth: 420,
originalHeight: 120,
zIndex: 2,
sourceType: 'generated',
},
{
layerId: 'layer-reference',
resourceId: 'resource-reference',
title: '参考图片',
src: '/creation-type-references/puzzle.webp',
x: 620,
y: 140,
width: 320,
height: 320,
originalWidth: 320,
originalHeight: 320,
zIndex: 1,
sourceType: 'uploaded',
},
],
resources: [],
updatedAt: '2026-06-18T00:00:00.000Z',
});
render(<ImageCanvasEditorView />);
const audioControl = await screen.findByLabelText('画布音频:背景音乐');
fireEvent.pointerDown(audioControl, {
button: 0,
pointerId: 53,
clientX: 160,
clientY: 180,
});
await act(async () => {
fireEvent.keyDown(audioControl, { key: 'Backspace', code: 'Backspace' });
});
expect(screen.queryByLabelText('画布音频:背景音乐')).toBeNull();
expect(screen.getByAltText('画布图片:参考图片')).toBeTruthy();
});
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, 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(false);
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('shows delete when right-clicking a generation placeholder', async () => {
render(<ImageCanvasEditorView />);
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
const frame = await screen.findByLabelText('图像生成占位图');
fireEvent.contextMenu(frame, {
clientX: 510,
clientY: 330,
});
const menu = screen.getByRole('menu', { name: '生成器右键菜单' });
fireEvent.click(within(menu).getByRole('menuitem', { name: '删除' }));
expect(screen.queryByLabelText('图像生成占位图')).toBeNull();
});
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 />);
expect(
screen.queryByRole('complementary', { name: '图片资源栏' }),
).toBeNull();
const sidebar = openAssetSidebar();
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.getByRole('button', { name: '改造' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '打开素材' }));
expect(screen.getByRole('button', { name: '添加拼图素材' })).toBeTruthy();
});
it('hides the right Agent conversation entry by default', () => {
render(<ImageCanvasEditorView />);
expect(screen.queryByRole('button', { name: '画布 Agent' })).toBeNull();
expect(
screen.queryByRole('button', { name: '打开画布 Agent' }),
).toBeNull();
expect(screen.queryByLabelText('发送给画布 Agent')).toBeNull();
expect(listEditorAgentConversationsMock).not.toHaveBeenCalled();
});
it('opens the right Agent conversation panel by default when enabled', async () => {
enableEditorAgentSidebarForTest();
render(<ImageCanvasEditorView />);
expect(await screen.findByLabelText('发送给画布 Agent')).toBeTruthy();
expect(
screen.queryByRole('complementary', { name: '画布任务列表' }),
).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '画布 Agent' }));
expect(screen.queryByLabelText('发送给画布 Agent')).toBeNull();
});
it('keeps the Agent conversation panel open when restored tasks auto-open the task list', async () => {
enableEditorAgentSidebarForTest();
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-with-restored-task',
title: '恢复任务项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
...defaultEditorProjectLayers,
{
layerId: 'generation-dialog:restored-task',
resourceId: 'generation-dialog:restored-task',
itemType: 'generation-dialog',
dialog: {
id: 'restored-task',
mode: 'generate',
prompt: '继续生成',
status: 'idle',
},
},
],
resources: defaultEditorProjectResources,
updatedAt: '2026-07-06T00:00:00.000Z',
});
render(<ImageCanvasEditorView />);
expect(await screen.findByLabelText('发送给画布 Agent')).toBeTruthy();
await waitFor(() => {
expect(
screen.queryByRole('complementary', { name: '画布任务列表' }),
).toBeNull();
});
});
it('keeps the left resource sidebar independent from right-side task and Agent panels', async () => {
enableEditorAgentSidebarForTest();
render(<ImageCanvasEditorView />);
const assetSidebar = openAssetSidebar();
expect(within(assetSidebar).getByText('素材')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '任务列表' }));
expect(
screen.getByRole('complementary', { name: '画布任务列表' }),
).toBeTruthy();
expect(
screen.getByRole('complementary', { name: '图片资源栏' }),
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '打开图层' }));
const layerSidebar = screen.getByRole('complementary', {
name: '图片资源栏',
});
expect(within(layerSidebar).getByText('图层')).toBeTruthy();
expect(
screen.getByRole('complementary', { name: '画布任务列表' }),
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '画布 Agent' }));
expect(
screen.getByRole('complementary', { name: '图片资源栏' }),
).toBeTruthy();
expect(
screen.queryByRole('complementary', { name: '画布任务列表' }),
).toBeNull();
expect(await screen.findByLabelText('发送给画布 Agent')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '任务列表' }));
expect(
screen.getByRole('complementary', { name: '图片资源栏' }),
).toBeTruthy();
expect(
screen.getByRole('complementary', { name: '画布任务列表' }),
).toBeTruthy();
expect(screen.queryByLabelText('发送给画布 Agent')).toBeNull();
});
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();
openAssetSidebar();
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 />);
openAssetSidebar();
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('saves manually selected layer tags in the canvas layout', async () => {
render(<ImageCanvasEditorView />);
const layerButton = await screen.findByRole('button', {
name: '选择拼图素材',
});
fireEvent.click(
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
);
fireEvent.click(
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
'menuitem',
{ name: '角色' },
),
);
await screen.findByRole('button', { name: '素材标签:角色' });
await waitFor(() => {
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
imageSrc: '/creation-type-references/puzzle.webp',
assetKind: 'character',
}),
);
});
await waitFor(() => {
const savedLayout = saveEditorProjectLayoutMock.mock.calls
.map((call) => call[1])
.find((layout) =>
layout.layers.some(
(layer: { resourceId?: unknown; title?: unknown }) =>
layer.title === '拼图素材' &&
layer.resourceId === 'resource-editor-project-default-640',
),
);
expect(savedLayout).toBeTruthy();
expect(savedLayout.layers).toEqual(
expect.arrayContaining([
expect.not.objectContaining({
title: '拼图素材',
assetKind: expect.anything(),
}),
]),
);
});
});
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: '当前缩放比例 100%' }),
).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.queryByRole('button', { name: '画布小地图' })).toBeNull();
expect(backgroundButton.className).toContain('platform-icon-button');
const minimapToggle = within(panelToolbar).getByRole('button', {
name: '切换小地图',
});
expect(minimapToggle).toBeTruthy();
fireEvent.click(minimapToggle);
expect(screen.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(minimapToggle);
expect(screen.queryByRole('button', { name: '画布小地图' })).toBeNull();
});
it('renders Agent generation thumbnails as passive previews', async () => {
enableEditorAgentSidebarForTest();
const detail = createEditorAgentDetailWithGeneration('resource-puzzle');
listEditorAgentConversationsMock.mockResolvedValueOnce([
createEditorAgentConversationSummary(),
]);
getEditorAgentConversationMock.mockResolvedValueOnce(detail);
render(<ImageCanvasEditorView />);
await ensureAgentConversationOpen();
const messageLog = await screen.findByRole('log', {
name: '画布 Agent 消息流',
});
expect(
within(messageLog).queryByRole('button', { name: '生成结果' }),
).toBeNull();
expect(within(messageLog).queryByText('gpt-image-2')).toBeNull();
expect(loadEditorProjectMock).not.toHaveBeenCalled();
});
it('refreshes canvas and asset library when Agent generation finishes', async () => {
enableEditorAgentSidebarForTest();
let showGeneratedAsset = false;
loadEditorAssetLibraryMock.mockImplementation(async () => ({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: showGeneratedAsset
? [
...defaultEditorAssetLibraryAssets,
{
assetId: 'asset-agent-stream',
folderId: 'project',
label: 'Agent结果素材',
imageSrc: '/agent-stream.png',
width: 512,
height: 512,
sourceType: 'generated',
assetKind: 'editor_agent_generated_image',
},
]
: defaultEditorAssetLibraryAssets,
}));
listEditorAgentConversationsMock.mockResolvedValueOnce([
createEditorAgentConversationSummary(),
]);
getEditorAgentConversationMock.mockResolvedValueOnce({
...createEditorAgentConversationSummary(),
messages: [],
});
streamEditorAgentMessageMock.mockImplementation(
async (conversationId, _payload, options) => {
options.onEvent?.({
event: 'message_delta',
data: {
conversationId,
messageId: 'assistant-agent-stream',
role: 'assistant',
kind: 'chat',
textDelta: '我来生成图片。',
},
});
showGeneratedAsset = true;
options.onEvent?.({
event: 'generation_result',
data: {
conversationId,
messageId: 'assistant-agent-stream',
toolCallId: 'tool-call-agent-stream',
toolName: 'generate_image',
model: 'gpt-image-2',
images: [
{
resourceId: 'resource-agent-stream',
imageSrc: '/agent-stream.png',
thumbnailSrc: null,
width: 512,
height: 512,
},
],
},
});
options.onEvent?.({
event: 'done',
data: { conversationId, title: null },
});
},
);
loadEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-default',
title: '默认项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
...defaultEditorProjectLayers,
{
layerId: 'layer-agent-stream',
resourceId: 'resource-agent-stream',
title: 'Agent结果图',
src: '/agent-stream.png',
x: 1500,
y: 300,
width: 512,
height: 512,
originalWidth: 512,
originalHeight: 512,
zIndex: 3,
sourceType: 'generated',
},
],
resources: [
...defaultEditorProjectResources,
{
resourceId: 'resource-agent-stream',
projectId: 'editor-project-default',
imageSrc: '/agent-stream.png',
width: 512,
height: 512,
sourceType: 'generated',
prompt: 'Agent生成结果',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-agent-stream',
assetKind: 'editor_agent_generated_image',
},
],
updatedAt: '2026-07-03T00:02:00.000Z',
});
render(<ImageCanvasEditorView />);
await ensureAgentConversationOpen();
await waitFor(() => {
expect(getEditorAgentConversationMock).toHaveBeenCalledWith(
'editor-agent-conv-test',
);
});
fireEvent.change(await screen.findByLabelText('发送给画布 Agent'), {
target: { value: '生成一张图' },
});
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(loadEditorProjectMock).toHaveBeenCalledWith(
'editor-project-default',
);
});
expect(await screen.findByAltText('画布图片:Agent结果图')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '打开素材' }));
expect(await screen.findByText('Agent结果素材')).toBeTruthy();
});
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('renders 100 percent zoom with the previous 50 percent visual scale', () => {
render(<ImageCanvasEditorView />);
expect(
screen.getByRole('button', { name: '当前缩放比例 100%' }),
).toBeTruthy();
expect(
(screen.getByTestId('image-canvas-world') as HTMLElement).style.transform,
).toBe('translate(0px, 0px) scale(0.5)');
});
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 = openMinimap();
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', async () => {
render(<ImageCanvasEditorView />);
const minimap = openMinimap();
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,
});
await waitFor(() => {
expect(readTranslateX()).toBeLessThan(0);
});
const translateAfterRightDrag = readTranslateX();
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', {
button: 0,
pointerId: 72,
clientX: 90,
clientY: 42,
});
await waitFor(() => {
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('switches tools and restores the previous tool after holding Space', async () => {
render(<ImageCanvasEditorView />);
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
const selectTool = within(bottomToolbar).getByRole('button', {
name: '选择工具',
});
const handTool = within(bottomToolbar).getByRole('button', {
name: '抓手工具',
});
expect(selectTool.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(selectTool.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 selectTool = within(bottomToolbar).getByRole('button', {
name: '选择工具',
});
await user.click(handTool);
expect(handTool.getAttribute('aria-pressed')).toBe('true');
await user.click(selectTool);
expect(selectTool.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: 30,
clientY: 150,
});
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 handTool = within(bottomToolbar).getByRole('button', {
name: '抓手工具',
});
await user.click(handTool);
expect(handTool.getAttribute('aria-pressed')).toBe('true');
expect(
screen.queryByTestId('image-canvas-editor-snap-guide-vertical'),
).toBeNull();
});
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,
);
openAssetSidebar();
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 />);
openAssetSidebar();
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();
});
});