ab61390121
打开画布时 Agent 开关启用则保持 Agent 默认展开。 恢复任务自动打开任务列表时不再抢占 Agent 默认态。 用户手动打开任务列表时仍按右侧面板互斥关闭 Agent。 补充恢复任务场景下 Agent 默认显示的回归测试。
2338 lines
76 KiB
TypeScript
2338 lines
76 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import {
|
|
act,
|
|
fireEvent,
|
|
render,
|
|
screen,
|
|
waitFor,
|
|
within,
|
|
} from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import JSZip from 'jszip';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import type { EditorAgentConversationClient } from './useEditorAgentConversation';
|
|
import {
|
|
ApiClientError,
|
|
AuthUiContext,
|
|
createAuthValue,
|
|
defaultEditorAssetLibraryAssets,
|
|
defaultEditorProjectLayers,
|
|
defaultEditorProjectResources,
|
|
dispatchPointerEvent,
|
|
ImageCanvasEditorView,
|
|
readZipText,
|
|
setupImageCanvasEditorViewTestLifecycle,
|
|
} from './ImageCanvasEditorView.test-utils';
|
|
|
|
type EditorAgentListConversations =
|
|
EditorAgentConversationClient['listConversations'];
|
|
type EditorAgentCreateConversation =
|
|
EditorAgentConversationClient['createConversation'];
|
|
type EditorAgentGetConversation =
|
|
EditorAgentConversationClient['getConversation'];
|
|
type EditorAgentDeleteConversation =
|
|
EditorAgentConversationClient['deleteConversation'];
|
|
type EditorAgentStreamMessage = EditorAgentConversationClient['streamMessage'];
|
|
type EditorAgentConversationSummary = Awaited<
|
|
ReturnType<EditorAgentListConversations>
|
|
>[number];
|
|
type EditorAgentConversationDetail = Awaited<
|
|
ReturnType<EditorAgentGetConversation>
|
|
>;
|
|
const EDITOR_AGENT_SIDEBAR_ENV = 'VITE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR';
|
|
|
|
const listEditorAgentConversationsMock = vi.hoisted(() =>
|
|
vi.fn<
|
|
Parameters<EditorAgentListConversations>,
|
|
ReturnType<EditorAgentListConversations>
|
|
>(async () => []),
|
|
);
|
|
const createEditorAgentConversationMock = vi.hoisted(() =>
|
|
vi.fn<
|
|
Parameters<EditorAgentCreateConversation>,
|
|
ReturnType<EditorAgentCreateConversation>
|
|
>(async () => ({
|
|
conversationId: 'editor-agent-conv-created',
|
|
projectId: 'editor-project-default',
|
|
title: '画布 Agent',
|
|
createdAt: '2026-07-03T00:00:00.000Z',
|
|
updatedAt: '2026-07-03T00:00:00.000Z',
|
|
messages: [],
|
|
})),
|
|
);
|
|
const getEditorAgentConversationMock = vi.hoisted(() =>
|
|
vi.fn<
|
|
Parameters<EditorAgentGetConversation>,
|
|
ReturnType<EditorAgentGetConversation>
|
|
>(async () => ({
|
|
conversationId: 'editor-agent-conv-test',
|
|
projectId: 'editor-project-default',
|
|
title: '画布 Agent',
|
|
createdAt: '2026-07-03T00:00:00.000Z',
|
|
updatedAt: '2026-07-03T00:00:00.000Z',
|
|
messages: [],
|
|
})),
|
|
);
|
|
const deleteEditorAgentConversationMock = vi.hoisted(() =>
|
|
vi.fn<
|
|
Parameters<EditorAgentDeleteConversation>,
|
|
ReturnType<EditorAgentDeleteConversation>
|
|
>(async () => ({
|
|
conversationId: 'editor-agent-conv-test',
|
|
projectId: 'editor-project-default',
|
|
title: '画布 Agent',
|
|
createdAt: '2026-07-03T00:00:00.000Z',
|
|
updatedAt: '2026-07-03T00:00:00.000Z',
|
|
})),
|
|
);
|
|
const streamEditorAgentMessageMock = vi.hoisted(() =>
|
|
vi.fn<
|
|
Parameters<EditorAgentStreamMessage>,
|
|
ReturnType<EditorAgentStreamMessage>
|
|
>(async () => undefined),
|
|
);
|
|
const generateEditorImageMock = vi.hoisted(() => vi.fn());
|
|
const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn());
|
|
const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn());
|
|
const editEditorImageMock = vi.hoisted(() => vi.fn());
|
|
const createEditorAssetMock = vi.hoisted(() => vi.fn());
|
|
const createEditorProjectResourceMock = vi.hoisted(() => vi.fn());
|
|
const createEditorAssetFolderMock = vi.hoisted(() => vi.fn());
|
|
const updateEditorAssetMock = vi.hoisted(() => vi.fn());
|
|
const updateEditorAssetFolderMock = vi.hoisted(() => vi.fn());
|
|
const deleteEditorAssetFolderMock = vi.hoisted(() => vi.fn());
|
|
const deleteEditorAssetMock = vi.hoisted(() => vi.fn());
|
|
const loadEditorAssetLibraryMock = vi.hoisted(() => vi.fn());
|
|
const loadEditorProjectMock = vi.hoisted(() => vi.fn());
|
|
const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn());
|
|
const renameEditorProjectMock = vi.hoisted(() => vi.fn());
|
|
const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn());
|
|
const getPlatformProfileDashboardMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
|
const actual = await vi.importActual<
|
|
typeof import('../../services/image-editor/editorProjectClient')
|
|
>('../../services/image-editor/editorProjectClient');
|
|
return {
|
|
...actual,
|
|
editEditorImage: editEditorImageMock,
|
|
createEditorAsset: createEditorAssetMock,
|
|
createEditorAssetFolder: createEditorAssetFolderMock,
|
|
createEditorProjectResource: createEditorProjectResourceMock,
|
|
deleteEditorAsset: deleteEditorAssetMock,
|
|
deleteEditorAssetFolder: deleteEditorAssetFolderMock,
|
|
generateEditorCharacterAnimation: generateEditorCharacterAnimationMock,
|
|
generateEditorIconSpritesheet: generateEditorIconSpritesheetMock,
|
|
generateEditorImage: generateEditorImageMock,
|
|
loadEditorAssetLibrary: loadEditorAssetLibraryMock,
|
|
loadEditorProject: loadEditorProjectMock,
|
|
loadOrCreateRecentEditorProject: loadOrCreateRecentEditorProjectMock,
|
|
renameEditorProject: renameEditorProjectMock,
|
|
saveEditorProjectLayout: saveEditorProjectLayoutMock,
|
|
updateEditorAsset: updateEditorAssetMock,
|
|
updateEditorAssetFolder: updateEditorAssetFolderMock,
|
|
};
|
|
});
|
|
|
|
vi.mock('../../services/platform-entry/platformProfileClient', () => ({
|
|
getPlatformProfileDashboard: getPlatformProfileDashboardMock,
|
|
}));
|
|
|
|
vi.mock('../../services/image-editor/editorAgentClient', () => ({
|
|
createEditorAgentConversation: createEditorAgentConversationMock,
|
|
deleteEditorAgentConversation: deleteEditorAgentConversationMock,
|
|
getEditorAgentConversation: getEditorAgentConversationMock,
|
|
listEditorAgentConversations: listEditorAgentConversationsMock,
|
|
streamEditorAgentMessage: streamEditorAgentMessageMock,
|
|
}));
|
|
|
|
function createEditorAgentConversationSummary(
|
|
overrides: Partial<EditorAgentConversationSummary> = {},
|
|
): EditorAgentConversationSummary {
|
|
return {
|
|
conversationId: 'editor-agent-conv-test',
|
|
projectId: 'editor-project-default',
|
|
title: '画布 Agent',
|
|
createdAt: '2026-07-03T00:00:00.000Z',
|
|
updatedAt: '2026-07-03T00:00:00.000Z',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createEditorAgentDetailWithGeneration(
|
|
resourceId: string,
|
|
): EditorAgentConversationDetail {
|
|
const summary = createEditorAgentConversationSummary();
|
|
return {
|
|
...summary,
|
|
messages: [
|
|
{
|
|
id: 'agent-message-result',
|
|
role: 'assistant',
|
|
kind: 'chat',
|
|
text: '生成完成',
|
|
attachments: [],
|
|
generations: [
|
|
{
|
|
toolCallId: 'agent-tool-call-1',
|
|
toolName: 'generate_image',
|
|
taskId: 'task-agent-1',
|
|
status: 'completed',
|
|
model: 'gpt-image-2',
|
|
images: [
|
|
{
|
|
resourceId,
|
|
imageSrc: '/agent-result.png',
|
|
thumbnailSrc: '/agent-result-thumb.png',
|
|
width: 512,
|
|
height: 512,
|
|
},
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
status: 'completed',
|
|
createdAt: '2026-07-03T00:00:01.000Z',
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function openAssetSidebar() {
|
|
fireEvent.click(screen.getByRole('button', { name: '打开素材' }));
|
|
return screen.getByRole('complementary', { name: '图片资源栏' });
|
|
}
|
|
|
|
function openMinimap() {
|
|
const panelToolbar = screen.getByRole('toolbar', { name: '画布面板入口' });
|
|
fireEvent.click(
|
|
within(panelToolbar).getByRole('button', { name: '切换小地图' }),
|
|
);
|
|
return screen.getByRole('button', { name: '画布小地图' });
|
|
}
|
|
|
|
async function ensureAgentConversationOpen() {
|
|
const agentButton = screen.getByRole('button', { name: '画布 Agent' });
|
|
if (agentButton.getAttribute('aria-pressed') !== 'true') {
|
|
fireEvent.click(agentButton);
|
|
}
|
|
return screen.findByLabelText('发送给画布 Agent');
|
|
}
|
|
|
|
function enableEditorAgentSidebarForTest() {
|
|
vi.stubEnv(EDITOR_AGENT_SIDEBAR_ENV, 'true');
|
|
}
|
|
|
|
describe('ImageCanvasEditorView', () => {
|
|
setupImageCanvasEditorViewTestLifecycle({
|
|
generateEditorImageMock,
|
|
generateEditorIconSpritesheetMock,
|
|
generateEditorCharacterAnimationMock,
|
|
editEditorImageMock,
|
|
createEditorAssetMock,
|
|
createEditorProjectResourceMock,
|
|
createEditorAssetFolderMock,
|
|
updateEditorAssetMock,
|
|
updateEditorAssetFolderMock,
|
|
deleteEditorAssetFolderMock,
|
|
deleteEditorAssetMock,
|
|
loadEditorAssetLibraryMock,
|
|
loadEditorProjectMock,
|
|
loadOrCreateRecentEditorProjectMock,
|
|
renameEditorProjectMock,
|
|
saveEditorProjectLayoutMock,
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.stubEnv(EDITOR_AGENT_SIDEBAR_ENV, 'false');
|
|
listEditorAgentConversationsMock.mockResolvedValue([]);
|
|
createEditorAgentConversationMock.mockResolvedValue({
|
|
...createEditorAgentConversationSummary({
|
|
conversationId: 'editor-agent-conv-created',
|
|
}),
|
|
messages: [],
|
|
});
|
|
getEditorAgentConversationMock.mockResolvedValue({
|
|
...createEditorAgentConversationSummary(),
|
|
messages: [],
|
|
});
|
|
deleteEditorAgentConversationMock.mockResolvedValue(
|
|
createEditorAgentConversationSummary(),
|
|
);
|
|
streamEditorAgentMessageMock.mockResolvedValue(undefined);
|
|
getPlatformProfileDashboardMock.mockResolvedValue({
|
|
walletBalance: 1234,
|
|
totalPlayTimeMs: 0,
|
|
playedWorldCount: 0,
|
|
updatedAt: null,
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
listEditorAgentConversationsMock.mockReset();
|
|
createEditorAgentConversationMock.mockReset();
|
|
getEditorAgentConversationMock.mockReset();
|
|
deleteEditorAgentConversationMock.mockReset();
|
|
streamEditorAgentMessageMock.mockReset();
|
|
getPlatformProfileDashboardMock.mockReset();
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
it('loads the project from projectid query before falling back to recent project', async () => {
|
|
listEditorAgentConversationsMock.mockResolvedValue([]);
|
|
loadEditorProjectMock.mockResolvedValueOnce({
|
|
projectId: 'editor-project-query',
|
|
title: '查询项目',
|
|
viewport: { x: 12, y: 16, scale: 0.8 },
|
|
layers: [],
|
|
resources: [],
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
});
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
'/editor/canvas?projectid=editor-project-query',
|
|
);
|
|
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
await waitFor(() => {
|
|
expect(loadEditorProjectMock).toHaveBeenCalledWith(
|
|
'editor-project-query',
|
|
);
|
|
});
|
|
expect(loadOrCreateRecentEditorProjectMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('shows the toolbar guide for a newly created blank project until a generator opens', async () => {
|
|
loadEditorProjectMock.mockResolvedValueOnce({
|
|
projectId: 'editor-project-guide',
|
|
title: '空白项目',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [],
|
|
resources: [],
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
});
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
'/editor/canvas?projectid=editor-project-guide&guide=toolbar',
|
|
);
|
|
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
expect(
|
|
await screen.findByText('工具栏在这里,快试着随便做点什么'),
|
|
).toBeTruthy();
|
|
expect(
|
|
document
|
|
.querySelector('.image-canvas-editor__toolbar-guide-art')
|
|
?.getAttribute('src'),
|
|
).toBe('/branding/taonier-toolbar-guide-bubble.png');
|
|
const bottomToolbar = screen.getByRole('toolbar', {
|
|
name: 'AI画布工具栏',
|
|
});
|
|
expect(bottomToolbar.className).toContain(
|
|
'image-canvas-editor__bottom-toolbar--guided',
|
|
);
|
|
expect(window.location.search).toBe('?projectid=editor-project-guide');
|
|
|
|
fireEvent.click(
|
|
within(bottomToolbar).getByRole('button', { name: '生成图片' }),
|
|
);
|
|
|
|
expect(
|
|
await screen.findByRole('dialog', { name: '生成图片' }),
|
|
).toBeTruthy();
|
|
await waitFor(() => {
|
|
expect(screen.queryByText('工具栏在这里,快试着随便做点什么')).toBeNull();
|
|
});
|
|
expect(bottomToolbar.className).not.toContain(
|
|
'image-canvas-editor__bottom-toolbar--guided',
|
|
);
|
|
});
|
|
|
|
it('opens the requested canvas startup tool from the project query once', async () => {
|
|
loadEditorProjectMock.mockResolvedValueOnce({
|
|
projectId: 'editor-project-music',
|
|
title: '音乐项目',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [],
|
|
resources: [],
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
});
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
'/editor/canvas?projectid=editor-project-music&tool=background-music',
|
|
);
|
|
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
expect(
|
|
await screen.findByRole('dialog', { name: '生成游戏背景音乐' }),
|
|
).toBeTruthy();
|
|
expect(screen.queryByText('工具栏在这里,快试着随便做点什么')).toBeNull();
|
|
expect(window.location.search).toBe('?projectid=editor-project-music');
|
|
});
|
|
|
|
it('shows the loaded project title and a topbar entry back to projects', async () => {
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
expect(
|
|
await screen.findByRole('heading', { name: '默认项目' }),
|
|
).toBeTruthy();
|
|
const projectLink = screen.getByRole('link', { name: '返回项目页面' });
|
|
|
|
expect(projectLink.getAttribute('href')).toBe('/project');
|
|
expect(screen.queryByRole('heading', { name: '图片编辑器' })).toBeNull();
|
|
});
|
|
|
|
it('opens login modal when the asset library is unauthorized', async () => {
|
|
const openLoginModal = vi.fn();
|
|
loadEditorAssetLibraryMock.mockRejectedValueOnce(
|
|
new ApiClientError({
|
|
message: '未授权访问',
|
|
status: 401,
|
|
code: 'UNAUTHORIZED',
|
|
}),
|
|
);
|
|
|
|
render(
|
|
<AuthUiContext.Provider
|
|
value={createAuthValue({
|
|
user: {
|
|
id: 'user-1',
|
|
publicUserCode: 'U001',
|
|
displayName: '测试用户',
|
|
avatarUrl: null,
|
|
phoneNumberMasked: '138****0000',
|
|
loginMethod: 'password',
|
|
bindingStatus: 'active',
|
|
wechatBound: false,
|
|
},
|
|
canAccessProtectedData: true,
|
|
openLoginModal,
|
|
})}
|
|
>
|
|
<ImageCanvasEditorView />
|
|
</AuthUiContext.Provider>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(openLoginModal).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
it('shows the live mud point balance in the canvas topbar when logged in', async () => {
|
|
render(
|
|
<AuthUiContext.Provider
|
|
value={createAuthValue({
|
|
user: {
|
|
id: 'user-1',
|
|
publicUserCode: 'U001',
|
|
displayName: '测试用户',
|
|
avatarUrl: null,
|
|
phoneNumberMasked: '138****0000',
|
|
loginMethod: 'password',
|
|
bindingStatus: 'active',
|
|
wechatBound: false,
|
|
},
|
|
canAccessProtectedData: true,
|
|
})}
|
|
>
|
|
<ImageCanvasEditorView />
|
|
</AuthUiContext.Provider>,
|
|
);
|
|
|
|
expect(await screen.findByLabelText('泥点余额 1,234泥点')).toBeTruthy();
|
|
expect(getPlatformProfileDashboardMock).toHaveBeenCalledWith({
|
|
authImpact: 'local',
|
|
skipRefresh: true,
|
|
notifyAuthStateChange: false,
|
|
clearAuthOnUnauthorized: false,
|
|
});
|
|
});
|
|
|
|
it('opens the account modal from the canvas topbar avatar entry', async () => {
|
|
const openAccountModal = vi.fn();
|
|
render(
|
|
<AuthUiContext.Provider
|
|
value={createAuthValue({
|
|
user: {
|
|
id: 'user-1',
|
|
publicUserCode: 'U001',
|
|
displayName: '测试用户',
|
|
avatarUrl: 'data:image/png;base64,AVATAR',
|
|
phoneNumberMasked: '138****0000',
|
|
loginMethod: 'password',
|
|
bindingStatus: 'active',
|
|
wechatBound: false,
|
|
},
|
|
canAccessProtectedData: true,
|
|
openAccountModal,
|
|
})}
|
|
>
|
|
<ImageCanvasEditorView />
|
|
</AuthUiContext.Provider>,
|
|
);
|
|
|
|
const accountButton = await screen.findByRole('button', {
|
|
name: '账号 测试用户',
|
|
});
|
|
|
|
expect(accountButton.querySelector('img')?.getAttribute('src')).toBe(
|
|
'data:image/png;base64,AVATAR',
|
|
);
|
|
|
|
fireEvent.click(accountButton);
|
|
|
|
expect(openAccountModal).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('opens the same account wallet entry from the canvas topbar mud point button', async () => {
|
|
render(
|
|
<AuthUiContext.Provider
|
|
value={createAuthValue({
|
|
user: {
|
|
id: 'user-1',
|
|
publicUserCode: 'U001',
|
|
displayName: '测试用户',
|
|
avatarUrl: null,
|
|
phoneNumberMasked: '138****0000',
|
|
loginMethod: 'password',
|
|
bindingStatus: 'active',
|
|
wechatBound: false,
|
|
},
|
|
canAccessProtectedData: true,
|
|
})}
|
|
>
|
|
<ImageCanvasEditorView />
|
|
</AuthUiContext.Provider>,
|
|
);
|
|
|
|
const walletButton = await screen.findByRole('button', {
|
|
name: '泥点余额 1,234泥点',
|
|
});
|
|
|
|
fireEvent.click(walletButton);
|
|
|
|
expect(await screen.findByRole('dialog', { name: '兑换码' })).toBeTruthy();
|
|
expect(screen.getByPlaceholderText('输入兑换码')).toBeTruthy();
|
|
});
|
|
|
|
it('opens the login modal immediately when entering the editor while logged out', async () => {
|
|
const openLoginModal = vi.fn();
|
|
|
|
render(
|
|
<AuthUiContext.Provider value={createAuthValue({ openLoginModal })}>
|
|
<ImageCanvasEditorView />
|
|
</AuthUiContext.Provider>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(openLoginModal).toHaveBeenCalledTimes(1);
|
|
});
|
|
expect(typeof openLoginModal.mock.calls[0]?.[0]).toBe('function');
|
|
expect(loadEditorAssetLibraryMock).not.toHaveBeenCalled();
|
|
expect(loadEditorProjectMock).not.toHaveBeenCalled();
|
|
expect(loadOrCreateRecentEditorProjectMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('renames the current project from the canvas topbar', async () => {
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
await screen.findByRole('heading', { name: '默认项目' });
|
|
fireEvent.click(screen.getByRole('button', { name: '编辑项目名称' }));
|
|
fireEvent.change(screen.getByLabelText('项目名称'), {
|
|
target: { value: '新画布项目' },
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '保存项目名称' }));
|
|
|
|
await waitFor(() => {
|
|
expect(renameEditorProjectMock).toHaveBeenCalledWith(
|
|
'editor-project-default',
|
|
'新画布项目',
|
|
);
|
|
});
|
|
expect(
|
|
await screen.findByRole('heading', { name: '新画布项目' }),
|
|
).toBeTruthy();
|
|
});
|
|
|
|
it('does not inject built-in mock assets when the persisted library is empty', async () => {
|
|
listEditorAgentConversationsMock.mockResolvedValue([]);
|
|
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
|
projectId: 'editor-project-empty',
|
|
title: '空画布',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [],
|
|
resources: [],
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
});
|
|
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
|
folders: [
|
|
{
|
|
folderId: 'project',
|
|
label: '项目素材',
|
|
sortOrder: 0,
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
},
|
|
],
|
|
assets: [],
|
|
});
|
|
|
|
render(<ImageCanvasEditorView />);
|
|
openAssetSidebar();
|
|
|
|
expect(
|
|
await screen.findByRole('region', { name: '项目素材' }),
|
|
).toBeTruthy();
|
|
expect(screen.queryByRole('button', { name: '添加拼图素材' })).toBeNull();
|
|
expect(screen.queryByRole('button', { name: '添加大鱼素材' })).toBeNull();
|
|
expect(screen.queryByAltText(/画布图片:拼图素材/u)).toBeNull();
|
|
});
|
|
|
|
it('exports valid canvas assets as a zip from the topbar with metadata', async () => {
|
|
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
|
projectId: 'editor-project-export',
|
|
title: '导出项目',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [
|
|
{
|
|
layerId: 'layer-data-a',
|
|
resourceId: 'resource-data-a',
|
|
title: '素材/A',
|
|
src: 'data:image/png;base64,YQ==',
|
|
x: 12,
|
|
y: 24,
|
|
width: 320,
|
|
height: 220,
|
|
originalWidth: 640,
|
|
originalHeight: 440,
|
|
zIndex: 1,
|
|
sourceType: 'uploaded',
|
|
sourceAssetId: 'asset-data-a',
|
|
groupId: 'group-a',
|
|
hidden: true,
|
|
locked: true,
|
|
flipX: true,
|
|
},
|
|
{
|
|
layerId: 'layer-data-a-copy',
|
|
resourceId: 'resource-data-a-copy',
|
|
title: '素材/A 副本',
|
|
src: 'data:image/png;base64,YQ==',
|
|
x: 42,
|
|
y: 54,
|
|
width: 320,
|
|
height: 220,
|
|
originalWidth: 640,
|
|
originalHeight: 440,
|
|
zIndex: 2,
|
|
sourceType: 'uploaded',
|
|
sourceAssetId: 'asset-data-a',
|
|
},
|
|
{
|
|
layerId: 'layer-generated',
|
|
resourceId: 'resource-generated',
|
|
title: '生成图',
|
|
src: '/generated-ok.png',
|
|
x: 70,
|
|
y: 80,
|
|
width: 360,
|
|
height: 360,
|
|
originalWidth: 1024,
|
|
originalHeight: 1024,
|
|
zIndex: 3,
|
|
sourceType: 'generated',
|
|
sourceResourceId: 'resource-generated',
|
|
prompt: '明亮主视觉',
|
|
actualPrompt: '后端拼接提示词',
|
|
model: 'gpt-image-2',
|
|
provider: 'VectorEngine',
|
|
taskId: 'task-1',
|
|
assetObjectId: 'asset-object-visible',
|
|
generationInputs: {
|
|
fields: [{ title: '生成提示词', value: '明亮主视觉' }],
|
|
references: [],
|
|
},
|
|
},
|
|
{
|
|
layerId: 'layer-failed',
|
|
resourceId: 'resource-failed',
|
|
title: '失败图',
|
|
src: '/missing.png',
|
|
x: 90,
|
|
y: 100,
|
|
width: 120,
|
|
height: 120,
|
|
originalWidth: 120,
|
|
originalHeight: 120,
|
|
zIndex: 4,
|
|
sourceType: 'generated',
|
|
sourceResourceId: 'resource-failed',
|
|
},
|
|
],
|
|
resources: [],
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
});
|
|
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
|
folders: [
|
|
{
|
|
folderId: 'project',
|
|
label: '项目素材',
|
|
sortOrder: 0,
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
},
|
|
],
|
|
assets: [
|
|
{
|
|
assetId: 'asset-data-a',
|
|
folderId: 'project',
|
|
label: '素材/A',
|
|
imageSrc: 'data:image/png;base64,YQ==',
|
|
width: 640,
|
|
height: 440,
|
|
sourceType: 'uploaded',
|
|
},
|
|
],
|
|
});
|
|
const originalFetch = globalThis.fetch;
|
|
const fetchMock = vi.fn(async (url: string) => {
|
|
const pathname = new URL(url, 'http://localhost').pathname;
|
|
if (pathname === '/generated-ok.png') {
|
|
return new Response(new Blob(['generated'], { type: 'image/png' }));
|
|
}
|
|
return new Response(null, { status: 404 });
|
|
});
|
|
globalThis.fetch = fetchMock as typeof fetch;
|
|
const originalCreateObjectUrl = URL.createObjectURL;
|
|
const originalRevokeObjectUrl = URL.revokeObjectURL;
|
|
const originalAnchorClick = HTMLAnchorElement.prototype.click;
|
|
let exportedBlob: Blob | null = null;
|
|
let downloadName = '';
|
|
URL.createObjectURL = vi.fn((blob: Blob) => {
|
|
exportedBlob = blob;
|
|
return 'blob:editor-export';
|
|
});
|
|
URL.revokeObjectURL = vi.fn();
|
|
HTMLAnchorElement.prototype.click = vi.fn(function click(
|
|
this: HTMLAnchorElement,
|
|
) {
|
|
downloadName = this.download;
|
|
});
|
|
|
|
try {
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
await screen.findByRole('heading', { name: '导出项目' });
|
|
await waitFor(() => {
|
|
expect(
|
|
(
|
|
screen.getByRole('button', {
|
|
name: '下载画布素材',
|
|
}) as HTMLButtonElement
|
|
).disabled,
|
|
).toBe(false);
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '下载画布素材' }));
|
|
|
|
await waitFor(() => {
|
|
expect(exportedBlob).toBeTruthy();
|
|
});
|
|
expect(downloadName).toMatch(/^导出项目-画布素材-\d{8}\.zip$/u);
|
|
|
|
const zip = await JSZip.loadAsync(exportedBlob!);
|
|
expect(zip.file('导出项目-画布素材/images/001-素材 A.png')).toBeTruthy();
|
|
expect(zip.file('导出项目-画布素材/images/002-生成图.png')).toBeTruthy();
|
|
expect(zip.file('导出项目-画布素材/images/003-失败图.png')).toBeNull();
|
|
|
|
const metadata = JSON.parse(
|
|
await readZipText(zip, '导出项目-画布素材/metadata.json'),
|
|
);
|
|
expect(metadata.projectId).toBeUndefined();
|
|
expect(metadata.layers).toHaveLength(4);
|
|
expect(metadata.layers[0].file).toBe('images/001-素材 A.png');
|
|
expect(metadata.layers[1].file).toBe('images/001-素材 A.png');
|
|
expect(metadata.layers[0].canvas).toBeUndefined();
|
|
expect(metadata.layers[0].visible.type).toBe('上传图片');
|
|
expect(metadata.layers[2].visible).toEqual({
|
|
type: '生成图片',
|
|
generationInputs: {
|
|
fields: [{ title: '生成提示词', value: '明亮主视觉' }],
|
|
references: [],
|
|
},
|
|
model: 'gpt-image-2',
|
|
task: '1',
|
|
object: 'asset-object-visible',
|
|
resolution: '1024 x 1024 px',
|
|
});
|
|
expect(metadata.layers[2].sourceType).toBeUndefined();
|
|
expect(metadata.layers[2].prompt).toBeUndefined();
|
|
expect(metadata.layers[2].actualPrompt).toBeUndefined();
|
|
expect(metadata.layers[2].provider).toBeUndefined();
|
|
expect(metadata.layers[2].sourceResourceId).toBeUndefined();
|
|
expect(metadata.layers[3].file).toBeNull();
|
|
expect(metadata.layers[3].exportError).toContain('404');
|
|
expect(metadata.failedImages).toEqual([
|
|
{ title: '失败图', error: expect.stringContaining('404') },
|
|
]);
|
|
expect(
|
|
await readZipText(zip, '导出项目-画布素材/manifest.txt'),
|
|
).toContain('失败素材数量:1');
|
|
expect(screen.getByText('部分素材未能导出')).toBeTruthy();
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
URL.createObjectURL = originalCreateObjectUrl;
|
|
URL.revokeObjectURL = originalRevokeObjectUrl;
|
|
HTMLAnchorElement.prototype.click = originalAnchorClick;
|
|
}
|
|
});
|
|
|
|
it('disables the canvas asset export entry when there are no valid layers', async () => {
|
|
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
|
projectId: 'editor-project-empty-export',
|
|
title: '空导出项目',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [],
|
|
resources: [],
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
});
|
|
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
|
folders: [
|
|
{
|
|
folderId: 'project',
|
|
label: '项目素材',
|
|
sortOrder: 0,
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
},
|
|
],
|
|
assets: [],
|
|
});
|
|
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
await screen.findByRole('heading', { name: '空导出项目' });
|
|
expect(
|
|
(
|
|
screen.getByRole('button', {
|
|
name: '下载画布素材',
|
|
}) as HTMLButtonElement
|
|
).disabled,
|
|
).toBe(true);
|
|
});
|
|
|
|
it('shows image resolution on hover and opens the crop-expand panel after selecting a layer', () => {
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
const canvasImage = screen.getByAltText('画布图片:拼图素材');
|
|
fireEvent.mouseEnter(canvasImage.closest('button')!);
|
|
|
|
const sizeBadge = screen.getByText('640 x 640 px');
|
|
expect(sizeBadge.className).toContain('rounded-full');
|
|
expect(sizeBadge.className).toContain('image-canvas-editor__size-badge');
|
|
|
|
fireEvent.pointerDown(canvasImage.closest('button')!, {
|
|
button: 0,
|
|
pointerId: 1,
|
|
clientX: 120,
|
|
clientY: 120,
|
|
});
|
|
|
|
const cropButton = screen.getByRole('button', { name: '裁扩按钮' });
|
|
fireEvent.pointerDown(cropButton, {
|
|
button: 0,
|
|
pointerId: 2,
|
|
clientX: 120,
|
|
clientY: 96,
|
|
});
|
|
fireEvent.click(cropButton);
|
|
|
|
expect(screen.getByRole('dialog', { name: '裁扩图片' })).toBeTruthy();
|
|
expect(
|
|
screen
|
|
.getByRole('button', { name: '自由比例' })
|
|
.getAttribute('aria-pressed'),
|
|
).toBe('true');
|
|
expect(screen.queryByLabelText('裁扩左')).toBeNull();
|
|
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).toBeTruthy();
|
|
});
|
|
|
|
it('opens image info for uploaded canvas images without generated edit tools', () => {
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
fireEvent.pointerDown(
|
|
screen.getByAltText('画布图片:拼图素材').closest('button')!,
|
|
{
|
|
button: 0,
|
|
pointerId: 61,
|
|
clientX: 120,
|
|
clientY: 120,
|
|
},
|
|
);
|
|
|
|
const infoButton = screen.getByRole('button', {
|
|
name: '查看拼图素材图片信息',
|
|
});
|
|
expect(infoButton.className).toContain(
|
|
'image-canvas-editor__metadata-corner',
|
|
);
|
|
fireEvent.click(infoButton);
|
|
|
|
const infoPanel = screen.getByRole('dialog', { name: '图片信息' });
|
|
expect(within(infoPanel).getByText('图片类型')).toBeTruthy();
|
|
expect(within(infoPanel).getByText('上传图片')).toBeTruthy();
|
|
expect(within(infoPanel).getByText('生成输入')).toBeTruthy();
|
|
expect(
|
|
infoPanel.querySelector('.image-canvas-editor__metadata-inputs')
|
|
?.textContent,
|
|
).toBe('-');
|
|
expect(within(infoPanel).queryByText('Prompt')).toBeNull();
|
|
expect(within(infoPanel).getByText('Model')).toBeTruthy();
|
|
expect(within(infoPanel).queryByText('Size')).toBeNull();
|
|
expect(within(infoPanel).getByText('Resolution')).toBeTruthy();
|
|
expect(within(infoPanel).getByText('640 x 640 px')).toBeTruthy();
|
|
expect(
|
|
within(infoPanel).queryByRole('button', { name: '复制Prompt' }),
|
|
).toBeNull();
|
|
expect(screen.getByRole('button', { name: '改造' })).toBeTruthy();
|
|
});
|
|
|
|
it('hydrates canvas images from Resolution instead of saved Size', async () => {
|
|
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
|
projectId: 'editor-project-resolution',
|
|
title: '原分辨率画布',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [
|
|
{
|
|
layerId: 'layer-resolution',
|
|
resourceId: 'resource-resolution',
|
|
title: '旧布局图片',
|
|
src: 'data:image/png;base64,cmVzb2x1dGlvbg==',
|
|
x: 120,
|
|
y: 140,
|
|
width: 320,
|
|
height: 240,
|
|
originalWidth: 1536,
|
|
originalHeight: 1024,
|
|
zIndex: 2,
|
|
sourceType: 'generated',
|
|
model: 'gpt-image-2',
|
|
provider: 'VectorEngine',
|
|
taskId: 'resolution-task-1',
|
|
},
|
|
],
|
|
resources: [],
|
|
updatedAt: '2026-06-16T00:00:00.000Z',
|
|
});
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
const canvasImage = await screen.findByAltText('画布图片:旧布局图片');
|
|
const canvasLayer = canvasImage.closest('button') as HTMLElement;
|
|
expect(Number.parseFloat(canvasLayer.style.width)).toBe(1536);
|
|
expect(Number.parseFloat(canvasLayer.style.height)).toBe(1024);
|
|
|
|
fireEvent.mouseEnter(canvasLayer);
|
|
expect(screen.getByText('1536 x 1024 px')).toBeTruthy();
|
|
fireEvent.click(
|
|
screen.getAllByRole('button', { name: '查看旧布局图片图片信息' })[0]!,
|
|
);
|
|
const infoPanel = screen.getByRole('dialog', { name: '图片信息' });
|
|
expect(within(infoPanel).queryByText('Size')).toBeNull();
|
|
expect(within(infoPanel).getByText('Resolution')).toBeTruthy();
|
|
expect(within(infoPanel).getByText('1536 x 1024 px')).toBeTruthy();
|
|
});
|
|
|
|
it('downloads the selected layer from the floating toolbar', async () => {
|
|
let downloadName = '';
|
|
let downloadHref = '';
|
|
const originalFetch = globalThis.fetch;
|
|
const originalCreateObjectUrl = URL.createObjectURL;
|
|
const originalRevokeObjectUrl = URL.revokeObjectURL;
|
|
globalThis.fetch = vi.fn(async () => {
|
|
return new Response(new Blob(['puzzle'], { type: 'image/webp' }));
|
|
}) as typeof fetch;
|
|
URL.createObjectURL = vi.fn(() => 'blob:layer-download');
|
|
URL.revokeObjectURL = vi.fn();
|
|
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(
|
|
function click(this: HTMLAnchorElement) {
|
|
downloadName = this.download;
|
|
downloadHref = this.href;
|
|
},
|
|
);
|
|
try {
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy();
|
|
|
|
fireEvent.pointerDown(
|
|
screen.getByAltText('画布图片:拼图素材').closest('button')!,
|
|
{
|
|
button: 0,
|
|
pointerId: 51,
|
|
clientX: 120,
|
|
clientY: 120,
|
|
},
|
|
);
|
|
fireEvent.click(screen.getByRole('button', { name: '下载按钮' }));
|
|
|
|
await waitFor(() => {
|
|
expect(downloadName).toBe('拼图素材.webp');
|
|
});
|
|
expect(downloadHref).toBe('blob:layer-download');
|
|
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
expect.stringContaining('/creation-type-references/puzzle.webp'),
|
|
expect.any(Object),
|
|
);
|
|
expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy();
|
|
expect(screen.getByAltText('画布图片:大鱼素材')).toBeTruthy();
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
URL.createObjectURL = originalCreateObjectUrl;
|
|
URL.revokeObjectURL = originalRevokeObjectUrl;
|
|
}
|
|
});
|
|
|
|
it('deletes the selected layer with Backspace when focus is outside text inputs', async () => {
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy();
|
|
|
|
fireEvent.pointerDown(
|
|
screen.getByAltText('画布图片:拼图素材').closest('button')!,
|
|
{
|
|
button: 0,
|
|
pointerId: 52,
|
|
clientX: 120,
|
|
clientY: 120,
|
|
},
|
|
);
|
|
await act(async () => {
|
|
fireEvent.keyDown(window, { key: 'Backspace', code: 'Backspace' });
|
|
});
|
|
|
|
expect(screen.queryByAltText('画布图片:拼图素材')).toBeNull();
|
|
expect(screen.getByAltText('画布图片:大鱼素材')).toBeTruthy();
|
|
});
|
|
|
|
it('moves focus from the Agent input back to the canvas layer before handling shortcuts', async () => {
|
|
enableEditorAgentSidebarForTest();
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
const agentInput = await ensureAgentConversationOpen();
|
|
agentInput.focus();
|
|
expect(document.activeElement).toBe(agentInput);
|
|
|
|
const layerButton = screen
|
|
.getByAltText('画布图片:拼图素材')
|
|
.closest('button')!;
|
|
fireEvent.pointerDown(layerButton, {
|
|
button: 0,
|
|
pointerId: 53,
|
|
clientX: 120,
|
|
clientY: 120,
|
|
});
|
|
|
|
expect(document.activeElement).toBe(layerButton);
|
|
await act(async () => {
|
|
fireEvent.keyDown(window, { key: 'Backspace', code: 'Backspace' });
|
|
});
|
|
|
|
expect(screen.queryByAltText('画布图片:拼图素材')).toBeNull();
|
|
expect(screen.getByAltText('画布图片:大鱼素材')).toBeTruthy();
|
|
});
|
|
|
|
it('opens the matching audio generation panel when remodeling an existing sound effect layer', async () => {
|
|
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
|
projectId: 'editor-project-audio-remodel',
|
|
title: '音频改造画布',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [
|
|
{
|
|
layerId: 'layer-sound-effect',
|
|
resourceId: 'resource-sound-effect',
|
|
title: '游戏音效',
|
|
src: '/generated-character-drafts/editor-audios/sound.wav',
|
|
mediaType: 'audio',
|
|
assetKind: 'sound-effect',
|
|
x: 120,
|
|
y: 140,
|
|
width: 420,
|
|
height: 120,
|
|
originalWidth: 420,
|
|
originalHeight: 120,
|
|
zIndex: 2,
|
|
sourceType: 'generated',
|
|
prompt: '金币跳出音',
|
|
model: 'audio1.0',
|
|
generationInputs: {
|
|
fields: [
|
|
{ title: '音效提示词', value: '金币跳出音' },
|
|
{ title: 'model', value: 'audio1.0' },
|
|
{ title: 'duration', value: '8秒' },
|
|
],
|
|
references: [
|
|
{
|
|
label: '历史参考图',
|
|
src: '/creation-type-references/puzzle.webp',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
resources: [],
|
|
updatedAt: '2026-06-19T00:00:00.000Z',
|
|
});
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
const audioLayer = await screen.findByRole('button', {
|
|
name: '选择游戏音效',
|
|
});
|
|
fireEvent.pointerDown(audioLayer, {
|
|
button: 0,
|
|
pointerId: 54,
|
|
clientX: 160,
|
|
clientY: 180,
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '改造' }));
|
|
|
|
const panel = screen.getByRole('dialog', { name: '生成游戏音效' });
|
|
expect(within(panel).getByDisplayValue('金币跳出音')).toBeTruthy();
|
|
expect(
|
|
within(panel).getByRole('button', { name: '音效时长 8秒' }),
|
|
).toBeTruthy();
|
|
expect(
|
|
panel.querySelector('.image-canvas-editor__reference-strip'),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('deletes the selected audio layer with Backspace from the audio control focus', async () => {
|
|
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
|
projectId: 'editor-project-audio-delete',
|
|
title: '音频删除画布',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [
|
|
{
|
|
layerId: 'layer-audio',
|
|
resourceId: 'resource-audio',
|
|
title: '背景音乐',
|
|
src: '/generated-character-drafts/editor-audios/bgm.mp3',
|
|
mediaType: 'audio',
|
|
assetKind: 'background-music',
|
|
x: 120,
|
|
y: 140,
|
|
width: 420,
|
|
height: 120,
|
|
originalWidth: 420,
|
|
originalHeight: 120,
|
|
zIndex: 2,
|
|
sourceType: 'generated',
|
|
},
|
|
{
|
|
layerId: 'layer-reference',
|
|
resourceId: 'resource-reference',
|
|
title: '参考图片',
|
|
src: '/creation-type-references/puzzle.webp',
|
|
x: 620,
|
|
y: 140,
|
|
width: 320,
|
|
height: 320,
|
|
originalWidth: 320,
|
|
originalHeight: 320,
|
|
zIndex: 1,
|
|
sourceType: 'uploaded',
|
|
},
|
|
],
|
|
resources: [],
|
|
updatedAt: '2026-06-18T00:00:00.000Z',
|
|
});
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
const audioControl = await screen.findByLabelText('画布音频:背景音乐');
|
|
fireEvent.pointerDown(audioControl, {
|
|
button: 0,
|
|
pointerId: 53,
|
|
clientX: 160,
|
|
clientY: 180,
|
|
});
|
|
await act(async () => {
|
|
fireEvent.keyDown(audioControl, { key: 'Backspace', code: 'Backspace' });
|
|
});
|
|
|
|
expect(screen.queryByLabelText('画布音频:背景音乐')).toBeNull();
|
|
expect(screen.getByAltText('画布图片:参考图片')).toBeTruthy();
|
|
});
|
|
|
|
it('blocks the browser context menu inside the editor workspace', () => {
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
const editor = screen.getByRole('region', { name: '图片画布编辑器' });
|
|
const contextMenuEvent = new MouseEvent('contextmenu', {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
});
|
|
const wasNotCanceled = editor.dispatchEvent(contextMenuEvent);
|
|
|
|
expect(wasNotCanceled).toBe(false);
|
|
expect(contextMenuEvent.defaultPrevented).toBe(true);
|
|
});
|
|
|
|
it('shows the blank canvas context menu with paste, zoom, and fit all', () => {
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
const viewport = screen.getByLabelText('画布工作区');
|
|
fireEvent.contextMenu(viewport, {
|
|
clientX: 320,
|
|
clientY: 220,
|
|
});
|
|
|
|
const menu = screen.getByRole('menu', { name: '画布右键菜单' });
|
|
expect(
|
|
(
|
|
within(menu).getByRole('menuitem', {
|
|
name: '粘贴',
|
|
}) as HTMLButtonElement
|
|
).disabled,
|
|
).toBe(false);
|
|
expect(within(menu).getByRole('menuitem', { name: '放大' })).toBeTruthy();
|
|
expect(
|
|
within(menu).getByRole('menuitem', { name: '显示画布所有元素' }),
|
|
).toBeTruthy();
|
|
});
|
|
|
|
it('keeps right-clicking a canvas layer from falling through to blank pan menu handling', () => {
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
const layerButton = screen
|
|
.getByAltText('画布图片:拼图素材')
|
|
.closest('button')!;
|
|
const rightPointerDown = new MouseEvent('pointerdown', {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
button: 2,
|
|
clientX: 510,
|
|
clientY: 330,
|
|
});
|
|
|
|
const wasNotCanceled = layerButton.dispatchEvent(rightPointerDown);
|
|
|
|
expect(wasNotCanceled).toBe(true);
|
|
expect(rightPointerDown.defaultPrevented).toBe(false);
|
|
|
|
fireEvent.contextMenu(layerButton, {
|
|
clientX: 510,
|
|
clientY: 330,
|
|
});
|
|
|
|
expect(screen.getByRole('menu', { name: '图片功能面板' })).toBeTruthy();
|
|
expect(screen.getByRole('menuitem', { name: '创建副本' })).toBeTruthy();
|
|
});
|
|
|
|
it('shows delete when right-clicking a generation placeholder', async () => {
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
|
|
|
|
const frame = await screen.findByLabelText('图像生成占位图');
|
|
fireEvent.contextMenu(frame, {
|
|
clientX: 510,
|
|
clientY: 330,
|
|
});
|
|
|
|
const menu = screen.getByRole('menu', { name: '生成器右键菜单' });
|
|
fireEvent.click(within(menu).getByRole('menuitem', { name: '删除' }));
|
|
|
|
expect(screen.queryByLabelText('图像生成占位图')).toBeNull();
|
|
});
|
|
|
|
it('copies, cuts, and pastes layers from the context menus', () => {
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
fireEvent.contextMenu(
|
|
screen.getByAltText('画布图片:拼图素材').closest('button')!,
|
|
{
|
|
clientX: 510,
|
|
clientY: 330,
|
|
},
|
|
);
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '复制' }));
|
|
|
|
fireEvent.contextMenu(screen.getByLabelText('画布工作区'), {
|
|
clientX: 360,
|
|
clientY: 240,
|
|
});
|
|
const copyPasteMenu = screen.getByRole('menu', { name: '画布右键菜单' });
|
|
expect(
|
|
(
|
|
within(copyPasteMenu).getByRole('menuitem', {
|
|
name: '粘贴',
|
|
}) as HTMLButtonElement
|
|
).disabled,
|
|
).toBe(false);
|
|
fireEvent.click(
|
|
within(copyPasteMenu).getByRole('menuitem', { name: '粘贴' }),
|
|
);
|
|
expect(screen.getAllByAltText(/画布图片:拼图素材/u)).toHaveLength(2);
|
|
|
|
fireEvent.contextMenu(
|
|
screen.getByAltText('画布图片:大鱼素材').closest('button')!,
|
|
{
|
|
clientX: 950,
|
|
clientY: 380,
|
|
},
|
|
);
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '剪切' }));
|
|
expect(screen.queryByAltText('画布图片:大鱼素材')).toBeNull();
|
|
|
|
fireEvent.contextMenu(screen.getByLabelText('画布工作区'), {
|
|
clientX: 420,
|
|
clientY: 260,
|
|
});
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '粘贴' }));
|
|
expect(screen.getByAltText('画布图片:大鱼素材')).toBeTruthy();
|
|
});
|
|
|
|
it('handles layer context menu duplicate, ordering, hide, lock, flip, group, ungroup, and delete', async () => {
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
const firstLayer = screen
|
|
.getByAltText('画布图片:拼图素材')
|
|
.closest('button')!;
|
|
fireEvent.contextMenu(firstLayer, { clientX: 510, clientY: 330 });
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '创建副本' }));
|
|
expect(screen.getAllByAltText(/画布图片:拼图素材/u)).toHaveLength(2);
|
|
|
|
const copiedLayer = screen
|
|
.getAllByAltText(/画布图片:拼图素材/u)[1]!
|
|
.closest('button')!;
|
|
fireEvent.contextMenu(copiedLayer, { clientX: 540, clientY: 360 });
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '水平翻转' }));
|
|
expect(
|
|
(screen.getAllByAltText(/画布图片:拼图素材/u)[1] as HTMLElement).style
|
|
.transform,
|
|
).toBe('scale(-1, 1)');
|
|
|
|
fireEvent.contextMenu(copiedLayer, { clientX: 540, clientY: 360 });
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '锁定' }));
|
|
await waitFor(() => {
|
|
expect(copiedLayer.className).toContain(
|
|
'image-canvas-editor__layer--locked',
|
|
);
|
|
});
|
|
|
|
fireEvent.contextMenu(copiedLayer, { clientX: 540, clientY: 360 });
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '隐藏' }));
|
|
expect(screen.getAllByAltText(/画布图片:拼图素材/u)).toHaveLength(1);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '打开图层' }));
|
|
expect(screen.getByText(/已隐藏/u)).toBeTruthy();
|
|
fireEvent.contextMenu(
|
|
screen.getByText(/已隐藏/u).closest('.image-canvas-editor__layer-row')!,
|
|
{
|
|
clientX: 80,
|
|
clientY: 220,
|
|
},
|
|
);
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '显示' }));
|
|
expect(screen.getAllByAltText(/画布图片:拼图素材/u)).toHaveLength(2);
|
|
|
|
const bigFishLayer = screen
|
|
.getByAltText('画布图片:大鱼素材')
|
|
.closest('button')!;
|
|
fireEvent.contextMenu(bigFishLayer, { clientX: 950, clientY: 380 });
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '置于顶层' }));
|
|
expect(Number.parseInt(bigFishLayer.style.zIndex, 10)).toBeGreaterThan(2);
|
|
|
|
fireEvent.contextMenu(bigFishLayer, { clientX: 950, clientY: 380 });
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '下移一层' }));
|
|
expect(Number.parseInt(bigFishLayer.style.zIndex, 10)).toBeGreaterThan(0);
|
|
|
|
fireEvent.keyDown(window, { key: 'Shift', code: 'ShiftLeft' });
|
|
fireEvent.pointerDown(
|
|
screen.getByAltText('画布图片:拼图素材').closest('button')!,
|
|
{
|
|
button: 0,
|
|
pointerId: 181,
|
|
clientX: 520,
|
|
clientY: 380,
|
|
shiftKey: true,
|
|
},
|
|
);
|
|
fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
|
|
pointerId: 181,
|
|
clientX: 520,
|
|
clientY: 380,
|
|
});
|
|
fireEvent.keyUp(window, { key: 'Shift', code: 'ShiftLeft' });
|
|
fireEvent.contextMenu(bigFishLayer, { clientX: 950, clientY: 380 });
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '创建组' }));
|
|
await waitFor(() => {
|
|
expect(screen.getAllByText(/已打组/u).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
fireEvent.contextMenu(bigFishLayer, { clientX: 950, clientY: 380 });
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '解除组' }));
|
|
await waitFor(() => {
|
|
expect(screen.queryByText(/已打组/u)).toBeNull();
|
|
});
|
|
|
|
fireEvent.contextMenu(bigFishLayer, { clientX: 950, clientY: 380 });
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '删除' }));
|
|
expect(screen.queryByAltText('画布图片:大鱼素材')).toBeNull();
|
|
});
|
|
|
|
it('switches the shared sidebar between assets and layers', () => {
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
expect(
|
|
screen.queryByRole('complementary', { name: '图片资源栏' }),
|
|
).toBeNull();
|
|
const sidebar = openAssetSidebar();
|
|
expect(within(sidebar).getByText('素材')).toBeTruthy();
|
|
expect(within(sidebar).queryByText('已生成文件')).toBeNull();
|
|
expect(within(sidebar).queryByText('图层')).toBeNull();
|
|
expect(screen.queryByRole('toolbar', { name: '画布主工具栏' })).toBeNull();
|
|
expect(
|
|
screen.queryByRole('complementary', { name: '图层面板' }),
|
|
).toBeNull();
|
|
expect(screen.queryByRole('dialog', { name: '已生成文件' })).toBeNull();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '打开图层' }));
|
|
const layersPanel = screen.getByRole('complementary', {
|
|
name: '图片资源栏',
|
|
});
|
|
expect(
|
|
within(layersPanel).getByRole('button', { name: '选择图层拼图素材' }),
|
|
).toBeTruthy();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '选择图层大鱼素材' }));
|
|
|
|
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).toBeTruthy();
|
|
expect(
|
|
screen.getByRole('button', { name: '查看大鱼素材图片信息' }),
|
|
).toBeTruthy();
|
|
expect(screen.getByRole('button', { name: '改造' })).toBeTruthy();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '打开素材' }));
|
|
|
|
expect(screen.getByRole('button', { name: '添加拼图素材' })).toBeTruthy();
|
|
});
|
|
|
|
it('hides the right Agent conversation entry by default', () => {
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
expect(screen.queryByRole('button', { name: '画布 Agent' })).toBeNull();
|
|
expect(
|
|
screen.queryByRole('button', { name: '打开画布 Agent' }),
|
|
).toBeNull();
|
|
expect(screen.queryByLabelText('发送给画布 Agent')).toBeNull();
|
|
expect(listEditorAgentConversationsMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('opens the right Agent conversation panel by default when enabled', async () => {
|
|
enableEditorAgentSidebarForTest();
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
expect(await screen.findByLabelText('发送给画布 Agent')).toBeTruthy();
|
|
expect(
|
|
screen.queryByRole('complementary', { name: '画布任务列表' }),
|
|
).toBeNull();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '画布 Agent' }));
|
|
|
|
expect(screen.queryByLabelText('发送给画布 Agent')).toBeNull();
|
|
});
|
|
|
|
it('keeps the Agent conversation panel open when restored tasks auto-open the task list', async () => {
|
|
enableEditorAgentSidebarForTest();
|
|
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
|
projectId: 'editor-project-with-restored-task',
|
|
title: '恢复任务项目',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [
|
|
...defaultEditorProjectLayers,
|
|
{
|
|
layerId: 'generation-dialog:restored-task',
|
|
resourceId: 'generation-dialog:restored-task',
|
|
itemType: 'generation-dialog',
|
|
dialog: {
|
|
id: 'restored-task',
|
|
mode: 'generate',
|
|
prompt: '继续生成',
|
|
status: 'idle',
|
|
},
|
|
},
|
|
],
|
|
resources: defaultEditorProjectResources,
|
|
updatedAt: '2026-07-06T00:00:00.000Z',
|
|
});
|
|
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
expect(await screen.findByLabelText('发送给画布 Agent')).toBeTruthy();
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.queryByRole('complementary', { name: '画布任务列表' }),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
it('keeps the left resource sidebar independent from right-side task and Agent panels', async () => {
|
|
enableEditorAgentSidebarForTest();
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
const assetSidebar = openAssetSidebar();
|
|
expect(within(assetSidebar).getByText('素材')).toBeTruthy();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '任务列表' }));
|
|
expect(
|
|
screen.getByRole('complementary', { name: '画布任务列表' }),
|
|
).toBeTruthy();
|
|
expect(
|
|
screen.getByRole('complementary', { name: '图片资源栏' }),
|
|
).toBeTruthy();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '打开图层' }));
|
|
const layerSidebar = screen.getByRole('complementary', {
|
|
name: '图片资源栏',
|
|
});
|
|
expect(within(layerSidebar).getByText('图层')).toBeTruthy();
|
|
expect(
|
|
screen.getByRole('complementary', { name: '画布任务列表' }),
|
|
).toBeTruthy();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '画布 Agent' }));
|
|
expect(
|
|
screen.getByRole('complementary', { name: '图片资源栏' }),
|
|
).toBeTruthy();
|
|
expect(
|
|
screen.queryByRole('complementary', { name: '画布任务列表' }),
|
|
).toBeNull();
|
|
expect(await screen.findByLabelText('发送给画布 Agent')).toBeTruthy();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '任务列表' }));
|
|
expect(
|
|
screen.getByRole('complementary', { name: '图片资源栏' }),
|
|
).toBeTruthy();
|
|
expect(
|
|
screen.getByRole('complementary', { name: '画布任务列表' }),
|
|
).toBeTruthy();
|
|
expect(screen.queryByLabelText('发送给画布 Agent')).toBeNull();
|
|
});
|
|
|
|
it('adds assets from the sidebar and supports zoom buttons', () => {
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
expect(
|
|
screen.getByRole('button', { name: '当前缩放比例 100%' }).className,
|
|
).toContain('platform-inline-option-button');
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 100%' }));
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '放大' }));
|
|
expect(
|
|
screen.getByRole('button', { name: '当前缩放比例 116%' }),
|
|
).toBeTruthy();
|
|
|
|
openAssetSidebar();
|
|
fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' }));
|
|
|
|
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
|
|
expect(
|
|
screen.getByRole('complementary', { name: '图片资源栏' }),
|
|
).toBeTruthy();
|
|
});
|
|
|
|
it('saves canvas layout without embedding image payloads in layer snapshots', async () => {
|
|
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
|
folders: [
|
|
{
|
|
folderId: 'project',
|
|
label: '项目素材',
|
|
sortOrder: 0,
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
},
|
|
],
|
|
assets: [
|
|
{
|
|
assetId: 'asset-data-heavy',
|
|
folderId: 'project',
|
|
label: '大图素材',
|
|
imageSrc: 'data:image/png;base64,'.concat('a'.repeat(4000)),
|
|
width: 1024,
|
|
height: 768,
|
|
sourceType: 'uploaded',
|
|
},
|
|
],
|
|
});
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
openAssetSidebar();
|
|
await screen.findByRole('button', { name: '添加大图素材' });
|
|
fireEvent.click(screen.getByRole('button', { name: '添加大图素材' }));
|
|
|
|
await waitFor(() => {
|
|
expect(saveEditorProjectLayoutMock).toHaveBeenCalled();
|
|
});
|
|
const lastLayout = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1];
|
|
|
|
expect(lastLayout.layers).toEqual(
|
|
expect.arrayContaining([
|
|
expect.not.objectContaining({
|
|
src: expect.stringMatching(/^data:image/u),
|
|
}),
|
|
]),
|
|
);
|
|
expect(lastLayout.layers).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
sourceAssetId: 'asset-data-heavy',
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('saves manually selected layer tags in the canvas layout', async () => {
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
const layerButton = await screen.findByRole('button', {
|
|
name: '选择拼图素材',
|
|
});
|
|
fireEvent.click(
|
|
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
|
|
);
|
|
fireEvent.click(
|
|
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
|
|
'menuitem',
|
|
{ name: '角色' },
|
|
),
|
|
);
|
|
|
|
await screen.findByRole('button', { name: '素材标签:角色' });
|
|
|
|
await waitFor(() => {
|
|
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
|
|
'editor-project-default',
|
|
expect.objectContaining({
|
|
imageSrc: '/creation-type-references/puzzle.webp',
|
|
assetKind: 'character',
|
|
}),
|
|
);
|
|
});
|
|
await waitFor(() => {
|
|
const savedLayout = saveEditorProjectLayoutMock.mock.calls
|
|
.map((call) => call[1])
|
|
.find((layout) =>
|
|
layout.layers.some(
|
|
(layer: { resourceId?: unknown; title?: unknown }) =>
|
|
layer.title === '拼图素材' &&
|
|
layer.resourceId === 'resource-editor-project-default-640',
|
|
),
|
|
);
|
|
|
|
expect(savedLayout).toBeTruthy();
|
|
expect(savedLayout.layers).toEqual(
|
|
expect.arrayContaining([
|
|
expect.not.objectContaining({
|
|
title: '拼图素材',
|
|
assetKind: expect.anything(),
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('offers Lovart-style zoom menu commands', async () => {
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 100%' }));
|
|
|
|
expect(screen.getByRole('menu', { name: '缩放菜单' })).toBeTruthy();
|
|
expect(
|
|
screen.getByRole('menuitem', { name: '显示画布所有元素' }),
|
|
).toBeTruthy();
|
|
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '缩放至100%' }));
|
|
expect(
|
|
screen.getByRole('button', { name: '当前缩放比例 100%' }),
|
|
).toBeTruthy();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 100%' }));
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '缩放至50%' }));
|
|
expect(
|
|
screen.getByRole('button', { name: '当前缩放比例 50%' }),
|
|
).toBeTruthy();
|
|
});
|
|
|
|
it('shows the Lovart-style minimap and canvas background settings panel', () => {
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
const viewport = screen.getByLabelText('画布工作区');
|
|
const panelToolbar = screen.getByRole('toolbar', { name: '画布面板入口' });
|
|
const backgroundButton = within(panelToolbar).getByRole('button', {
|
|
name: '画布背景色',
|
|
});
|
|
|
|
expect(screen.queryByRole('button', { name: '画布小地图' })).toBeNull();
|
|
expect(backgroundButton.className).toContain('platform-icon-button');
|
|
const minimapToggle = within(panelToolbar).getByRole('button', {
|
|
name: '切换小地图',
|
|
});
|
|
expect(minimapToggle).toBeTruthy();
|
|
fireEvent.click(minimapToggle);
|
|
expect(screen.getByRole('button', { name: '画布小地图' })).toBeTruthy();
|
|
|
|
fireEvent.click(backgroundButton);
|
|
const settingsPanel = screen.getByRole('dialog', {
|
|
name: '画布背景设置',
|
|
});
|
|
expect(within(settingsPanel).getByText('画布背景')).toBeTruthy();
|
|
expect(within(settingsPanel).getByLabelText('画布背景色相')).toBeTruthy();
|
|
expect(
|
|
within(settingsPanel).getByLabelText('画布背景十六进制颜色'),
|
|
).toBeTruthy();
|
|
|
|
fireEvent.click(
|
|
within(settingsPanel).getByRole('button', { name: '暖灰' }),
|
|
);
|
|
|
|
expect((viewport as HTMLElement).style.backgroundColor).toBe(
|
|
'rgb(243, 240, 234)',
|
|
);
|
|
|
|
fireEvent.change(within(settingsPanel).getByLabelText('自定义画布背景色'), {
|
|
target: { value: '#ffffff' },
|
|
});
|
|
expect((viewport as HTMLElement).style.backgroundColor).toBe(
|
|
'rgb(255, 255, 255)',
|
|
);
|
|
|
|
const hexInput =
|
|
within(settingsPanel).getByLabelText('画布背景十六进制颜色');
|
|
fireEvent.change(hexInput, { target: { value: '#abc' } });
|
|
expect((hexInput as HTMLInputElement).value).toBe('#aabbcc');
|
|
expect((viewport as HTMLElement).style.backgroundColor).toBe(
|
|
'rgb(170, 187, 204)',
|
|
);
|
|
|
|
fireEvent.change(hexInput, { target: { value: '#not-a-color' } });
|
|
expect((hexInput as HTMLInputElement).value).toBe('#not-a-color');
|
|
expect((viewport as HTMLElement).style.backgroundColor).toBe(
|
|
'rgb(170, 187, 204)',
|
|
);
|
|
|
|
fireEvent.click(
|
|
within(settingsPanel).getByRole('button', { name: '恢复默认' }),
|
|
);
|
|
expect((viewport as HTMLElement).style.backgroundColor).toBe(
|
|
'rgb(248, 250, 252)',
|
|
);
|
|
|
|
fireEvent.keyDown(window, { key: 'Escape' });
|
|
expect(screen.queryByRole('dialog', { name: '画布背景设置' })).toBeNull();
|
|
|
|
fireEvent.click(minimapToggle);
|
|
expect(screen.queryByRole('button', { name: '画布小地图' })).toBeNull();
|
|
});
|
|
|
|
it('renders Agent generation thumbnails as passive previews', async () => {
|
|
enableEditorAgentSidebarForTest();
|
|
const detail = createEditorAgentDetailWithGeneration('resource-puzzle');
|
|
listEditorAgentConversationsMock.mockResolvedValueOnce([
|
|
createEditorAgentConversationSummary(),
|
|
]);
|
|
getEditorAgentConversationMock.mockResolvedValueOnce(detail);
|
|
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
await ensureAgentConversationOpen();
|
|
const messageLog = await screen.findByRole('log', {
|
|
name: '画布 Agent 消息流',
|
|
});
|
|
|
|
expect(
|
|
within(messageLog).queryByRole('button', { name: '生成结果' }),
|
|
).toBeNull();
|
|
expect(within(messageLog).queryByText('gpt-image-2')).toBeNull();
|
|
expect(loadEditorProjectMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('refreshes canvas and asset library when Agent generation finishes', async () => {
|
|
enableEditorAgentSidebarForTest();
|
|
let showGeneratedAsset = false;
|
|
loadEditorAssetLibraryMock.mockImplementation(async () => ({
|
|
folders: [
|
|
{
|
|
folderId: 'project',
|
|
label: '项目素材',
|
|
sortOrder: 0,
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
},
|
|
],
|
|
assets: showGeneratedAsset
|
|
? [
|
|
...defaultEditorAssetLibraryAssets,
|
|
{
|
|
assetId: 'asset-agent-stream',
|
|
folderId: 'project',
|
|
label: 'Agent结果素材',
|
|
imageSrc: '/agent-stream.png',
|
|
width: 512,
|
|
height: 512,
|
|
sourceType: 'generated',
|
|
assetKind: 'editor_agent_generated_image',
|
|
},
|
|
]
|
|
: defaultEditorAssetLibraryAssets,
|
|
}));
|
|
listEditorAgentConversationsMock.mockResolvedValueOnce([
|
|
createEditorAgentConversationSummary(),
|
|
]);
|
|
getEditorAgentConversationMock.mockResolvedValueOnce({
|
|
...createEditorAgentConversationSummary(),
|
|
messages: [],
|
|
});
|
|
streamEditorAgentMessageMock.mockImplementation(
|
|
async (conversationId, _payload, options) => {
|
|
options.onEvent?.({
|
|
event: 'message_delta',
|
|
data: {
|
|
conversationId,
|
|
messageId: 'assistant-agent-stream',
|
|
role: 'assistant',
|
|
kind: 'chat',
|
|
textDelta: '我来生成图片。',
|
|
},
|
|
});
|
|
showGeneratedAsset = true;
|
|
options.onEvent?.({
|
|
event: 'generation_result',
|
|
data: {
|
|
conversationId,
|
|
messageId: 'assistant-agent-stream',
|
|
toolCallId: 'tool-call-agent-stream',
|
|
toolName: 'generate_image',
|
|
model: 'gpt-image-2',
|
|
images: [
|
|
{
|
|
resourceId: 'resource-agent-stream',
|
|
imageSrc: '/agent-stream.png',
|
|
thumbnailSrc: null,
|
|
width: 512,
|
|
height: 512,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
options.onEvent?.({
|
|
event: 'done',
|
|
data: { conversationId, title: null },
|
|
});
|
|
},
|
|
);
|
|
loadEditorProjectMock.mockResolvedValueOnce({
|
|
projectId: 'editor-project-default',
|
|
title: '默认项目',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [
|
|
...defaultEditorProjectLayers,
|
|
{
|
|
layerId: 'layer-agent-stream',
|
|
resourceId: 'resource-agent-stream',
|
|
title: 'Agent结果图',
|
|
src: '/agent-stream.png',
|
|
x: 1500,
|
|
y: 300,
|
|
width: 512,
|
|
height: 512,
|
|
originalWidth: 512,
|
|
originalHeight: 512,
|
|
zIndex: 3,
|
|
sourceType: 'generated',
|
|
},
|
|
],
|
|
resources: [
|
|
...defaultEditorProjectResources,
|
|
{
|
|
resourceId: 'resource-agent-stream',
|
|
projectId: 'editor-project-default',
|
|
imageSrc: '/agent-stream.png',
|
|
width: 512,
|
|
height: 512,
|
|
sourceType: 'generated',
|
|
prompt: 'Agent生成结果',
|
|
model: 'gpt-image-2',
|
|
provider: 'VectorEngine',
|
|
taskId: 'task-agent-stream',
|
|
assetKind: 'editor_agent_generated_image',
|
|
},
|
|
],
|
|
updatedAt: '2026-07-03T00:02:00.000Z',
|
|
});
|
|
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
await ensureAgentConversationOpen();
|
|
await waitFor(() => {
|
|
expect(getEditorAgentConversationMock).toHaveBeenCalledWith(
|
|
'editor-agent-conv-test',
|
|
);
|
|
});
|
|
fireEvent.change(await screen.findByLabelText('发送给画布 Agent'), {
|
|
target: { value: '生成一张图' },
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
|
|
|
await waitFor(() => {
|
|
expect(loadEditorProjectMock).toHaveBeenCalledWith(
|
|
'editor-project-default',
|
|
);
|
|
});
|
|
expect(await screen.findByAltText('画布图片:Agent结果图')).toBeTruthy();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '打开素材' }));
|
|
expect(await screen.findByText('Agent结果素材')).toBeTruthy();
|
|
});
|
|
|
|
it('resets the canvas view without forwarding the click event to fit layers', () => {
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
expect(() => {
|
|
fireEvent.click(screen.getByRole('button', { name: '重置画布视图' }));
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it('uses normal wheel for vertical canvas scroll and ctrl wheel for zoom', () => {
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
const viewport = screen.getByLabelText('画布工作区');
|
|
expect(
|
|
screen.getByRole('button', { name: '当前缩放比例 100%' }),
|
|
).toBeTruthy();
|
|
|
|
fireEvent.wheel(viewport, { deltaY: 120, clientX: 400, clientY: 280 });
|
|
expect(
|
|
screen.getByRole('button', { name: '当前缩放比例 100%' }),
|
|
).toBeTruthy();
|
|
|
|
fireEvent.wheel(viewport, {
|
|
deltaY: -120,
|
|
ctrlKey: true,
|
|
clientX: 400,
|
|
clientY: 280,
|
|
});
|
|
expect(
|
|
screen.getByRole('button', { name: '当前缩放比例 110%' }),
|
|
).toBeTruthy();
|
|
|
|
const ctrlWheelEvent = new WheelEvent('wheel', {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
ctrlKey: true,
|
|
deltaY: -120,
|
|
clientX: 400,
|
|
clientY: 280,
|
|
});
|
|
viewport.dispatchEvent(ctrlWheelEvent);
|
|
expect(ctrlWheelEvent.defaultPrevented).toBe(true);
|
|
});
|
|
|
|
it('renders 100 percent zoom with the previous 50 percent visual scale', () => {
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
expect(
|
|
screen.getByRole('button', { name: '当前缩放比例 100%' }),
|
|
).toBeTruthy();
|
|
expect(
|
|
(screen.getByTestId('image-canvas-world') as HTMLElement).style.transform,
|
|
).toBe('translate(0px, 0px) scale(0.5)');
|
|
});
|
|
|
|
it('selects multiple canvas layers with shift click', async () => {
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
const firstLayer = screen
|
|
.getByAltText('画布图片:拼图素材')
|
|
.closest('button')!;
|
|
const secondLayer = screen
|
|
.getByAltText('画布图片:大鱼素材')
|
|
.closest('button')!;
|
|
|
|
fireEvent.pointerDown(firstLayer, {
|
|
button: 0,
|
|
pointerId: 81,
|
|
clientX: 120,
|
|
clientY: 120,
|
|
});
|
|
fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
|
|
pointerId: 81,
|
|
clientX: 120,
|
|
clientY: 120,
|
|
});
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByAltText('画布图片:拼图素材').closest('button')?.className,
|
|
).toContain('image-canvas-editor__layer--selected');
|
|
});
|
|
fireEvent.keyDown(window, { key: 'Shift', code: 'ShiftLeft' });
|
|
fireEvent.pointerDown(secondLayer, {
|
|
button: 0,
|
|
pointerId: 82,
|
|
clientX: 520,
|
|
clientY: 180,
|
|
shiftKey: true,
|
|
});
|
|
fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
|
|
pointerId: 82,
|
|
clientX: 520,
|
|
clientY: 180,
|
|
});
|
|
fireEvent.keyUp(window, { key: 'Shift', code: 'ShiftLeft' });
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByAltText('画布图片:拼图素材').closest('button')?.className,
|
|
).toContain('image-canvas-editor__layer--selected');
|
|
expect(
|
|
screen.getByAltText('画布图片:大鱼素材').closest('button')?.className,
|
|
).toContain('image-canvas-editor__layer--selected');
|
|
});
|
|
});
|
|
|
|
it('drags the minimap to move the canvas viewport', () => {
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
const minimap = openMinimap();
|
|
vi.spyOn(minimap, 'getBoundingClientRect').mockReturnValue({
|
|
x: 0,
|
|
y: 0,
|
|
left: 0,
|
|
top: 0,
|
|
right: 132,
|
|
bottom: 84,
|
|
width: 132,
|
|
height: 84,
|
|
toJSON: () => ({}),
|
|
});
|
|
dispatchPointerEvent(minimap, 'pointerdown', {
|
|
button: 0,
|
|
pointerId: 71,
|
|
clientX: 120,
|
|
clientY: 72,
|
|
});
|
|
|
|
const firstLayer = screen
|
|
.getByAltText('画布图片:拼图素材')
|
|
.closest('button')!;
|
|
expect(Number.parseFloat((firstLayer as HTMLElement).style.left)).toBe(470);
|
|
expect(screen.getByRole('button', { name: '画布小地图' })).toBeTruthy();
|
|
});
|
|
|
|
it('keeps minimap drag direction stable after pausing and reversing', async () => {
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
const minimap = openMinimap();
|
|
vi.spyOn(minimap, 'getBoundingClientRect').mockReturnValue({
|
|
x: 0,
|
|
y: 0,
|
|
left: 0,
|
|
top: 0,
|
|
right: 132,
|
|
bottom: 84,
|
|
width: 132,
|
|
height: 84,
|
|
toJSON: () => ({}),
|
|
});
|
|
const world = screen
|
|
.getByLabelText('画布工作区')
|
|
.querySelector('.image-canvas-editor__world') as HTMLElement;
|
|
const readTranslateX = () => {
|
|
const match = /translate\(([-\d.]+)px,/u.exec(world.style.transform);
|
|
return match ? Number(match[1]) : 0;
|
|
};
|
|
|
|
dispatchPointerEvent(minimap, 'pointerdown', {
|
|
button: 0,
|
|
pointerId: 72,
|
|
clientX: 60,
|
|
clientY: 42,
|
|
});
|
|
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', {
|
|
button: 0,
|
|
pointerId: 72,
|
|
clientX: 120,
|
|
clientY: 42,
|
|
});
|
|
await waitFor(() => {
|
|
expect(readTranslateX()).toBeLessThan(0);
|
|
});
|
|
const translateAfterRightDrag = readTranslateX();
|
|
|
|
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', {
|
|
button: 0,
|
|
pointerId: 72,
|
|
clientX: 90,
|
|
clientY: 42,
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(readTranslateX()).toBeGreaterThan(translateAfterRightDrag);
|
|
});
|
|
});
|
|
|
|
it('persists layer groups in the canvas layer snapshot', async () => {
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '打开图层' }));
|
|
fireEvent.pointerDown(
|
|
screen.getByAltText('画布图片:拼图素材').closest('button')!,
|
|
{
|
|
button: 0,
|
|
pointerId: 90,
|
|
clientX: 120,
|
|
clientY: 120,
|
|
},
|
|
);
|
|
fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
|
|
pointerId: 90,
|
|
clientX: 120,
|
|
clientY: 120,
|
|
});
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByAltText('画布图片:拼图素材').closest('button')?.className,
|
|
).toContain('image-canvas-editor__layer--selected');
|
|
});
|
|
fireEvent.keyDown(window, { key: 'Shift', code: 'ShiftLeft' });
|
|
fireEvent.pointerDown(
|
|
screen.getByAltText('画布图片:大鱼素材').closest('button')!,
|
|
{
|
|
button: 0,
|
|
pointerId: 91,
|
|
clientX: 520,
|
|
clientY: 180,
|
|
shiftKey: true,
|
|
},
|
|
);
|
|
fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
|
|
pointerId: 91,
|
|
clientX: 520,
|
|
clientY: 180,
|
|
});
|
|
fireEvent.keyUp(window, { key: 'Shift', code: 'ShiftLeft' });
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByAltText('画布图片:拼图素材').closest('button')?.className,
|
|
).toContain('image-canvas-editor__layer--selected');
|
|
expect(
|
|
screen.getByAltText('画布图片:大鱼素材').closest('button')?.className,
|
|
).toContain('image-canvas-editor__layer--selected');
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '图层打组' }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getAllByText(/已打组/u)).toHaveLength(2);
|
|
});
|
|
await waitFor(() => {
|
|
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
|
'editor-project-default',
|
|
expect.objectContaining({
|
|
layers: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
title: '拼图素材',
|
|
groupId: expect.stringMatching(/^layer-group-/u),
|
|
}),
|
|
expect.objectContaining({
|
|
title: '大鱼素材',
|
|
groupId: expect.stringMatching(/^layer-group-/u),
|
|
}),
|
|
]),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('switches tools and restores the previous tool after holding Space', async () => {
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
|
|
const selectTool = within(bottomToolbar).getByRole('button', {
|
|
name: '选择工具',
|
|
});
|
|
const handTool = within(bottomToolbar).getByRole('button', {
|
|
name: '抓手工具',
|
|
});
|
|
|
|
expect(selectTool.getAttribute('aria-pressed')).toBe('true');
|
|
|
|
fireEvent.keyDown(window, { code: 'Space', key: ' ' });
|
|
expect(handTool.getAttribute('aria-pressed')).toBe('true');
|
|
|
|
fireEvent.keyUp(window, { code: 'Space', key: ' ' });
|
|
expect(selectTool.getAttribute('aria-pressed')).toBe('true');
|
|
});
|
|
|
|
it('switches away from hand tool from the bottom toolbar', async () => {
|
|
const user = userEvent.setup();
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
|
|
const handTool = within(bottomToolbar).getByRole('button', {
|
|
name: '抓手工具',
|
|
});
|
|
const selectTool = within(bottomToolbar).getByRole('button', {
|
|
name: '选择工具',
|
|
});
|
|
|
|
await user.click(handTool);
|
|
expect(handTool.getAttribute('aria-pressed')).toBe('true');
|
|
|
|
await user.click(selectTool);
|
|
expect(selectTool.getAttribute('aria-pressed')).toBe('true');
|
|
expect(handTool.getAttribute('aria-pressed')).toBe('false');
|
|
});
|
|
|
|
it('pans with the middle mouse button without leaving select mode', async () => {
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
const viewport = screen.getByLabelText('画布工作区');
|
|
const middlePointerDown = new MouseEvent('pointerdown', {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
button: 1,
|
|
buttons: 4,
|
|
clientX: 260,
|
|
clientY: 220,
|
|
});
|
|
Object.defineProperty(middlePointerDown, 'pointerId', { value: 11 });
|
|
fireEvent(viewport, middlePointerDown);
|
|
|
|
await waitFor(() => {
|
|
expect(viewport.className).toContain(
|
|
'image-canvas-editor__viewport--panning',
|
|
);
|
|
});
|
|
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
|
|
expect(
|
|
within(bottomToolbar)
|
|
.getByRole('button', { name: '选择工具' })
|
|
.getAttribute('aria-pressed'),
|
|
).toBe('true');
|
|
});
|
|
|
|
it('shows snap guides when dragging a layer near another layer alignment', async () => {
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
const puzzleLayer = screen
|
|
.getByAltText('画布图片:拼图素材')
|
|
.closest('button')!;
|
|
dispatchPointerEvent(puzzleLayer, 'pointerdown', {
|
|
button: 0,
|
|
pointerId: 21,
|
|
clientX: 120,
|
|
clientY: 120,
|
|
});
|
|
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', {
|
|
pointerId: 21,
|
|
clientX: 30,
|
|
clientY: 150,
|
|
});
|
|
|
|
expect(
|
|
screen.getByTestId('image-canvas-editor-snap-guide-vertical'),
|
|
).toBeTruthy();
|
|
expect(
|
|
screen.getByTestId('image-canvas-editor-snap-guide-horizontal'),
|
|
).toBeTruthy();
|
|
});
|
|
|
|
it('can switch tools after a layer drag started without pointer release', async () => {
|
|
const user = userEvent.setup();
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
fireEvent.pointerDown(
|
|
screen.getByAltText('画布图片:拼图素材').closest('button')!,
|
|
{
|
|
button: 0,
|
|
pointerId: 41,
|
|
clientX: 120,
|
|
clientY: 120,
|
|
},
|
|
);
|
|
fireEvent.pointerMove(screen.getByLabelText('画布工作区'), {
|
|
pointerId: 41,
|
|
clientX: 220,
|
|
clientY: 160,
|
|
});
|
|
|
|
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
|
|
const handTool = within(bottomToolbar).getByRole('button', {
|
|
name: '抓手工具',
|
|
});
|
|
await user.click(handTool);
|
|
|
|
expect(handTool.getAttribute('aria-pressed')).toBe('true');
|
|
expect(
|
|
screen.queryByTestId('image-canvas-editor-snap-guide-vertical'),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('undoes and redoes canvas layer changes from the panel controls', () => {
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
expect(screen.getByRole('button', { name: '撤销' })).toHaveProperty(
|
|
'disabled',
|
|
true,
|
|
);
|
|
expect(screen.getByRole('button', { name: '重做' })).toHaveProperty(
|
|
'disabled',
|
|
true,
|
|
);
|
|
|
|
openAssetSidebar();
|
|
fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' }));
|
|
|
|
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
|
|
expect(screen.getByRole('button', { name: '撤销' })).toHaveProperty(
|
|
'disabled',
|
|
false,
|
|
);
|
|
fireEvent.click(screen.getByRole('button', { name: '撤销' }));
|
|
|
|
expect(screen.queryByAltText('画布图片:声浪素材')).toBeNull();
|
|
expect(screen.getByRole('button', { name: '重做' })).toHaveProperty(
|
|
'disabled',
|
|
false,
|
|
);
|
|
fireEvent.click(screen.getByRole('button', { name: '重做' }));
|
|
|
|
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
|
|
});
|
|
|
|
it('supports undo and redo keyboard shortcuts inside the editor', () => {
|
|
render(<ImageCanvasEditorView />);
|
|
|
|
openAssetSidebar();
|
|
fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' }));
|
|
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
|
|
|
|
fireEvent.keyDown(window, { key: 'z', code: 'KeyZ', ctrlKey: true });
|
|
expect(screen.queryByAltText('画布图片:声浪素材')).toBeNull();
|
|
|
|
fireEvent.keyDown(window, {
|
|
key: 'Z',
|
|
code: 'KeyZ',
|
|
ctrlKey: true,
|
|
shiftKey: true,
|
|
});
|
|
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
|
|
});
|
|
});
|