9bb4942ae7
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/95 Co-authored-by: 王德宇 <kvtodev@outlook.com> Co-committed-by: 王德宇 <kvtodev@outlook.com>
1706 lines
51 KiB
TypeScript
1706 lines
51 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import {
|
|
act,
|
|
fireEvent,
|
|
render,
|
|
screen,
|
|
waitFor,
|
|
within,
|
|
} from '@testing-library/react';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import type {
|
|
EditorAgentMessage,
|
|
EditorAgentMessageResponse,
|
|
} from '@/packages/shared/src/contracts';
|
|
import type { EditorAgentConversationClient } from '@/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts';
|
|
import { EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS } from '@/src/components/image-editor/EditorAgentConversation/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('@/src/services/image-editor/editorProjectClient.ts', async () => {
|
|
const actual = await vi.importActual<
|
|
typeof import('../../../services/image-editor/editorProjectClient.ts')
|
|
>('@/src/services/image-editor/editorProjectClient.ts');
|
|
return {
|
|
...actual,
|
|
createEditorProjectResource: createEditorProjectResourceMock,
|
|
};
|
|
});
|
|
|
|
vi.mock('@/src/services/image-editor/editorMediaAssetUploadClient.ts', () => ({
|
|
uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock,
|
|
}));
|
|
|
|
vi.mock('@/src/components/image-editor/ImageCanvasFileModel.ts', async () => {
|
|
const actual = await vi.importActual<
|
|
typeof import('../ImageCanvasFileModel.ts')
|
|
>('@/src/components/image-editor/ImageCanvasFileModel.ts');
|
|
return {
|
|
...actual,
|
|
probeImageFileDimensions: probeImageFileDimensionsMock,
|
|
};
|
|
});
|
|
|
|
function createClient(): EditorAgentConversationClient {
|
|
return {
|
|
listConversations: vi.fn().mockResolvedValue([
|
|
{
|
|
conversationId: 'conversation-1',
|
|
projectId: 'project-1',
|
|
title: '角色参考',
|
|
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: 0,
|
|
role: 'assistant',
|
|
text: '已经看到画布内容',
|
|
attachments: [],
|
|
toolCall: null,
|
|
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',
|
|
}),
|
|
sendMessage: vi.fn().mockResolvedValue({
|
|
conversation: {
|
|
conversationId: 'conversation-1',
|
|
projectId: 'project-1',
|
|
title: '角色参考',
|
|
updatedAt: '2026-07-03T00:00:10.000Z',
|
|
},
|
|
deltaMessages: [
|
|
{
|
|
id: 1,
|
|
role: 'assistant',
|
|
text: '收到,我会参考这张图。',
|
|
attachments: [],
|
|
toolCall: null,
|
|
createdAt: '2026-07-03T00:00:00.000Z',
|
|
},
|
|
],
|
|
errorMessage: null,
|
|
} as EditorAgentMessageResponse),
|
|
confirmToolCall: vi.fn(),
|
|
cancelToolCall: vi.fn(),
|
|
};
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
function createPendingToolCallMessage(): EditorAgentMessage {
|
|
return {
|
|
id: 2,
|
|
role: 'system',
|
|
text: 'internal system prompt that must stay hidden',
|
|
attachments: [],
|
|
toolCall: {
|
|
toolName: 'edit-image',
|
|
status: 'not_completed',
|
|
args: {
|
|
object_image_id: 'source-image-1',
|
|
reference_image_ids: ['reference-image-1', 'reference-image-2'],
|
|
prompt: '把角色换成像素风',
|
|
},
|
|
displayArgs: {
|
|
stringArgs: [
|
|
{
|
|
name: 'prompt',
|
|
label: '提示词',
|
|
value: '把角色换成像素风',
|
|
},
|
|
],
|
|
imageArgs: [
|
|
{
|
|
name: 'object_image_id',
|
|
label: '目标图片',
|
|
refs: [
|
|
{
|
|
imageId: 'source-image-1',
|
|
imageSrc: 'data:image/png;base64,c291cmNl',
|
|
label: '原角色',
|
|
width: 512,
|
|
height: 512,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: 'reference_image_ids',
|
|
label: '参考图片',
|
|
refs: [
|
|
{
|
|
imageId: 'reference-image-1',
|
|
imageSrc: 'data:image/png;base64,cmVmZXJlbmNlLTE=',
|
|
label: '像素风参考',
|
|
},
|
|
{
|
|
imageId: 'reference-image-2',
|
|
imageSrc: 'data:image/png;base64,cmVmZXJlbmNlLTI=',
|
|
label: '配色参考',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
extras: { priceMudPoints: 3 },
|
|
},
|
|
images: [],
|
|
error: null,
|
|
},
|
|
createdAt: '2026-07-10T00:00:00.000Z',
|
|
};
|
|
}
|
|
|
|
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(attachmentDialog.parentElement?.className).toContain(
|
|
'platform-theme--light',
|
|
);
|
|
expect(attachmentDialog.className).toContain('platform-remap-surface');
|
|
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.sendMessage).toHaveBeenCalledWith(
|
|
'conversation-2',
|
|
expect.objectContaining({
|
|
text: '参考附件做像素风',
|
|
attachments: [
|
|
expect.objectContaining({
|
|
source: 'canvas_resource',
|
|
referenceId: 'resource-1',
|
|
}),
|
|
],
|
|
}),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it('adds a generated result to the composer through the existing right click menu', async () => {
|
|
const client = createClient();
|
|
vi.mocked(client.getConversation).mockResolvedValue({
|
|
conversationId: 'conversation-1',
|
|
projectId: 'project-1',
|
|
title: '生成结果',
|
|
messages: [
|
|
{
|
|
id: 9,
|
|
role: 'system',
|
|
text: 'internal tool result',
|
|
attachments: [],
|
|
toolCall: {
|
|
toolName: 'generate_image',
|
|
status: 'completed',
|
|
args: {},
|
|
displayArgs: {
|
|
stringArgs: [],
|
|
imageArgs: [],
|
|
extras: { priceMudPoints: 1 },
|
|
},
|
|
images: [
|
|
{
|
|
resourceId: 'resource-generated-1',
|
|
objectKey: 'editor/generated-1.png',
|
|
imageSrc: '/generated-editor-images/generated-1.png',
|
|
width: 512,
|
|
height: 512,
|
|
},
|
|
],
|
|
},
|
|
createdAt: '2026-07-20T00:00:00.000Z',
|
|
},
|
|
],
|
|
createdAt: '2026-07-20T00:00:00.000Z',
|
|
updatedAt: '2026-07-20T00:00:10.000Z',
|
|
});
|
|
|
|
render(
|
|
<EditorAgentConversationPanelView
|
|
open
|
|
onToggleOpen={vi.fn()}
|
|
client={client}
|
|
layers={[
|
|
{
|
|
id: 'layer-generated-1',
|
|
resourceId: 'resource-generated-1',
|
|
title: 'Agent生成图片-1',
|
|
src: '/generated-editor-images/generated-1.png',
|
|
objectKey: 'editor/generated-1.png',
|
|
x: 0,
|
|
y: 0,
|
|
width: 512,
|
|
height: 512,
|
|
originalWidth: 512,
|
|
originalHeight: 512,
|
|
zIndex: 1,
|
|
sourceType: 'generated',
|
|
},
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
const messageLog = await screen.findByRole('log', {
|
|
name: '画布 Agent 消息流',
|
|
});
|
|
await waitFor(() =>
|
|
expect(messageLog.querySelector('.grid.grid-cols-3 > div')).toBeTruthy(),
|
|
);
|
|
fireEvent.contextMenu(
|
|
messageLog.querySelector('.grid.grid-cols-3 > div')!,
|
|
{ clientX: 30, clientY: 40 },
|
|
);
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '引用' }));
|
|
|
|
expect(await screen.findByText('Agent生成图片-1')).toBeTruthy();
|
|
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
|
|
|
await waitFor(() => {
|
|
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
'conversation-1',
|
|
expect.objectContaining({
|
|
text: '',
|
|
attachments: [
|
|
expect.objectContaining({
|
|
source: 'canvas_resource',
|
|
referenceId: 'resource-generated-1',
|
|
objectKey: 'editor/generated-1.png',
|
|
imageSrc: '/generated-editor-images/generated-1.png',
|
|
}),
|
|
],
|
|
}),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('disables sending while a message request is pending without showing stop', async () => {
|
|
const client = createClient();
|
|
let resolveSend!: (response: EditorAgentMessageResponse) => void;
|
|
vi.mocked(client.sendMessage).mockImplementation(
|
|
() =>
|
|
new Promise<EditorAgentMessageResponse>((resolve) => {
|
|
resolveSend = resolve;
|
|
}),
|
|
);
|
|
|
|
render(
|
|
<EditorAgentConversationPanelView
|
|
open
|
|
onToggleOpen={vi.fn()}
|
|
client={client}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
|
|
});
|
|
vi.useFakeTimers();
|
|
|
|
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
|
|
target: { value: '继续规划' },
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
|
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
expect(
|
|
screen.getByRole('button', { name: '发送' }).hasAttribute('disabled'),
|
|
).toBe(true);
|
|
expect(screen.queryByRole('button', { name: '停止' })).toBeNull();
|
|
expect(screen.queryByText('仍在处理中,请耐心等待')).toBeNull();
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS);
|
|
});
|
|
expect(screen.getByText('仍在处理中,请耐心等待')).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
resolveSend({
|
|
conversation: {
|
|
conversationId: 'conversation-1',
|
|
projectId: 'project-1',
|
|
title: '角色参考',
|
|
updatedAt: '2026-07-03T00:00:20.000Z',
|
|
},
|
|
deltaMessages: [],
|
|
errorMessage: null,
|
|
});
|
|
await Promise.resolve();
|
|
});
|
|
expect(screen.queryByText('仍在处理中,请耐心等待')).toBeNull();
|
|
});
|
|
|
|
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.sendMessage).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),
|
|
);
|
|
});
|
|
expect(screen.getByRole('option', { name: '角色参考' })).toBeTruthy();
|
|
expect(screen.queryByRole('option', { name: 'conversation-1' })).toBeNull();
|
|
});
|
|
|
|
it('references a historical pasted attachment outside the current canvas', async () => {
|
|
const client = createClient();
|
|
vi.mocked(client.getConversation).mockResolvedValue({
|
|
conversationId: 'conversation-1',
|
|
projectId: 'project-1',
|
|
title: '历史附件',
|
|
messages: [
|
|
{
|
|
id: 8,
|
|
role: 'user',
|
|
text: '参考这张粘贴图',
|
|
attachments: [
|
|
{
|
|
source: 'canvas_resource',
|
|
referenceId: 'resource-historical-paste',
|
|
objectKey: 'editor/agent-paste/historical.png',
|
|
imageSrc: '/generated/historical-paste.png',
|
|
thumbnailSrc: null,
|
|
label: '历史粘贴图',
|
|
width: 320,
|
|
height: 240,
|
|
},
|
|
],
|
|
toolCall: null,
|
|
createdAt: '2026-07-20T00:00:00.000Z',
|
|
},
|
|
],
|
|
createdAt: '2026-07-20T00:00:00.000Z',
|
|
updatedAt: '2026-07-20T00:00:10.000Z',
|
|
});
|
|
|
|
render(
|
|
<EditorAgentConversationPanelView
|
|
open
|
|
onToggleOpen={vi.fn()}
|
|
client={client}
|
|
layers={[]}
|
|
assets={[]}
|
|
/>,
|
|
);
|
|
|
|
const historicalAttachment = await screen.findByText('历史粘贴图');
|
|
fireEvent.contextMenu(historicalAttachment.closest('.group')!);
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '引用' }));
|
|
await waitFor(() => {
|
|
expect(screen.getAllByText('历史粘贴图')).toHaveLength(2);
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
|
await waitFor(() => {
|
|
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
'conversation-1',
|
|
expect.objectContaining({
|
|
attachments: [
|
|
{
|
|
source: 'canvas_resource',
|
|
referenceId: 'resource-historical-paste',
|
|
objectKey: 'editor/agent-paste/historical.png',
|
|
imageSrc: '/generated/historical-paste.png',
|
|
thumbnailSrc: null,
|
|
label: '历史粘贴图',
|
|
width: 320,
|
|
height: 240,
|
|
},
|
|
],
|
|
}),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('refreshes an attachment matched by source and reference id when sending', async () => {
|
|
const client = createClient();
|
|
vi.mocked(client.getConversation).mockResolvedValue({
|
|
conversationId: 'conversation-1',
|
|
projectId: 'project-1',
|
|
title: '附件更新',
|
|
messages: [
|
|
{
|
|
id: 8,
|
|
role: 'user',
|
|
text: '参考旧版图片',
|
|
attachments: [
|
|
{
|
|
source: 'canvas_resource',
|
|
referenceId: 'resource-updated',
|
|
objectKey: 'editor/old.png',
|
|
imageSrc: '/generated/old.png',
|
|
label: '旧版附件',
|
|
},
|
|
],
|
|
toolCall: null,
|
|
createdAt: '2026-07-20T00:00:00.000Z',
|
|
},
|
|
],
|
|
createdAt: '2026-07-20T00:00:00.000Z',
|
|
updatedAt: '2026-07-20T00:00:10.000Z',
|
|
});
|
|
|
|
render(
|
|
<EditorAgentConversationPanelView
|
|
open
|
|
onToggleOpen={vi.fn()}
|
|
client={client}
|
|
layers={[
|
|
{
|
|
id: 'layer-updated',
|
|
resourceId: 'resource-updated',
|
|
title: '最新附件',
|
|
src: '/generated/current.png',
|
|
objectKey: 'editor/current.png',
|
|
x: 0,
|
|
y: 0,
|
|
width: 512,
|
|
height: 512,
|
|
originalWidth: 512,
|
|
originalHeight: 512,
|
|
zIndex: 1,
|
|
sourceType: 'generated',
|
|
},
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
const historicalAttachment = await screen.findByText('旧版附件');
|
|
fireEvent.contextMenu(historicalAttachment.closest('.group')!);
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '引用' }));
|
|
await waitFor(() => {
|
|
expect(screen.getAllByText('旧版附件')).toHaveLength(2);
|
|
});
|
|
expect(screen.queryByText('最新附件')).toBeNull();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
|
await waitFor(() => {
|
|
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
'conversation-1',
|
|
expect.objectContaining({
|
|
attachments: [
|
|
expect.objectContaining({
|
|
source: 'canvas_resource',
|
|
referenceId: 'resource-updated',
|
|
objectKey: 'editor/current.png',
|
|
imageSrc: '/generated/current.png',
|
|
}),
|
|
],
|
|
}),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('keeps a referenced image when a pending paste upload finishes', async () => {
|
|
const client = createClient();
|
|
vi.mocked(client.getConversation).mockResolvedValue({
|
|
conversationId: 'conversation-1',
|
|
projectId: 'project-1',
|
|
title: '并发附件',
|
|
messages: [
|
|
{
|
|
id: 9,
|
|
role: 'system',
|
|
text: 'internal tool result',
|
|
attachments: [],
|
|
toolCall: {
|
|
toolName: 'generate_image',
|
|
status: 'completed',
|
|
args: {},
|
|
displayArgs: {
|
|
stringArgs: [],
|
|
imageArgs: [],
|
|
extras: { priceMudPoints: 1 },
|
|
},
|
|
images: [
|
|
{
|
|
resourceId: 'resource-referenced',
|
|
objectKey: 'editor/referenced.png',
|
|
imageSrc: '/generated-editor-images/referenced.png',
|
|
width: 512,
|
|
height: 512,
|
|
},
|
|
],
|
|
},
|
|
createdAt: '2026-07-20T00:00:00.000Z',
|
|
},
|
|
],
|
|
createdAt: '2026-07-20T00:00:00.000Z',
|
|
updatedAt: '2026-07-20T00:00:10.000Z',
|
|
});
|
|
let resolveUpload!: (upload: {
|
|
src: string;
|
|
objectKey: string;
|
|
assetObjectId: string;
|
|
legacyPublicPath: string;
|
|
}) => void;
|
|
uploadEditorMediaAssetFileMock.mockImplementationOnce(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
resolveUpload = resolve;
|
|
}),
|
|
);
|
|
|
|
render(
|
|
<EditorAgentConversationPanelView
|
|
open
|
|
onToggleOpen={vi.fn()}
|
|
client={client}
|
|
layers={[
|
|
{
|
|
id: 'layer-referenced',
|
|
resourceId: 'resource-referenced',
|
|
title: '并发引用图',
|
|
src: '/generated-editor-images/referenced.png',
|
|
objectKey: 'editor/referenced.png',
|
|
x: 0,
|
|
y: 0,
|
|
width: 512,
|
|
height: 512,
|
|
originalWidth: 512,
|
|
originalHeight: 512,
|
|
zIndex: 1,
|
|
sourceType: 'generated',
|
|
},
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
const messageLog = await screen.findByRole('log', {
|
|
name: '画布 Agent 消息流',
|
|
});
|
|
await waitFor(() =>
|
|
expect(messageLog.querySelector('.grid.grid-cols-3 > div')).toBeTruthy(),
|
|
);
|
|
const input = screen.getByLabelText('发送给画布 Agent');
|
|
fireEvent.paste(input, {
|
|
clipboardData: {
|
|
files: [
|
|
new File(['pending-paste'], 'pending.png', { type: 'image/png' }),
|
|
],
|
|
},
|
|
});
|
|
await waitFor(() =>
|
|
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledTimes(1),
|
|
);
|
|
|
|
fireEvent.contextMenu(
|
|
messageLog.querySelector('.grid.grid-cols-3 > div')!,
|
|
{ clientX: 30, clientY: 40 },
|
|
);
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '引用' }));
|
|
expect(await screen.findByText('并发引用图')).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
resolveUpload({
|
|
src: '/generated/pasted.png',
|
|
objectKey:
|
|
'generated-character-drafts/editor/agent-paste/image/pasted.png',
|
|
assetObjectId: 'asset-object-pasted',
|
|
legacyPublicPath: '/generated/pasted.png',
|
|
});
|
|
});
|
|
expect(await screen.findByText('粘贴图片')).toBeTruthy();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
|
await waitFor(() => {
|
|
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
'conversation-1',
|
|
expect.objectContaining({
|
|
attachments: [
|
|
expect.objectContaining({
|
|
referenceId: 'resource-referenced',
|
|
}),
|
|
expect.objectContaining({ referenceId: 'resource-pasted' }),
|
|
],
|
|
}),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('evaluates the nine-image limit against references added during paste', async () => {
|
|
const client = createClient();
|
|
const layers = Array.from({ length: 9 }, (_, index) => ({
|
|
id: `layer-${index + 1}`,
|
|
resourceId: `resource-${index + 1}`,
|
|
title: `附件-${index + 1}`,
|
|
src: `/generated/attachment-${index + 1}.png`,
|
|
objectKey: `editor/attachment-${index + 1}.png`,
|
|
x: 0,
|
|
y: 0,
|
|
width: 512,
|
|
height: 512,
|
|
originalWidth: 512,
|
|
originalHeight: 512,
|
|
zIndex: index + 1,
|
|
sourceType: 'generated' as const,
|
|
}));
|
|
vi.mocked(client.getConversation).mockResolvedValue({
|
|
conversationId: 'conversation-1',
|
|
projectId: 'project-1',
|
|
title: '附件上限',
|
|
messages: [
|
|
{
|
|
id: 9,
|
|
role: 'system',
|
|
text: 'internal tool result',
|
|
attachments: [],
|
|
toolCall: {
|
|
toolName: 'generate_image',
|
|
status: 'completed',
|
|
args: {},
|
|
displayArgs: {
|
|
stringArgs: [],
|
|
imageArgs: [],
|
|
extras: { priceMudPoints: 1 },
|
|
},
|
|
images: [
|
|
{
|
|
resourceId: 'resource-9',
|
|
objectKey: 'editor/attachment-9.png',
|
|
imageSrc: '/generated/attachment-9.png',
|
|
width: 512,
|
|
height: 512,
|
|
},
|
|
],
|
|
},
|
|
createdAt: '2026-07-20T00:00:00.000Z',
|
|
},
|
|
],
|
|
createdAt: '2026-07-20T00:00:00.000Z',
|
|
updatedAt: '2026-07-20T00:00:10.000Z',
|
|
});
|
|
let resolveUpload!: (upload: {
|
|
src: string;
|
|
objectKey: string;
|
|
assetObjectId: string;
|
|
legacyPublicPath: string;
|
|
}) => void;
|
|
uploadEditorMediaAssetFileMock.mockImplementationOnce(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
resolveUpload = resolve;
|
|
}),
|
|
);
|
|
|
|
render(
|
|
<EditorAgentConversationPanelView
|
|
open
|
|
onToggleOpen={vi.fn()}
|
|
client={client}
|
|
layers={layers}
|
|
/>,
|
|
);
|
|
|
|
const messageLog = await screen.findByRole('log', {
|
|
name: '画布 Agent 消息流',
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '添加附件' }));
|
|
const attachmentDialog = screen.getByRole('dialog', {
|
|
name: '选择图片附件',
|
|
});
|
|
for (let index = 1; index <= 8; index += 1) {
|
|
fireEvent.click(
|
|
within(attachmentDialog).getByRole('checkbox', {
|
|
name: `选择画布图片 附件-${index}`,
|
|
}),
|
|
);
|
|
}
|
|
fireEvent.click(
|
|
within(attachmentDialog).getByRole('button', { name: '应用' }),
|
|
);
|
|
|
|
const input = screen.getByLabelText('发送给画布 Agent');
|
|
fireEvent.paste(input, {
|
|
clipboardData: {
|
|
files: [
|
|
new File(['pending-paste'], 'pending.png', { type: 'image/png' }),
|
|
],
|
|
},
|
|
});
|
|
await waitFor(() =>
|
|
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledTimes(1),
|
|
);
|
|
await waitFor(() =>
|
|
expect(messageLog.querySelector('.grid.grid-cols-3 > div')).toBeTruthy(),
|
|
);
|
|
fireEvent.contextMenu(
|
|
messageLog.querySelector('.grid.grid-cols-3 > div')!,
|
|
{ clientX: 30, clientY: 40 },
|
|
);
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '引用' }));
|
|
expect(await screen.findByText('附件-9')).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
resolveUpload({
|
|
src: '/generated/pasted.png',
|
|
objectKey:
|
|
'generated-character-drafts/editor/agent-paste/image/pasted.png',
|
|
assetObjectId: 'asset-object-pasted',
|
|
legacyPublicPath: '/generated/pasted.png',
|
|
});
|
|
});
|
|
expect(await screen.findByText('最多 9 张')).toBeTruthy();
|
|
expect(screen.queryByText('粘贴图片')).toBeNull();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
|
await waitFor(() => {
|
|
const request = vi.mocked(client.sendMessage).mock.calls[0]?.[1];
|
|
expect(request?.attachments).toHaveLength(9);
|
|
expect(request?.attachments).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ referenceId: 'resource-9' }),
|
|
]),
|
|
);
|
|
expect(request?.attachments).not.toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ referenceId: 'resource-pasted' }),
|
|
]),
|
|
);
|
|
});
|
|
});
|
|
|
|
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.sendMessage).toHaveBeenCalledWith(
|
|
'conversation-1',
|
|
expect.objectContaining({
|
|
text: '',
|
|
attachments: [
|
|
expect.objectContaining({
|
|
source: 'canvas_resource',
|
|
referenceId: 'resource-1',
|
|
}),
|
|
],
|
|
}),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('restores the draft and selected attachments when sending fails', async () => {
|
|
const client = createClient();
|
|
vi.mocked(client.sendMessage).mockRejectedValueOnce(
|
|
new Error('Network error'),
|
|
);
|
|
|
|
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.change(screen.getByLabelText('发送给画布 Agent'), {
|
|
target: { value: '失败后恢复这条草稿' },
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
|
|
|
expect(await screen.findByText('Network error')).toBeTruthy();
|
|
expect(
|
|
(screen.getByLabelText('发送给画布 Agent') as HTMLTextAreaElement).value,
|
|
).toBe('失败后恢复这条草稿');
|
|
expect(screen.getByText('角色图层')).toBeTruthy();
|
|
expect(
|
|
within(
|
|
screen.getByRole('log', { name: '画布 Agent 消息流' }),
|
|
).queryByText('失败后恢复这条草稿'),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('merges sent attachments with references added while a failed request is pending', async () => {
|
|
const client = createClient();
|
|
let rejectSend!: (reason?: unknown) => void;
|
|
vi.mocked(client.sendMessage).mockImplementationOnce(
|
|
() =>
|
|
new Promise<EditorAgentMessageResponse>((_resolve, reject) => {
|
|
rejectSend = reject;
|
|
}),
|
|
);
|
|
vi.mocked(client.getConversation).mockResolvedValue({
|
|
conversationId: 'conversation-1',
|
|
projectId: 'project-1',
|
|
title: '失败恢复并发附件',
|
|
messages: [
|
|
{
|
|
id: 9,
|
|
role: 'system',
|
|
text: 'internal tool result',
|
|
attachments: [],
|
|
toolCall: {
|
|
toolName: 'generate_image',
|
|
status: 'completed',
|
|
args: {},
|
|
displayArgs: {
|
|
stringArgs: [],
|
|
imageArgs: [],
|
|
extras: { priceMudPoints: 1 },
|
|
},
|
|
images: [
|
|
{
|
|
resourceId: 'resource-b',
|
|
objectKey: 'editor/attachment-b.png',
|
|
imageSrc: '/generated-editor-images/attachment-b.png',
|
|
width: 512,
|
|
height: 512,
|
|
},
|
|
],
|
|
},
|
|
createdAt: '2026-07-20T00:00:00.000Z',
|
|
},
|
|
],
|
|
createdAt: '2026-07-20T00:00:00.000Z',
|
|
updatedAt: '2026-07-20T00:00:10.000Z',
|
|
});
|
|
|
|
render(
|
|
<EditorAgentConversationPanelView
|
|
open
|
|
onToggleOpen={vi.fn()}
|
|
client={client}
|
|
layers={[
|
|
{
|
|
id: 'layer-a',
|
|
resourceId: 'resource-a',
|
|
title: '附件 A',
|
|
src: '/generated-editor-images/attachment-a.png',
|
|
objectKey: 'editor/attachment-a.png',
|
|
x: 0,
|
|
y: 0,
|
|
width: 512,
|
|
height: 512,
|
|
originalWidth: 512,
|
|
originalHeight: 512,
|
|
zIndex: 1,
|
|
sourceType: 'generated',
|
|
},
|
|
{
|
|
id: 'layer-b',
|
|
resourceId: 'resource-b',
|
|
title: '附件 B',
|
|
src: '/generated-editor-images/attachment-b.png',
|
|
objectKey: 'editor/attachment-b.png',
|
|
x: 0,
|
|
y: 0,
|
|
width: 512,
|
|
height: 512,
|
|
originalWidth: 512,
|
|
originalHeight: 512,
|
|
zIndex: 2,
|
|
sourceType: 'generated',
|
|
},
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
const messageLog = await screen.findByRole('log', {
|
|
name: '画布 Agent 消息流',
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '添加附件' }));
|
|
const attachmentDialog = screen.getByRole('dialog', {
|
|
name: '选择图片附件',
|
|
});
|
|
fireEvent.click(
|
|
within(attachmentDialog).getByRole('checkbox', {
|
|
name: '选择画布图片 附件 A',
|
|
}),
|
|
);
|
|
fireEvent.click(
|
|
within(attachmentDialog).getByRole('button', { name: '应用' }),
|
|
);
|
|
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
|
|
|
await waitFor(() => {
|
|
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
'conversation-1',
|
|
expect.objectContaining({
|
|
attachments: [expect.objectContaining({ referenceId: 'resource-a' })],
|
|
}),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
await waitFor(() =>
|
|
expect(messageLog.querySelector('.grid.grid-cols-3 > div')).toBeTruthy(),
|
|
);
|
|
fireEvent.contextMenu(
|
|
messageLog.querySelector('.grid.grid-cols-3 > div')!,
|
|
{ clientX: 30, clientY: 40 },
|
|
);
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '引用' }));
|
|
expect(await screen.findByText('附件 B')).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
rejectSend(new Error('Network error'));
|
|
});
|
|
|
|
expect(await screen.findByText('Network error')).toBeTruthy();
|
|
expect(screen.getByText('附件 A')).toBeTruthy();
|
|
expect(screen.getByText('附件 B')).toBeTruthy();
|
|
});
|
|
|
|
it('keeps the current snapshot when failed recovery has the same attachment identity', async () => {
|
|
const client = createClient();
|
|
let rejectSend!: (reason?: unknown) => void;
|
|
vi.mocked(client.sendMessage).mockImplementationOnce(
|
|
() =>
|
|
new Promise<EditorAgentMessageResponse>((_resolve, reject) => {
|
|
rejectSend = reject;
|
|
}),
|
|
);
|
|
const oldLayer = {
|
|
id: 'layer-updated',
|
|
resourceId: 'resource-updated',
|
|
title: '旧版附件',
|
|
src: '/generated-editor-images/old.png',
|
|
objectKey: 'editor/old.png',
|
|
x: 0,
|
|
y: 0,
|
|
width: 512,
|
|
height: 512,
|
|
originalWidth: 512,
|
|
originalHeight: 512,
|
|
zIndex: 1,
|
|
sourceType: 'generated' as const,
|
|
};
|
|
const currentLayer = {
|
|
...oldLayer,
|
|
title: '当前附件',
|
|
src: '/generated-editor-images/current.png',
|
|
objectKey: 'editor/current.png',
|
|
};
|
|
const { rerender } = render(
|
|
<EditorAgentConversationPanelView
|
|
open
|
|
onToggleOpen={vi.fn()}
|
|
client={client}
|
|
layers={[oldLayer]}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '添加附件' }));
|
|
let 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.sendMessage).toHaveBeenCalledWith(
|
|
'conversation-1',
|
|
expect.objectContaining({
|
|
attachments: [
|
|
expect.objectContaining({
|
|
referenceId: 'resource-updated',
|
|
objectKey: 'editor/old.png',
|
|
}),
|
|
],
|
|
}),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
rerender(
|
|
<EditorAgentConversationPanelView
|
|
open
|
|
onToggleOpen={vi.fn()}
|
|
client={client}
|
|
layers={[currentLayer]}
|
|
/>,
|
|
);
|
|
fireEvent.click(screen.getByRole('button', { name: '添加附件' }));
|
|
attachmentDialog = screen.getByRole('dialog', {
|
|
name: '选择图片附件',
|
|
});
|
|
fireEvent.click(
|
|
within(attachmentDialog).getByRole('checkbox', {
|
|
name: '选择画布图片 当前附件',
|
|
}),
|
|
);
|
|
fireEvent.click(
|
|
within(attachmentDialog).getByRole('button', { name: '应用' }),
|
|
);
|
|
|
|
await act(async () => {
|
|
rejectSend(new Error('Network error'));
|
|
});
|
|
|
|
expect(await screen.findByText('Network error')).toBeTruthy();
|
|
expect(screen.getByText('当前附件')).toBeTruthy();
|
|
expect(screen.queryByText('旧版附件')).toBeNull();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
|
await waitFor(() => {
|
|
expect(client.sendMessage).toHaveBeenCalledTimes(2);
|
|
expect(
|
|
vi.mocked(client.sendMessage).mock.calls[1]?.[1].attachments,
|
|
).toEqual([
|
|
expect.objectContaining({
|
|
referenceId: 'resource-updated',
|
|
objectKey: 'editor/current.png',
|
|
}),
|
|
]);
|
|
});
|
|
});
|
|
|
|
it('keeps the latest nine attachments when failed attachment recovery would exceed the limit', async () => {
|
|
const client = createClient();
|
|
let rejectSend!: (reason?: unknown) => void;
|
|
vi.mocked(client.sendMessage).mockImplementationOnce(
|
|
() =>
|
|
new Promise<EditorAgentMessageResponse>((_resolve, reject) => {
|
|
rejectSend = reject;
|
|
}),
|
|
);
|
|
const layers = Array.from({ length: 18 }, (_, index) => ({
|
|
id: `layer-${index + 1}`,
|
|
resourceId: `resource-${index + 1}`,
|
|
title: `附件-${index + 1}`,
|
|
src: `/generated-editor-images/attachment-${index + 1}.png`,
|
|
objectKey: `editor/attachment-${index + 1}.png`,
|
|
x: 0,
|
|
y: 0,
|
|
width: 512,
|
|
height: 512,
|
|
originalWidth: 512,
|
|
originalHeight: 512,
|
|
zIndex: index + 1,
|
|
sourceType: 'generated' as const,
|
|
}));
|
|
|
|
render(
|
|
<EditorAgentConversationPanelView
|
|
open
|
|
onToggleOpen={vi.fn()}
|
|
client={client}
|
|
layers={layers}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '添加附件' }));
|
|
let attachmentDialog = screen.getByRole('dialog', {
|
|
name: '选择图片附件',
|
|
});
|
|
for (let index = 1; index <= 9; index += 1) {
|
|
fireEvent.click(
|
|
within(attachmentDialog).getByRole('checkbox', {
|
|
name: `选择画布图片 附件-${index}`,
|
|
}),
|
|
);
|
|
}
|
|
fireEvent.click(
|
|
within(attachmentDialog).getByRole('button', { name: '应用' }),
|
|
);
|
|
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
|
|
|
await waitFor(() => {
|
|
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
'conversation-1',
|
|
expect.objectContaining({ attachments: expect.any(Array) }),
|
|
expect.any(Object),
|
|
);
|
|
expect(
|
|
vi.mocked(client.sendMessage).mock.calls[0]?.[1].attachments,
|
|
).toHaveLength(9);
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '添加附件' }));
|
|
attachmentDialog = screen.getByRole('dialog', {
|
|
name: '选择图片附件',
|
|
});
|
|
for (let index = 10; index <= 18; index += 1) {
|
|
fireEvent.click(
|
|
within(attachmentDialog).getByRole('checkbox', {
|
|
name: `选择画布图片 附件-${index}`,
|
|
}),
|
|
);
|
|
}
|
|
fireEvent.click(
|
|
within(attachmentDialog).getByRole('button', { name: '应用' }),
|
|
);
|
|
|
|
await act(async () => {
|
|
rejectSend(new Error('Network error'));
|
|
});
|
|
|
|
expect(
|
|
await screen.findByText('最多 9 张,发送失败的附件未恢复'),
|
|
).toBeTruthy();
|
|
for (let index = 1; index <= 9; index += 1) {
|
|
expect(screen.queryByText(`附件-${index}`)).toBeNull();
|
|
}
|
|
for (let index = 10; index <= 18; index += 1) {
|
|
expect(screen.getByText(`附件-${index}`)).toBeTruthy();
|
|
}
|
|
});
|
|
|
|
it('preserves messages when the panel is collapsed and reopened', async () => {
|
|
const client = createClient();
|
|
vi.mocked(client.sendMessage).mockResolvedValue({
|
|
conversation: {
|
|
conversationId: 'conversation-1',
|
|
projectId: 'project-1',
|
|
title: '角色参考',
|
|
updatedAt: '2026-07-03T00:00:10.000Z',
|
|
},
|
|
deltaMessages: [
|
|
{
|
|
id: 1,
|
|
role: 'assistant',
|
|
text: '我来生成图片。',
|
|
attachments: [],
|
|
toolCall: null,
|
|
createdAt: '2026-07-03T00:00:00.000Z',
|
|
},
|
|
],
|
|
errorMessage: null,
|
|
} as EditorAgentMessageResponse);
|
|
|
|
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();
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
it('shows structured pending details, hides system text and confirms once', async () => {
|
|
const client = createClient();
|
|
const pendingMessage = createPendingToolCallMessage();
|
|
const confirmedMessage: EditorAgentMessage = {
|
|
...pendingMessage,
|
|
text: 'internal completed tool output that must stay hidden',
|
|
toolCall: {
|
|
...pendingMessage.toolCall!,
|
|
externalJobId: 'task-edit-panel-1',
|
|
},
|
|
};
|
|
vi.mocked(client.getConversation)
|
|
.mockResolvedValueOnce({
|
|
conversationId: 'conversation-1',
|
|
projectId: 'project-1',
|
|
title: '角色参考',
|
|
messages: [pendingMessage],
|
|
createdAt: '2026-07-03T00:00:00.000Z',
|
|
updatedAt: '2026-07-03T00:00:10.000Z',
|
|
})
|
|
.mockResolvedValue({
|
|
conversationId: 'conversation-1',
|
|
projectId: 'project-1',
|
|
title: '角色参考',
|
|
messages: [confirmedMessage],
|
|
createdAt: '2026-07-03T00:00:00.000Z',
|
|
updatedAt: '2026-07-03T00:00:10.000Z',
|
|
});
|
|
let resolveConfirmation: () => void = () => {};
|
|
vi.mocked(client.confirmToolCall).mockImplementation(
|
|
() =>
|
|
new Promise<void>((resolve) => {
|
|
resolveConfirmation = resolve;
|
|
}),
|
|
);
|
|
const onCanvasRefreshRequested = vi.fn();
|
|
|
|
render(
|
|
<EditorAgentConversationPanelView
|
|
open
|
|
onToggleOpen={vi.fn()}
|
|
client={client}
|
|
onCanvasRefreshRequested={onCanvasRefreshRequested}
|
|
/>,
|
|
);
|
|
|
|
expect(await screen.findByText('把角色换成像素风')).toBeTruthy();
|
|
expect(screen.getByText('提示词')).toBeTruthy();
|
|
expect(screen.getByText('2 张')).toBeTruthy();
|
|
expect(screen.getByAltText('目标图片:原角色')).toBeTruthy();
|
|
expect(screen.getByAltText('参考图片:像素风参考')).toBeTruthy();
|
|
expect(screen.getByAltText('参考图片:配色参考')).toBeTruthy();
|
|
expect(screen.getByText('预计消耗 3泥点')).toBeTruthy();
|
|
expect(screen.queryByText('source-image-1')).toBeNull();
|
|
expect(screen.queryByText('reference-image-1')).toBeNull();
|
|
expect(
|
|
screen.queryByText('internal system prompt that must stay hidden'),
|
|
).toBeNull();
|
|
const getConversationCallsBeforeConfirm = vi.mocked(client.getConversation)
|
|
.mock.calls.length;
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '确认' }));
|
|
await waitFor(() => {
|
|
expect(client.confirmToolCall).toHaveBeenCalledWith('conversation-1', 2);
|
|
});
|
|
expect(screen.getByRole('button', { name: '执行中' })).toBeTruthy();
|
|
expect(screen.queryByRole('button', { name: '确认' })).toBeNull();
|
|
expect(
|
|
screen.getByRole('button', { name: '取消' }).hasAttribute('disabled'),
|
|
).toBe(true);
|
|
|
|
await act(async () => {
|
|
resolveConfirmation();
|
|
});
|
|
|
|
expect(await screen.findByText('执行中')).toBeTruthy();
|
|
expect(client.getConversation).toHaveBeenCalledTimes(
|
|
getConversationCallsBeforeConfirm + 1,
|
|
);
|
|
expect(
|
|
screen.queryByText(
|
|
'internal completed tool output that must stay hidden',
|
|
),
|
|
).toBeNull();
|
|
expect(onCanvasRefreshRequested).not.toHaveBeenCalled();
|
|
expect(screen.queryByRole('button', { name: '确认' })).toBeNull();
|
|
});
|
|
|
|
it('cancels a pending tool call without exposing its system text', async () => {
|
|
const client = createClient();
|
|
const pendingMessage = createPendingToolCallMessage();
|
|
const cancelledMessage: EditorAgentMessage = {
|
|
...pendingMessage,
|
|
text: 'internal cancelled tool output that must stay hidden',
|
|
toolCall: {
|
|
...pendingMessage.toolCall!,
|
|
status: 'cancelled',
|
|
},
|
|
};
|
|
vi.mocked(client.getConversation)
|
|
.mockResolvedValueOnce({
|
|
conversationId: 'conversation-1',
|
|
projectId: 'project-1',
|
|
title: '角色参考',
|
|
messages: [pendingMessage],
|
|
createdAt: '2026-07-03T00:00:00.000Z',
|
|
updatedAt: '2026-07-03T00:00:10.000Z',
|
|
})
|
|
.mockResolvedValue({
|
|
conversationId: 'conversation-1',
|
|
projectId: 'project-1',
|
|
title: '角色参考',
|
|
messages: [cancelledMessage],
|
|
createdAt: '2026-07-03T00:00:00.000Z',
|
|
updatedAt: '2026-07-03T00:00:10.000Z',
|
|
});
|
|
vi.mocked(client.cancelToolCall).mockResolvedValue(undefined);
|
|
|
|
render(
|
|
<EditorAgentConversationPanelView
|
|
open
|
|
onToggleOpen={vi.fn()}
|
|
client={client}
|
|
/>,
|
|
);
|
|
|
|
const cancelButton = await screen.findByRole('button', { name: '取消' });
|
|
const getConversationCallsBeforeCancel = vi.mocked(client.getConversation)
|
|
.mock.calls.length;
|
|
fireEvent.click(cancelButton);
|
|
|
|
expect(await screen.findByText('已取消')).toBeTruthy();
|
|
expect(client.cancelToolCall).toHaveBeenCalledWith('conversation-1', 2);
|
|
expect(client.getConversation).toHaveBeenCalledTimes(
|
|
getConversationCallsBeforeCancel + 1,
|
|
);
|
|
expect(
|
|
screen.queryByText(
|
|
'internal cancelled tool output that must stay hidden',
|
|
),
|
|
).toBeNull();
|
|
expect(screen.queryByRole('button', { name: '确认' })).toBeNull();
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|
|
});
|