530 lines
15 KiB
TypeScript
530 lines
15 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import {
|
|
act,
|
|
fireEvent,
|
|
render,
|
|
screen,
|
|
waitFor,
|
|
within,
|
|
} from '@testing-library/react';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import type { EditorAgentConversationClient } from '@/src/components/image-editor/useEditorAgentConversation.ts';
|
|
import { useImageCanvasContextStore } from '@/src/components/image-editor/useImageCanvasContextStore.ts';
|
|
|
|
import { EditorAgentConversationPanelView } from './EditorAgentConversationPanelView.tsx';
|
|
|
|
const createEditorProjectResourceMock = vi.hoisted(() => vi.fn());
|
|
const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
|
|
const probeImageFileDimensionsMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
|
const actual = await vi.importActual<
|
|
typeof import('../../../services/image-editor/editorProjectClient.ts')
|
|
>('../../services/image-editor/editorProjectClient');
|
|
return {
|
|
...actual,
|
|
createEditorProjectResource: createEditorProjectResourceMock,
|
|
};
|
|
});
|
|
|
|
vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({
|
|
uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock,
|
|
}));
|
|
|
|
vi.mock('./ImageCanvasFileModel', async () => {
|
|
const actual = await vi.importActual<typeof import('../ImageCanvasFileModel.ts')>(
|
|
'./ImageCanvasFileModel',
|
|
);
|
|
return {
|
|
...actual,
|
|
probeImageFileDimensions: probeImageFileDimensionsMock,
|
|
};
|
|
});
|
|
|
|
function createClient(): EditorAgentConversationClient {
|
|
return {
|
|
listConversations: vi.fn().mockResolvedValue([
|
|
{
|
|
conversationId: 'conversation-1',
|
|
projectId: 'project-1',
|
|
title: '角色参考',
|
|
createdAt: '2026-07-03T00:00:00.000Z',
|
|
updatedAt: '2026-07-03T00:00:00.000Z',
|
|
},
|
|
]),
|
|
createConversation: vi.fn().mockResolvedValue({
|
|
conversationId: 'conversation-2',
|
|
projectId: 'project-1',
|
|
title: '新对话',
|
|
messages: [],
|
|
createdAt: '2026-07-03T00:01:00.000Z',
|
|
updatedAt: '2026-07-03T00:01:00.000Z',
|
|
}),
|
|
getConversation: vi.fn().mockResolvedValue({
|
|
conversationId: 'conversation-1',
|
|
projectId: 'project-1',
|
|
title: '角色参考',
|
|
messages: [
|
|
{
|
|
id: 'assistant-old',
|
|
role: 'assistant',
|
|
kind: 'chat',
|
|
text: '已经看到画布内容',
|
|
attachments: [],
|
|
generations: [],
|
|
status: 'completed',
|
|
createdAt: '2026-07-03T00:00:10.000Z',
|
|
},
|
|
],
|
|
createdAt: '2026-07-03T00:00:00.000Z',
|
|
updatedAt: '2026-07-03T00:00:10.000Z',
|
|
}),
|
|
deleteConversation: vi.fn().mockResolvedValue({
|
|
conversationId: 'conversation-1',
|
|
projectId: 'project-1',
|
|
title: '角色参考',
|
|
messages: [],
|
|
createdAt: '2026-07-03T00:00:00.000Z',
|
|
updatedAt: '2026-07-03T00:00:00.000Z',
|
|
}),
|
|
streamMessage: vi
|
|
.fn()
|
|
.mockImplementation(async (_conversationId, _payload, options) => {
|
|
options.onEvent?.({
|
|
event: 'stage',
|
|
data: { conversationId: _conversationId, stage: 'responding' },
|
|
});
|
|
options.onEvent?.({
|
|
event: 'message_delta',
|
|
data: {
|
|
conversationId: _conversationId,
|
|
messageId: 'assistant-stream',
|
|
role: 'assistant',
|
|
kind: 'chat',
|
|
textDelta: '收到,我会参考这张图。',
|
|
},
|
|
});
|
|
options.onEvent?.({
|
|
event: 'done',
|
|
data: { conversationId: _conversationId, title: null },
|
|
});
|
|
}),
|
|
};
|
|
}
|
|
|
|
describe('EditorAgentConversationPanelView', () => {
|
|
beforeEach(() => {
|
|
useImageCanvasContextStore.getState().setProjectId('project-1');
|
|
uploadEditorMediaAssetFileMock.mockReset();
|
|
uploadEditorMediaAssetFileMock.mockResolvedValue({
|
|
src: '/generated/pasted.png',
|
|
objectKey: 'generated-character-drafts/editor/agent-paste/image/pasted.png',
|
|
assetObjectId: 'asset-object-pasted',
|
|
legacyPublicPath: '/generated/pasted.png',
|
|
});
|
|
probeImageFileDimensionsMock.mockReset();
|
|
probeImageFileDimensionsMock.mockResolvedValue({ width: 320, height: 240 });
|
|
createEditorProjectResourceMock.mockReset();
|
|
createEditorProjectResourceMock.mockResolvedValue({
|
|
resourceId: 'resource-pasted',
|
|
imageSrc: '/generated/pasted.png',
|
|
objectKey: 'generated-character-drafts/editor/agent-paste/image/pasted.png',
|
|
label: '粘贴图片',
|
|
width: 320,
|
|
height: 240,
|
|
});
|
|
});
|
|
|
|
it('manages conversations, attachments and message sending inside the panel', async () => {
|
|
const client = createClient();
|
|
|
|
render(
|
|
<EditorAgentConversationPanelView
|
|
open
|
|
onToggleOpen={vi.fn()}
|
|
client={client}
|
|
layers={[
|
|
{
|
|
id: 'layer-1',
|
|
resourceId: 'resource-1',
|
|
title: '角色图层',
|
|
src: '/generated/role.png',
|
|
x: 0,
|
|
y: 0,
|
|
width: 512,
|
|
height: 512,
|
|
originalWidth: 512,
|
|
originalHeight: 512,
|
|
zIndex: 1,
|
|
sourceType: 'generated',
|
|
},
|
|
{
|
|
id: 'layer-local-only',
|
|
resourceId: '',
|
|
title: '未入库图层',
|
|
src: '/local-only.png',
|
|
x: 0,
|
|
y: 0,
|
|
width: 256,
|
|
height: 256,
|
|
originalWidth: 256,
|
|
originalHeight: 256,
|
|
zIndex: 2,
|
|
sourceType: 'uploaded',
|
|
},
|
|
]}
|
|
assets={[
|
|
{
|
|
id: 'asset-1',
|
|
label: '素材库角色',
|
|
src: '/assets/role.png',
|
|
width: 512,
|
|
height: 512,
|
|
folderId: 'project',
|
|
sourceKind: 'uploaded',
|
|
sourceType: 'uploaded',
|
|
persisted: true,
|
|
},
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '新建对话' }));
|
|
await waitFor(() => {
|
|
expect(client.createConversation).toHaveBeenCalledWith('project-1', {});
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '添加附件' }));
|
|
const attachmentDialog = screen.getByRole('dialog', {
|
|
name: '选择图片附件',
|
|
});
|
|
expect(
|
|
within(attachmentDialog).queryByRole('checkbox', {
|
|
name: '选择画布图片 未入库图层',
|
|
}),
|
|
).toBeNull();
|
|
fireEvent.click(
|
|
within(attachmentDialog).getByRole('checkbox', {
|
|
name: '选择画布图片 角色图层',
|
|
}),
|
|
);
|
|
fireEvent.click(
|
|
within(attachmentDialog).getByRole('button', { name: '应用' }),
|
|
);
|
|
expect(screen.getByText('角色图层')).toBeTruthy();
|
|
|
|
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
|
|
target: { value: '参考附件做像素风' },
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('收到,我会参考这张图。')).toBeTruthy();
|
|
});
|
|
expect(client.streamMessage).toHaveBeenCalledWith(
|
|
'conversation-2',
|
|
expect.objectContaining({
|
|
text: '参考附件做像素风',
|
|
attachments: [
|
|
expect.objectContaining({
|
|
source: 'canvas_resource',
|
|
referenceId: 'resource-1',
|
|
}),
|
|
],
|
|
}),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it('uploads pasted images as canvas attachments before sending', async () => {
|
|
const client = createClient();
|
|
|
|
render(
|
|
<EditorAgentConversationPanelView
|
|
open
|
|
onToggleOpen={vi.fn()}
|
|
client={client}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
|
|
});
|
|
|
|
const pastedImage = new File(['pasted-image'], 'pasted.png', {
|
|
type: 'image/png',
|
|
});
|
|
const input = screen.getByLabelText('发送给画布 Agent');
|
|
fireEvent.paste(input, {
|
|
clipboardData: {
|
|
files: [pastedImage],
|
|
},
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith(
|
|
pastedImage,
|
|
'image',
|
|
expect.objectContaining({
|
|
entityId: 'project-1',
|
|
metadata: { source: 'agent-input-paste' },
|
|
}),
|
|
);
|
|
});
|
|
expect(probeImageFileDimensionsMock).toHaveBeenCalledWith(pastedImage);
|
|
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
|
|
'project-1',
|
|
expect.objectContaining({
|
|
imageSrc: '/generated/pasted.png',
|
|
objectKey: 'generated-character-drafts/editor/agent-paste/image/pasted.png',
|
|
assetObjectId: 'asset-object-pasted',
|
|
width: 320,
|
|
height: 240,
|
|
sourceType: 'uploaded',
|
|
}),
|
|
);
|
|
await waitFor(() => {
|
|
expect(screen.getByText('粘贴图片')).toBeTruthy();
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
|
|
|
await waitFor(() => {
|
|
expect(client.streamMessage).toHaveBeenCalledWith(
|
|
'conversation-1',
|
|
expect.objectContaining({
|
|
text: '',
|
|
attachments: [
|
|
expect.objectContaining({
|
|
source: 'canvas_resource',
|
|
referenceId: 'resource-pasted',
|
|
imageSrc: '/generated/pasted.png',
|
|
width: 320,
|
|
height: 240,
|
|
}),
|
|
],
|
|
}),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('sends selected attachments even when the text input is empty', async () => {
|
|
const client = createClient();
|
|
|
|
render(
|
|
<EditorAgentConversationPanelView
|
|
open
|
|
onToggleOpen={vi.fn()}
|
|
client={client}
|
|
layers={[
|
|
{
|
|
id: 'layer-1',
|
|
resourceId: 'resource-1',
|
|
title: '角色图层',
|
|
src: '/generated/role.png',
|
|
x: 0,
|
|
y: 0,
|
|
width: 512,
|
|
height: 512,
|
|
originalWidth: 512,
|
|
originalHeight: 512,
|
|
zIndex: 1,
|
|
sourceType: 'generated',
|
|
},
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '添加附件' }));
|
|
const attachmentDialog = screen.getByRole('dialog', {
|
|
name: '选择图片附件',
|
|
});
|
|
fireEvent.click(
|
|
within(attachmentDialog).getByRole('checkbox', {
|
|
name: '选择画布图片 角色图层',
|
|
}),
|
|
);
|
|
fireEvent.click(
|
|
within(attachmentDialog).getByRole('button', { name: '应用' }),
|
|
);
|
|
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
|
|
|
await waitFor(() => {
|
|
expect(client.streamMessage).toHaveBeenCalledWith(
|
|
'conversation-1',
|
|
expect.objectContaining({
|
|
text: '',
|
|
attachments: [
|
|
expect.objectContaining({
|
|
source: 'canvas_resource',
|
|
referenceId: 'resource-1',
|
|
}),
|
|
],
|
|
}),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('keeps streamed reply and generating state when the panel is collapsed and reopened', async () => {
|
|
const client = createClient();
|
|
let finishStream = () => {};
|
|
vi.mocked(client.streamMessage).mockImplementation(
|
|
async (conversationId, _payload, options) => {
|
|
options.onEvent?.({
|
|
event: 'message_delta',
|
|
data: {
|
|
conversationId,
|
|
messageId: 'assistant-stream',
|
|
role: 'assistant',
|
|
kind: 'chat',
|
|
textDelta: '我来生成图片。',
|
|
},
|
|
});
|
|
options.onEvent?.({
|
|
event: 'stage',
|
|
data: { conversationId, stage: 'generating' },
|
|
});
|
|
options.onEvent?.({
|
|
event: 'tool_started',
|
|
data: {
|
|
conversationId,
|
|
messageId: 'assistant-stream',
|
|
toolCallId: 'tool-call-generating',
|
|
toolName: 'generate_image',
|
|
taskId: 'task-generating',
|
|
model: 'gpt-image-2',
|
|
},
|
|
});
|
|
await new Promise<void>((resolve) => {
|
|
finishStream = resolve;
|
|
});
|
|
},
|
|
);
|
|
|
|
const { rerender } = render(
|
|
<EditorAgentConversationPanelView
|
|
open
|
|
onToggleOpen={vi.fn()}
|
|
client={client}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
|
|
});
|
|
|
|
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
|
|
target: { value: '生成一张图片' },
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('我来生成图片。')).toBeTruthy();
|
|
});
|
|
expect(screen.getByText('生成中')).toBeTruthy();
|
|
expect(screen.getByRole('button', { name: '停止' })).toBeTruthy();
|
|
|
|
rerender(
|
|
<EditorAgentConversationPanelView
|
|
open={false}
|
|
onToggleOpen={vi.fn()}
|
|
client={client}
|
|
/>,
|
|
);
|
|
expect(screen.getByRole('button', { name: '打开画布 Agent' })).toBeTruthy();
|
|
|
|
rerender(
|
|
<EditorAgentConversationPanelView
|
|
open
|
|
onToggleOpen={vi.fn()}
|
|
client={client}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText('我来生成图片。')).toBeTruthy();
|
|
expect(screen.getByText('生成中')).toBeTruthy();
|
|
expect(screen.getByRole('button', { name: '停止' })).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
finishStream();
|
|
});
|
|
});
|
|
|
|
it('keeps wheel scrolling inside the message history and input', async () => {
|
|
const client = createClient();
|
|
const parentWheel = vi.fn();
|
|
|
|
render(
|
|
<div onWheel={parentWheel}>
|
|
<EditorAgentConversationPanelView
|
|
open
|
|
onToggleOpen={vi.fn()}
|
|
client={client}
|
|
/>
|
|
</div>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
|
|
});
|
|
|
|
const messageWheel = new WheelEvent('wheel', {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
deltaY: 120,
|
|
});
|
|
fireEvent(
|
|
screen.getByRole('log', { name: '画布 Agent 消息流' }),
|
|
messageWheel,
|
|
);
|
|
expect(parentWheel).not.toHaveBeenCalled();
|
|
expect(messageWheel.defaultPrevented).toBe(false);
|
|
|
|
const inputWheel = new WheelEvent('wheel', {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
deltaY: 120,
|
|
});
|
|
fireEvent(screen.getByLabelText('发送给画布 Agent'), inputWheel);
|
|
expect(parentWheel).not.toHaveBeenCalled();
|
|
expect(inputWheel.defaultPrevented).toBe(false);
|
|
});
|
|
|
|
it('confirms conversation deletion from an independent dialog', async () => {
|
|
const client = createClient();
|
|
render(
|
|
<EditorAgentConversationPanelView
|
|
open
|
|
onToggleOpen={vi.fn()}
|
|
client={client}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '删除当前对话' }));
|
|
const confirmDialog = screen.getByRole('dialog', { name: '删除对话' });
|
|
fireEvent.click(
|
|
within(confirmDialog).getByRole('button', { name: '确认删除' }),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(client.deleteConversation).toHaveBeenCalledWith('conversation-1');
|
|
});
|
|
});
|
|
});
|