Files
Genarrative/src/components/image-editor/useEditorAgentConversation.test.tsx
T
kdletters 1671d35995 修复画布Agent聊天生成体验
让画布 Agent 消息统一走后端规划与 LLM 回复
修复生成图片在聊天、画布和素材库中的展示与恢复
补齐右键删除、画布焦点、聊天滚轮和面板避让交互
同步 Agent 事件契约、文档和定向回归测试
2026-07-04 16:41:33 +08:00

429 lines
12 KiB
TypeScript

/* @vitest-environment jsdom */
import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
type EditorAgentConversationClient,
useEditorAgentConversation,
} from './useEditorAgentConversation';
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:00:00.000Z',
updatedAt: '2026-07-03T00:01:00.000Z',
}),
getConversation: 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',
}),
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: 'conversation-1',
stage: 'thinking',
},
});
options.onEvent?.({
event: 'message_delta',
data: {
conversationId: 'conversation-1',
messageId: 'assistant-1',
role: 'assistant',
kind: 'chat',
textDelta: '我来处理',
},
});
options.onEvent?.({
event: 'tool_started',
data: {
conversationId: 'conversation-1',
messageId: 'assistant-1',
toolCallId: 'tool-call-1',
toolName: 'generate_image',
taskId: 'task-1',
model: 'gpt-image-2',
},
});
options.onEvent?.({
event: 'generation_result',
data: {
conversationId: 'conversation-1',
messageId: 'assistant-1',
toolCallId: 'tool-call-1',
toolName: 'generate_image',
model: null,
images: [
{
resourceId: 'resource-result-1',
imageSrc: '/result.png',
thumbnailSrc: '/result-thumb.png',
width: 1024,
height: 1024,
},
],
},
});
options.onEvent?.({
event: 'done',
data: {
conversationId: 'conversation-1',
title: '像素角色',
},
});
}),
};
}
describe('useEditorAgentConversation', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('loads conversations and applies message stream events', async () => {
const client = createClient();
const onGenerationResult = vi.fn();
const { result } = renderHook(() =>
useEditorAgentConversation({
projectId: 'project-1',
client,
onGenerationResult,
}),
);
await waitFor(() => {
expect(result.current.activeConversation?.conversationId).toBe(
'conversation-1',
);
});
await act(async () => {
await result.current.sendMessage('把这个角色改成像素风');
});
expect(client.streamMessage).toHaveBeenCalledWith(
'conversation-1',
expect.objectContaining({
text: '把这个角色改成像素风',
attachments: [],
}),
expect.objectContaining({
onEvent: expect.any(Function),
signal: expect.any(AbortSignal),
}),
);
expect(result.current.stage).toBe('completed');
expect(result.current.isStreaming).toBe(false);
expect(onGenerationResult).toHaveBeenCalledWith(
expect.objectContaining({
toolCallId: 'tool-call-1',
images: [
expect.objectContaining({
resourceId: 'resource-result-1',
}),
],
}),
);
expect(result.current.messages.map((message) => message.text)).toEqual([
'把这个角色改成像素风',
'我来处理',
]);
expect(result.current.messages[1]?.generations).toEqual([
expect.objectContaining({
toolCallId: 'tool-call-1',
taskId: 'task-1',
model: 'gpt-image-2',
status: 'completed',
images: [
expect.objectContaining({
resourceId: 'resource-result-1',
thumbnailSrc: '/result-thumb.png',
}),
],
}),
]);
expect(result.current.conversations[0]?.title).toBe('像素角色');
});
it('creates a conversation before sending when the project has no history', async () => {
const client = createClient();
vi.mocked(client.listConversations).mockResolvedValueOnce([]);
const { result } = renderHook(() =>
useEditorAgentConversation({ projectId: 'project-1', client }),
);
await waitFor(() => {
expect(result.current.isLoadingConversations).toBe(false);
});
await act(async () => {
await result.current.sendMessage('新建后发送');
});
expect(client.createConversation).toHaveBeenCalledWith('project-1', {});
expect(client.streamMessage).toHaveBeenCalledWith(
'conversation-2',
expect.objectContaining({ text: '新建后发送' }),
expect.any(Object),
);
});
it('allows sending an attachment-only message', async () => {
const client = createClient();
const { result } = renderHook(() =>
useEditorAgentConversation({ projectId: 'project-1', client }),
);
await waitFor(() => {
expect(result.current.activeConversation?.conversationId).toBe(
'conversation-1',
);
});
await act(async () => {
await result.current.sendMessage('', [
{
source: 'canvas_resource',
referenceId: 'resource-1',
objectKey: 'generated-editor-assets/resource-1.png',
imageSrc: '/resource-1.png',
label: '参考图',
},
]);
});
expect(client.streamMessage).toHaveBeenCalledWith(
'conversation-1',
expect.objectContaining({
text: '',
attachments: [
expect.objectContaining({
source: 'canvas_resource',
referenceId: 'resource-1',
}),
],
}),
expect.any(Object),
);
});
it('keeps LLM planning failures as one failed assistant message', async () => {
const client = createClient();
vi.mocked(client.streamMessage).mockImplementation(
async (_conversationId, _payload, options) => {
options.onEvent?.({
event: 'message_delta',
data: {
conversationId: 'conversation-1',
messageId: 'assistant-planning-error',
role: 'assistant',
kind: 'error',
textDelta: '画布 Agent 的 LLM 未配置,无法处理这句话。',
},
});
options.onEvent?.({
event: 'stage',
data: {
conversationId: 'conversation-1',
stage: 'failed',
},
});
options.onEvent?.({
event: 'done',
data: {
conversationId: 'conversation-1',
title: null,
},
});
},
);
const { result } = renderHook(() =>
useEditorAgentConversation({ projectId: 'project-1', client }),
);
await waitFor(() => {
expect(result.current.activeConversation?.conversationId).toBe(
'conversation-1',
);
});
await act(async () => {
await result.current.sendMessage('这是美术素材');
});
expect(result.current.stage).toBe('failed');
expect(result.current.errorMessage).toBe(
'画布 Agent 的 LLM 未配置,无法处理这句话。',
);
expect(
result.current.messages.filter((message) => message.kind === 'error'),
).toEqual([
expect.objectContaining({
id: 'assistant-planning-error',
text: '画布 Agent 的 LLM 未配置,无法处理这句话。',
status: 'failed',
}),
]);
});
it('shows a failed tool completion as a generation record', async () => {
const client = createClient();
vi.mocked(client.streamMessage).mockImplementation(
async (_conversationId, _payload, options) => {
options.onEvent?.({
event: 'message_delta',
data: {
conversationId: 'conversation-1',
messageId: 'assistant-failed',
role: 'assistant',
kind: 'chat',
textDelta: '我先尝试生成。',
},
});
options.onEvent?.({
event: 'tool_started',
data: {
conversationId: 'conversation-1',
messageId: 'assistant-failed',
toolCallId: 'tool-call-failed',
toolName: 'generate_image',
taskId: null,
model: 'gpt-image-2',
status: 'generating',
},
});
options.onEvent?.({
event: 'tool_completed',
data: {
conversationId: 'conversation-1',
messageId: 'assistant-failed',
toolCallId: 'tool-call-failed',
toolName: 'generate_image',
taskId: null,
model: 'gpt-image-2',
status: 'failed',
error: '泥点余额不足',
},
});
options.onEvent?.({
event: 'error',
data: {
conversationId: 'conversation-1',
code: 'INSUFFICIENT_BALANCE',
message: '泥点余额不足',
recoverable: true,
},
});
},
);
const { result } = renderHook(() =>
useEditorAgentConversation({ projectId: 'project-1', client }),
);
await waitFor(() => {
expect(result.current.activeConversation?.conversationId).toBe(
'conversation-1',
);
});
await act(async () => {
await expect(
result.current.sendMessage('生成一张图'),
).resolves.toBeUndefined();
});
const failedMessage = result.current.messages.find(
(message) => message.id === 'assistant-failed',
);
expect(failedMessage?.status).toBe('failed');
expect(failedMessage?.generations).toEqual([
expect.objectContaining({
toolCallId: 'tool-call-failed',
status: 'failed',
model: 'gpt-image-2',
error: '泥点余额不足',
}),
]);
expect(result.current.errorMessage).toBe('泥点余额不足');
});
it('aborts the active stream and marks streaming messages as stopped', async () => {
const client = createClient();
let streamSignal: AbortSignal | null = null;
vi.mocked(client.streamMessage).mockImplementation(
(_conversationId, _payload, options) =>
new Promise<void>((resolve) => {
streamSignal = options.signal ?? null;
options.onEvent?.({
event: 'message_delta',
data: {
conversationId: 'conversation-1',
messageId: 'assistant-streaming',
role: 'assistant',
kind: 'chat',
textDelta: '处理中',
},
});
streamSignal?.addEventListener('abort', () => resolve(), {
once: true,
});
}),
);
const { result } = renderHook(() =>
useEditorAgentConversation({ projectId: 'project-1', client }),
);
await waitFor(() => {
expect(result.current.activeConversation?.conversationId).toBe(
'conversation-1',
);
});
void act(() => {
void result.current.sendMessage('请继续');
});
await waitFor(() => {
expect(result.current.isStreaming).toBe(true);
});
act(() => {
result.current.stopCurrentTurn();
});
await waitFor(() => {
expect(streamSignal?.aborted).toBe(true);
});
expect(result.current.messages.at(-1)?.status).toBe('stopped');
expect(result.current.isStreaming).toBe(false);
});
});