Files
Genarrative/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx
T
k88936 f774a791e8 画布Agent关联外部生成任务
工具消息改为保存外部任务 ID 与取消事实,不再复制执行状态。

确认请求以稳定身份提交外部任务,并由前端查询任务状态和结果。

同步更新画布 Agent 测试与执行契约文档。
2026-07-13 14:24:24 +08:00

701 lines
20 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 {
EditorAgentMessage,
EditorAgentMessageResponse,
} from '@/packages/shared/src/contracts';
import type { EditorAgentConversationClient } 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: 1,
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: 2,
role: 'assistant',
text: '收到,我会参考这张图。',
attachments: [],
toolCall: null,
createdAt: '2026-07-03T00:00:00.000Z',
},
],
errorMessage: null,
} as EditorAgentMessageResponse),
confirmToolCall: vi.fn(),
cancelToolCall: vi.fn(),
};
}
function createPendingToolCallMessage(): EditorAgentMessage {
return {
id: 2,
role: 'system',
text: 'internal system prompt that must stay hidden',
attachments: [],
toolCall: {
toolName: 'edit-image',
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(
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('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('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('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: 3,
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();
vi.mocked(client.getConversation).mockResolvedValue({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
messages: [pendingMessage],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:10.000Z',
});
let resolveConfirmation: (message: EditorAgentMessage) => void = () => {};
vi.mocked(client.confirmToolCall).mockImplementation(
() =>
new Promise<EditorAgentMessage>((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();
fireEvent.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() => {
expect(client.confirmToolCall).toHaveBeenCalledWith('conversation-1', 2);
});
expect(
within(screen.getByRole('article', { name: 'Agent操作' })).getByText(
'执行中',
),
).toBeTruthy();
expect(screen.queryByRole('button', { name: '确认' })).toBeNull();
expect(screen.queryByRole('button', { name: '取消' })).toBeNull();
await act(async () => {
resolveConfirmation({
...pendingMessage,
text: 'internal completed tool output that must stay hidden',
toolCall: {
...pendingMessage.toolCall!,
externalJobId: 'task-edit-panel-1',
images: [
{
resourceId: null,
objectKey: 'generated/result.png',
imageSrc: '/result.png',
thumbnailSrc: null,
width: 512,
height: 512,
},
],
},
});
});
expect(await screen.findByText('已完成')).toBeTruthy();
expect(
screen.queryByText(
'internal completed tool output that must stay hidden',
),
).toBeNull();
expect(onCanvasRefreshRequested).toHaveBeenCalledTimes(1);
});
it('cancels a pending tool call without exposing its system text', async () => {
const client = createClient();
const pendingMessage = createPendingToolCallMessage();
vi.mocked(client.getConversation).mockResolvedValue({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
messages: [pendingMessage],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:10.000Z',
});
vi.mocked(client.cancelToolCall).mockResolvedValue({
...pendingMessage,
text: 'internal cancelled tool output that must stay hidden',
toolCall: {
...pendingMessage.toolCall!,
cancelledAt: '2026-07-03T00:00:11.000Z',
},
});
render(
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
/>,
);
fireEvent.click(await screen.findByRole('button', { name: '取消' }));
expect(await screen.findByText('已取消')).toBeTruthy();
expect(client.cancelToolCall).toHaveBeenCalledWith('conversation-1', 2);
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');
});
});
});