Files
Genarrative/src/components/image-editor/ImageCanvasEditorView.test.tsx
T

2852 lines
93 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 { EditorProjectResourceSnapshot } from '../../services/image-editor/editorProjectClient';
import type { EditorAgentConversationClient } from './EditorAgentConversation/useEditorAgentConversation';
import {
ApiClientError,
AuthUiContext,
createAuthValue,
createDeferred,
defaultEditorProjectLayers,
defaultEditorProjectResources,
dispatchPointerEvent,
ImageCanvasEditorView,
immediateAsync,
readZipText,
setupImageCanvasEditorViewTestLifecycle,
} from './ImageCanvasEditorView.test-utils';
type EditorAgentListConversations =
EditorAgentConversationClient['listConversations'];
type EditorAgentCreateConversation =
EditorAgentConversationClient['createConversation'];
type EditorAgentGetConversation =
EditorAgentConversationClient['getConversation'];
type EditorAgentDeleteConversation =
EditorAgentConversationClient['deleteConversation'];
type EditorAgentPersistMessage = EditorAgentConversationClient['persistMessage'];
type EditorAgentPlanMessage = EditorAgentConversationClient['planMessage'];
type EditorAgentConversationSummary = Awaited<
ReturnType<EditorAgentListConversations>
>[number];
type EditorAgentConversationDetail = Awaited<
ReturnType<EditorAgentGetConversation>
>;
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 persistEditorAgentMessageMock = vi.hoisted(() =>
vi.fn<
Parameters<EditorAgentPersistMessage>,
ReturnType<EditorAgentPersistMessage>
>(async (conversationId, payload) => ({
conversation: {
conversationId,
projectId: 'editor-project-default',
title: payload.text || '画布 Agent',
updatedAt: '2026-07-03T00:00:00.000Z',
},
userMessage: {
id: 0,
clientMessageId: payload.clientMessageId,
role: 'user',
text: payload.text,
attachments: payload.attachments ?? [],
toolCall: null,
createdAt: '2026-07-03T00:00:00.000Z',
},
})),
);
const sendEditorAgentMessageMock = vi.hoisted(() =>
vi.fn<
Parameters<EditorAgentPlanMessage>,
ReturnType<EditorAgentPlanMessage>
>(async () => ({
conversation: {
conversationId: 'editor-agent-conv-test',
projectId: 'editor-project-default',
title: '画布 Agent',
updatedAt: '2026-07-03T00:00:00.000Z',
},
deltaMessages: [],
errorMessage: null,
})),
);
const confirmEditorAgentToolCallMock = vi.hoisted(() => vi.fn());
const cancelEditorAgentToolCallMock = vi.hoisted(() => vi.fn());
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());
const loadFrontendRuntimeConfigMock = vi.hoisted(() => vi.fn());
const getPlatformProfileRechargeCenterMock = vi.hoisted(() => vi.fn());
const getPlatformProfileWalletLedgerMock = 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,
getPlatformProfileRechargeCenter: getPlatformProfileRechargeCenterMock,
getPlatformProfileWalletLedger: getPlatformProfileWalletLedgerMock,
}));
vi.mock('../../services/frontendRuntimeConfigService', () => ({
loadFrontendRuntimeConfig: loadFrontendRuntimeConfigMock,
}));
vi.mock('../../services/image-editor/editorAgentClient', () => ({
cancelEditorAgentToolCall: cancelEditorAgentToolCallMock,
confirmEditorAgentToolCall: confirmEditorAgentToolCallMock,
createEditorAgentConversation: createEditorAgentConversationMock,
deleteEditorAgentConversation: deleteEditorAgentConversationMock,
getEditorAgentConversation: getEditorAgentConversationMock,
listEditorAgentConversations: listEditorAgentConversationsMock,
persistEditorAgentMessage: persistEditorAgentMessageMock,
planEditorAgentMessage: sendEditorAgentMessageMock,
}));
function createEditorAgentConversationSummary(
overrides: Partial<EditorAgentConversationSummary> = {},
): EditorAgentConversationSummary {
return {
conversationId: 'editor-agent-conv-test',
projectId: 'editor-project-default',
title: '画布 Agent',
updatedAt: '2026-07-03T00:00:00.000Z',
...overrides,
};
}
function createEditImageToolCallDisplayArgs(prompt: string) {
return {
stringArgs: [{ name: 'prompt', label: '修改要求', value: prompt }],
imageArgs: [
{
name: 'object_image_id',
label: '目标图片',
refs: [
{
imageId: 'source-image',
imageSrc: '/source-image.png',
label: '源图片',
},
],
},
],
extras: { priceMudPoints: 3 },
};
}
function createEditorAgentDetailWithGeneration(
resourceId: string,
): EditorAgentConversationDetail {
const conversationSummary = createEditorAgentConversationSummary();
return {
...conversationSummary,
title: '画布 Agent',
createdAt: '2026-07-03T00:00:00.000Z',
messages: [
{
id: 0,
role: 'system',
text: 'internal completed tool output',
attachments: [],
toolCall: {
toolName: 'generate_image',
status: 'completed',
externalJobId: 'task-generation-1',
args: {},
displayArgs: {
stringArgs: [],
imageArgs: [],
extras: { priceMudPoints: 0 },
},
images: [
{
resourceId,
imageSrc: '/agent-result.png',
thumbnailSrc: '/agent-result-thumb.png',
width: 512,
height: 512,
},
],
error: null,
},
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 = await screen.findByRole('button', { name: '画布 Agent' });
if (agentButton.getAttribute('aria-pressed') !== 'true') {
fireEvent.click(agentButton);
}
return screen.findByLabelText('发送给画布 Agent');
}
function enableEditorAgentSidebarForTest() {
loadFrontendRuntimeConfigMock.mockResolvedValue({
imageEditorAgentSidebarEnabled: true,
});
}
describe('ImageCanvasEditorView', () => {
setupImageCanvasEditorViewTestLifecycle({
generateEditorImageMock,
generateEditorIconSpritesheetMock,
generateEditorCharacterAnimationMock,
editEditorImageMock,
createEditorAssetMock,
createEditorProjectResourceMock,
createEditorAssetFolderMock,
updateEditorAssetMock,
updateEditorAssetFolderMock,
deleteEditorAssetFolderMock,
deleteEditorAssetMock,
loadEditorAssetLibraryMock,
loadEditorProjectMock,
loadOrCreateRecentEditorProjectMock,
renameEditorProjectMock,
saveEditorProjectLayoutMock,
});
beforeEach(() => {
loadFrontendRuntimeConfigMock.mockImplementation(() =>
immediateAsync({
imageEditorAgentSidebarEnabled: false,
}),
);
listEditorAgentConversationsMock.mockResolvedValue([]);
createEditorAgentConversationMock.mockResolvedValue({
...createEditorAgentConversationSummary({
conversationId: 'editor-agent-conv-created',
}),
title: '画布 Agent',
createdAt: '2026-07-03T00:00:00.000Z',
messages: [],
});
getEditorAgentConversationMock.mockResolvedValue({
...createEditorAgentConversationSummary(),
title: '画布 Agent',
createdAt: '2026-07-03T00:00:00.000Z',
messages: [],
});
deleteEditorAgentConversationMock.mockResolvedValue(
createEditorAgentConversationSummary(),
);
persistEditorAgentMessageMock.mockImplementation(
async (conversationId, payload) => ({
conversation: createEditorAgentConversationSummary({ conversationId }),
userMessage: {
id: 0,
clientMessageId: payload.clientMessageId,
role: 'user',
text: payload.text,
attachments: payload.attachments ?? [],
toolCall: null,
createdAt: '2026-07-03T00:00:00.000Z',
},
}),
);
sendEditorAgentMessageMock.mockResolvedValue({
conversation: createEditorAgentConversationSummary(),
deltaMessages: [],
errorMessage: null,
});
confirmEditorAgentToolCallMock.mockReset();
cancelEditorAgentToolCallMock.mockReset();
getPlatformProfileDashboardMock.mockResolvedValue({
walletBalance: 1234,
totalPlayTimeMs: 0,
playedWorldCount: 0,
updatedAt: null,
});
getPlatformProfileRechargeCenterMock.mockResolvedValue({
walletBalance: 1234,
mudPointBalance: {
totalPoints: 1234,
permanentPoints: 1000,
limitedPoints: 214,
limitedExpiresAt: '2026-07-31T16:00:00Z',
dailyFreePoints: 20,
dailyFreeResetPoints: 20,
dailyFreeResetsAt: '2026-07-12T16:00:00Z',
},
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
cycleStartedAt: null,
cycleResetsAt: null,
cycleGrantedPoints: 0,
cycleRemainingPoints: 0,
cyclePeriodDays: 30,
},
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: false,
});
getPlatformProfileWalletLedgerMock.mockResolvedValue({
entries: [
{
id: 'editor-ledger-1',
amountDelta: -5,
balanceAfter: 1234,
sourceType: 'asset_operation_consume',
createdAt: '2026-07-12T08:00:00Z',
},
],
});
});
afterEach(() => {
listEditorAgentConversationsMock.mockReset();
createEditorAgentConversationMock.mockReset();
getEditorAgentConversationMock.mockReset();
deleteEditorAgentConversationMock.mockReset();
sendEditorAgentMessageMock.mockReset();
persistEditorAgentMessageMock.mockReset();
confirmEditorAgentToolCallMock.mockReset();
cancelEditorAgentToolCallMock.mockReset();
getPlatformProfileDashboardMock.mockReset();
getPlatformProfileRechargeCenterMock.mockReset();
getPlatformProfileWalletLedgerMock.mockReset();
loadFrontendRuntimeConfigMock.mockReset();
});
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('restores the canvas background color from the persisted project layout', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-background',
title: '背景色项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
itemType: 'canvas-settings',
layerId: 'canvas-settings:default',
resourceId: 'canvas-settings:default',
canvasBackgroundColor: '#112233',
},
],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
});
render(<ImageCanvasEditorView />);
const viewport = screen.getByLabelText('画布工作区');
await waitFor(() => {
expect((viewport as HTMLElement).style.backgroundColor).toBe(
'rgb(17, 34, 51)',
);
});
const panelToolbar = screen.getByRole('toolbar', { name: '画布面板入口' });
fireEvent.click(
within(panelToolbar).getByRole('button', { name: '画布背景色' }),
);
expect(
(
within(
screen.getByRole('dialog', { name: '画布背景设置' }),
).getByLabelText('画布背景十六进制颜色') as HTMLInputElement
).value,
).toBe('#112233');
});
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 shared wallet breakdown and ledger from the canvas topbar', async () => {
const user = userEvent.setup();
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',
});
await user.hover(walletButton);
const details = await screen.findByRole('dialog', {
name: '泥点账户详情',
});
expect(within(details).getByText('不限时泥点')).toBeTruthy();
expect(within(details).queryByText('限时泥点')).toBeNull();
expect(within(details).getByText('每日免费泥点')).toBeTruthy();
await user.click(within(details).getByRole('button', { name: '使用详情' }));
expect(
await screen.findByRole('dialog', { name: '泥点账单' }),
).toBeTruthy();
expect(getPlatformProfileWalletLedgerMock).toHaveBeenCalledTimes(1);
});
it('suspends canvas interaction during account payment dialogs and restores completed quick edit selections', 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 sourceLayer = (
await screen.findByAltText('画布图片:拼图素材')
).closest('button')!;
dispatchPointerEvent(sourceLayer, 'pointerdown', {
button: 0,
pointerId: 71,
clientX: 120,
clientY: 120,
});
fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));
const quickEditDialog = screen.getByRole('dialog', {
name: '快速编辑图片',
});
const selectionToolbar = screen.getByRole('toolbar', {
name: '快速编辑框选工具',
});
const rectTool = within(selectionToolbar).getByRole('button', {
name: '矩形框选',
});
fireEvent.click(rectTool);
const selectionCanvas = screen.getByRole('application', {
name: 'UI素材框选画布',
});
dispatchPointerEvent(selectionCanvas, 'pointerdown', {
pointerId: 72,
clientX: 20,
clientY: 20,
});
dispatchPointerEvent(selectionCanvas, 'pointermove', {
pointerId: 72,
clientX: 90,
clientY: 90,
});
dispatchPointerEvent(selectionCanvas, 'pointerup', {
pointerId: 72,
clientX: 90,
clientY: 90,
});
expect(
(
within(quickEditDialog).getByLabelText(
'快速编辑提示词',
) as HTMLTextAreaElement
).value,
).toContain('对1号红色圈选框里的内容做以下修改');
dispatchPointerEvent(selectionCanvas, 'pointerdown', {
pointerId: 73,
clientX: 30,
clientY: 30,
});
dispatchPointerEvent(selectionCanvas, 'pointermove', {
pointerId: 73,
clientX: 110,
clientY: 110,
});
fireEvent.keyDown(window, {
key: 'c',
code: 'KeyC',
ctrlKey: true,
});
const viewport = screen.getByLabelText('画布工作区');
fireEvent.click(
await screen.findByRole('button', {
name: '充值',
}),
);
expect(
await screen.findByRole('dialog', { name: '购买更多泥点' }),
).toBeTruthy();
fireEvent.keyDown(window, { key: 'Delete', code: 'Delete' });
fireEvent.contextMenu(viewport, { clientX: 320, clientY: 220 });
const pasteEvent = new Event('paste', {
bubbles: true,
cancelable: true,
});
act(() => {
window.dispatchEvent(pasteEvent);
});
expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy();
expect(screen.getAllByAltText('画布图片:拼图素材')).toHaveLength(1);
expect(screen.queryByRole('menu', { name: '画布右键菜单' })).toBeNull();
const pausedToolbar = screen.getByRole('toolbar', {
name: '快速编辑框选工具',
});
expect(
within(pausedToolbar)
.getByRole('button', { name: '矩形框选' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(viewport.getAttribute('aria-disabled')).toBe('true');
fireEvent.click(screen.getByRole('button', { name: '关闭购买更多泥点' }));
const resumedToolbar = await screen.findByRole('toolbar', {
name: '快速编辑框选工具',
});
expect(viewport.getAttribute('aria-disabled')).toBeNull();
expect(
within(resumedToolbar)
.getByRole('button', { name: '矩形框选' })
.getAttribute('aria-pressed'),
).toBe('true');
const resumedCanvas = screen.getByRole('application', {
name: 'UI素材框选画布',
});
dispatchPointerEvent(resumedCanvas, 'pointermove', {
pointerId: 73,
clientX: 110,
clientY: 110,
});
dispatchPointerEvent(resumedCanvas, 'pointerup', {
pointerId: 73,
clientX: 110,
clientY: 110,
});
const resumedPrompt = screen.getByLabelText(
'快速编辑提示词',
) as HTMLTextAreaElement;
expect(resumedPrompt.value).toContain('对1号红色圈选框里的内容做以下修改');
expect(resumedPrompt.value).not.toContain('2号红色圈选框');
});
it('locks quick edit selection tools after the edit task starts', async () => {
editEditorImageMock.mockReturnValueOnce(new Promise(() => {}));
render(<ImageCanvasEditorView />);
const sourceLayer = (
await screen.findByAltText('画布图片:拼图素材')
).closest('button')!;
dispatchPointerEvent(sourceLayer, 'pointerdown', {
button: 0,
pointerId: 74,
clientX: 120,
clientY: 120,
});
fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));
const quickEditDialog = screen.getByRole('dialog', {
name: '快速编辑图片',
});
fireEvent.change(within(quickEditDialog).getByLabelText('快速编辑提示词'), {
target: { value: '快速修图' },
});
fireEvent.click(
within(
screen.getByRole('toolbar', { name: '快速编辑框选工具' }),
).getByRole('button', { name: '矩形框选' }),
);
fireEvent.click(
within(quickEditDialog).getByRole('button', { name: '修改' }),
);
await waitFor(() => {
expect(editEditorImageMock).toHaveBeenCalledTimes(1);
});
expect(
screen.queryByRole('toolbar', { name: '快速编辑框选工具' }),
).toBeNull();
expect(
screen.queryByRole('application', { name: 'UI素材框选画布' }),
).toBeNull();
const submitButton = within(quickEditDialog).getByRole('button', {
name: '修改',
});
expect((submitButton as HTMLButtonElement).disabled).toBe(true);
expect(submitButton.textContent).toBe('生成中');
});
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('closes the Agent conversation before opening quick edit', async () => {
enableEditorAgentSidebarForTest();
render(<ImageCanvasEditorView />);
expect(await screen.findByLabelText('发送给画布 Agent')).toBeTruthy();
const sourceLayer = (
await screen.findByAltText('画布图片:拼图素材')
).closest('button')!;
fireEvent.click(sourceLayer);
fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));
expect(
await screen.findByRole('dialog', { name: '快速编辑图片' }),
).toBeTruthy();
expect(screen.queryByLabelText('发送给画布 Agent')).toBeNull();
});
it('closes the Agent conversation panel when runtime config disables it after focus', async () => {
loadFrontendRuntimeConfigMock
.mockResolvedValueOnce({
imageEditorAgentSidebarEnabled: true,
})
.mockResolvedValue({
imageEditorAgentSidebarEnabled: false,
});
render(<ImageCanvasEditorView />);
expect(await screen.findByLabelText('发送给画布 Agent')).toBeTruthy();
fireEvent.focus(window);
await waitFor(() => {
expect(screen.queryByRole('button', { name: '画布 Agent' })).toBeNull();
});
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 />);
await screen.findByRole('button', { name: '画布 Agent' });
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(await screen.findByRole('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', async () => {
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();
await act(async () => {
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('persists an undone asset kind and ignores the older pending response', async () => {
const characterResource = createDeferred<EditorProjectResourceSnapshot>();
const restoredResource = createDeferred<EditorProjectResourceSnapshot>();
createEditorProjectResourceMock
.mockImplementationOnce(() => characterResource.promise)
.mockImplementationOnce(() => restoredResource.promise);
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: '素材标签:角色' });
fireEvent.click(screen.getByRole('button', { name: '撤销' }));
await within(layerButton).findByRole('button', { name: '素材标签:未知' });
await waitFor(() => {
expect(createEditorProjectResourceMock).toHaveBeenCalledTimes(2);
});
expect(createEditorProjectResourceMock).toHaveBeenNthCalledWith(
2,
'editor-project-default',
expect.objectContaining({ assetKind: null }),
);
await act(async () => {
restoredResource.resolve({
resourceId: 'resource-restored-kind',
projectId: 'editor-project-default',
imageSrc: '/creation-type-references/puzzle.webp',
width: 640,
height: 640,
sourceType: 'uploaded',
assetKind: null,
});
await restoredResource.promise;
});
await waitFor(() => {
expect(
saveEditorProjectLayoutMock.mock.calls.some((call) =>
call[1].layers.some(
(layer: { resourceId?: unknown }) =>
layer.resourceId === 'resource-restored-kind',
),
),
).toBe(true);
});
await act(async () => {
characterResource.resolve({
resourceId: 'resource-stale-character',
projectId: 'editor-project-default',
imageSrc: '/creation-type-references/puzzle.webp',
width: 640,
height: 640,
sourceType: 'uploaded',
assetKind: 'character',
});
await characterResource.promise;
});
expect(
saveEditorProjectLayoutMock.mock.calls.some((call) =>
call[1].layers.some(
(layer: { resourceId?: unknown }) =>
layer.resourceId === 'resource-stale-character',
),
),
).toBe(false);
});
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();
expect(
within(settingsPanel).getByLabelText('画布背景十六进制颜色'),
).toBeTruthy();
expect(settingsPanel.querySelector('input[type="color"]')).toBeNull();
fireEvent.click(
within(settingsPanel).getByRole('button', { name: '暖灰' }),
);
expect((viewport as HTMLElement).style.backgroundColor).toBe(
'rgb(243, 240, 234)',
);
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('confirms Agent tool calls and refreshes the conversation for the job id', async () => {
enableEditorAgentSidebarForTest();
listEditorAgentConversationsMock.mockResolvedValueOnce([
createEditorAgentConversationSummary(),
]);
const pendingConversation: EditorAgentConversationDetail = {
...createEditorAgentConversationSummary(),
title: '画布 Agent',
createdAt: '2026-07-03T00:00:00.000Z',
messages: [
{
id: 3,
role: 'system',
text: 'internal pending tool prompt',
attachments: [],
toolCall: {
toolName: 'edit-image',
status: 'not_completed',
args: {
object_image_id: 'source-image',
prompt: '把图片换成像素风',
},
displayArgs:
createEditImageToolCallDisplayArgs('把图片换成像素风'),
images: [],
error: null,
},
createdAt: '2026-07-03T00:00:00.000Z',
},
],
};
const confirmedConversation: EditorAgentConversationDetail = {
...pendingConversation,
messages: [
{
id: 3,
role: 'system',
text: 'internal confirmed tool output',
attachments: [],
toolCall: {
toolName: 'edit-image',
status: 'not_completed',
externalJobId: 'task-edit-canvas-1',
args: {},
displayArgs:
createEditImageToolCallDisplayArgs('把图片换成像素风'),
images: [],
error: null,
},
createdAt: '2026-07-03T00:00:00.000Z',
},
],
};
getEditorAgentConversationMock
.mockResolvedValueOnce(pendingConversation)
.mockResolvedValue(confirmedConversation);
confirmEditorAgentToolCallMock.mockResolvedValue(undefined);
render(<ImageCanvasEditorView />);
await ensureAgentConversationOpen();
await waitFor(() => {
expect(getEditorAgentConversationMock).toHaveBeenCalledWith(
'editor-agent-conv-test',
);
});
const getConversationCallsBeforeConfirm =
getEditorAgentConversationMock.mock.calls.length;
fireEvent.click(await screen.findByRole('button', { name: '确认' }));
await waitFor(() => {
expect(confirmEditorAgentToolCallMock).toHaveBeenCalledWith(
'editor-agent-conv-test',
3,
);
});
expect(getEditorAgentConversationMock).toHaveBeenCalledTimes(
getConversationCallsBeforeConfirm + 1,
);
expect(await screen.findByText('执行中')).toBeTruthy();
expect(screen.queryByAltText('画布图片:Agent结果图')).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 two-axis canvas pan and ctrl wheel for zoom', () => {
render(<ImageCanvasEditorView />);
const viewport = screen.getByLabelText('画布工作区');
const world = screen.getByTestId('image-canvas-world') as HTMLElement;
expect(
screen.getByRole('button', { name: '当前缩放比例 100%' }),
).toBeTruthy();
fireEvent.wheel(viewport, {
deltaX: 80,
deltaY: 120,
clientX: 400,
clientY: 280,
});
expect(world.style.transform).toBe(
'translate(-80px, -120px) scale(0.5)',
);
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,
});
fireEvent(viewport, 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('blocks panel undo when an added image would disappear', async () => {
render(<ImageCanvasEditorView />);
expect(screen.getByRole('button', { name: '撤销' })).toHaveProperty(
'disabled',
true,
);
expect(screen.getByRole('button', { name: '恢复' })).toHaveProperty(
'disabled',
true,
);
openAssetSidebar();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' }));
});
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
expect(screen.getByRole('button', { name: '撤销' })).toHaveProperty(
'disabled',
false,
);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '撤销' }));
});
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
expect(
screen.getByText('无法撤销:添加图片操作,可能会使图片消失'),
).toBeTruthy();
fireEvent.keyDown(window, { key: 'Delete', code: 'Delete' });
expect(screen.queryByAltText('画布图片:声浪素材')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '撤销' }));
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
expect(screen.getByText('已撤销:删除图片操作')).toBeTruthy();
expect(screen.getByRole('button', { name: '恢复' })).toHaveProperty(
'disabled',
false,
);
fireEvent.click(screen.getByRole('button', { name: '恢复' }));
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
expect(
screen.getByText('无法恢复:删除图片操作,可能会使图片消失'),
).toBeTruthy();
});
it('blocks protected keyboard undo without creating a restore entry', async () => {
render(<ImageCanvasEditorView />);
openAssetSidebar();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' }));
});
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
fireEvent.keyDown(window, { key: 'z', code: 'KeyZ', ctrlKey: true });
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
expect(
screen.getByText('无法撤销:添加图片操作,可能会使图片消失'),
).toBeTruthy();
expect(screen.getByRole('button', { name: '恢复' })).toHaveProperty(
'disabled',
true,
);
await act(async () => {
fireEvent.keyDown(window, {
key: 'Z',
code: 'KeyZ',
ctrlKey: true,
shiftKey: true,
});
});
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
});
it('restores a safely undone move from the panel and keyboard shortcut', async () => {
render(<ImageCanvasEditorView />);
openAssetSidebar();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' }));
});
fireEvent.keyDown(window, { key: 'ArrowRight', code: 'ArrowRight' });
fireEvent.keyDown(window, { key: 'z', code: 'KeyZ', ctrlKey: true });
expect(screen.getByText('已撤销:移动图片操作')).toBeTruthy();
expect(screen.getByRole('button', { name: '恢复' })).toHaveProperty(
'disabled',
false,
);
fireEvent.click(screen.getByRole('button', { name: '恢复' }));
expect(screen.getByText('已恢复:移动图片操作')).toBeTruthy();
fireEvent.keyDown(window, { key: 'z', code: 'KeyZ', ctrlKey: true });
fireEvent.keyDown(window, {
key: 'Z',
code: 'KeyZ',
ctrlKey: true,
shiftKey: true,
});
expect(screen.getByText('已恢复:移动图片操作')).toBeTruthy();
});
});