2852 lines
93 KiB
TypeScript
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();
|
|
});
|
|
});
|