Files
Genarrative/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx
T

541 lines
16 KiB
TypeScript

/* @vitest-environment jsdom */
import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type {
EditorAgentMessage,
EditorAgentMessageResponse,
} from '../../../../packages/shared/src/contracts/editorAgent.ts';
import {
type EditorAgentConversationClient,
useEditorAgentConversation,
} from './useEditorAgentConversation.ts';
function createEditImageDisplayArgs(prompt: string) {
return {
stringArgs: [{ name: 'prompt', label: '修改要求', value: prompt }],
imageArgs: [
{
name: 'object_image_id',
label: '目标图片',
refs: [
{
imageId: 'source-image',
imageSrc: '/source-image.png',
label: '源图片',
},
],
},
],
extras: { priceMudPoints: 3 },
};
}
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: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',
}),
sendMessage: vi.fn().mockResolvedValue({
conversation: {
conversationId: 'conversation-1',
projectId: 'project-1',
title: '把这个角色改成像素风',
updatedAt: '2026-07-03T00:00:01.000Z',
},
deltaMessages: [
{
id: 1,
role: 'assistant',
text: '我来处理',
attachments: [],
toolCall: {
toolName: 'generate_image',
status: 'completed',
args: {},
displayArgs: {
stringArgs: [],
imageArgs: [],
extras: { priceMudPoints: 0 },
},
images: [
{
resourceId: 'resource-result-1',
imageSrc: '/result.png',
thumbnailSrc: '/result-thumb.png',
width: 1024,
height: 1024,
},
],
error: null,
},
createdAt: '2026-07-03T00:00:00.000Z',
},
],
errorMessage: null,
} as EditorAgentMessageResponse),
confirmToolCall: vi.fn(),
cancelToolCall: vi.fn(),
};
}
describe('useEditorAgentConversation', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('loads conversations and applies delta messages', async () => {
const client = createClient();
const onCanvasRefreshRequested = vi.fn();
const { result } = renderHook(() =>
useEditorAgentConversation({
projectId: 'project-1',
client,
onCanvasRefreshRequested,
}),
);
await waitFor(() => {
expect(result.current.activeConversation?.conversationId).toBe(
'conversation-1',
);
});
await act(async () => {
await result.current.sendMessage('把这个角色改成像素风');
});
expect(client.sendMessage).toHaveBeenCalledWith(
'conversation-1',
expect.objectContaining({
text: '把这个角色改成像素风',
attachments: [],
}),
expect.objectContaining({
signal: expect.any(AbortSignal),
}),
);
expect(result.current.isWaiting).toBe(false);
expect(result.current.activeConversation?.title).toBe(
'把这个角色改成像素风',
);
expect(onCanvasRefreshRequested).toHaveBeenCalledTimes(1);
expect(result.current.messages.map((message) => message.text)).toEqual([
'把这个角色改成像素风',
'我来处理',
]);
expect(result.current.messages[1]?.toolCall).toEqual(
expect.objectContaining({
toolName: 'generate_image',
status: 'completed',
images: [
expect.objectContaining({
resourceId: 'resource-result-1',
thumbnailSrc: '/result-thumb.png',
}),
],
}),
);
});
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.sendMessage).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.sendMessage).toHaveBeenCalledWith(
'conversation-1',
expect.objectContaining({
text: '',
attachments: [
expect.objectContaining({
source: 'canvas_resource',
referenceId: 'resource-1',
}),
],
}),
expect.any(Object),
);
});
it('handles backend error responses', async () => {
const client = createClient();
vi.mocked(client.sendMessage).mockResolvedValue({
conversation: {
conversationId: 'conversation-1',
projectId: 'project-1',
title: '这是美术素材',
updatedAt: '2026-07-03T00:00:01.000Z',
},
deltaMessages: [],
errorMessage: 'LLM 未配置,无法处理这句话。',
} as EditorAgentMessageResponse);
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.errorMessage).toBe('LLM 未配置,无法处理这句话。');
});
it('confirms a pending tool call, replaces its message and requests a canvas refresh', async () => {
const client = createClient();
vi.mocked(client.sendMessage).mockResolvedValue({
conversation: {
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
updatedAt: '2026-07-03T00:00:01.000Z',
},
deltaMessages: [
{
id: 1,
role: 'system',
text: '需要生成一张图',
attachments: [],
toolCall: {
toolName: 'edit-image',
status: 'pending_confirmation',
args: {
object_image_id: 'source-image',
prompt: '换成像素风',
},
displayArgs: createEditImageDisplayArgs('换成像素风'),
images: [],
error: null,
},
createdAt: '2026-07-03T00:00:00.000Z',
},
],
errorMessage: null,
} as EditorAgentMessageResponse);
vi.mocked(client.confirmToolCall).mockResolvedValue({
id: 1,
role: 'system',
text: 'internal completed tool output',
attachments: [],
toolCall: {
toolName: 'edit-image',
status: 'completed',
args: {
object_image_id: 'source-image',
prompt: '换成像素风',
},
displayArgs: createEditImageDisplayArgs('换成像素风'),
images: [
{
resourceId: null,
objectKey: 'generated/result.png',
imageSrc: '/result.png',
thumbnailSrc: null,
width: 512,
height: 512,
},
],
error: null,
},
createdAt: '2026-07-03T00:00:00.000Z',
});
const onCanvasRefreshRequested = vi.fn();
const { result } = renderHook(() =>
useEditorAgentConversation({
projectId: 'project-1',
client,
onCanvasRefreshRequested,
}),
);
await waitFor(() => {
expect(result.current.activeConversation?.conversationId).toBe(
'conversation-1',
);
});
await act(async () => {
await result.current.sendMessage('生成一张图');
});
const systemMsg = result.current.messages[1];
expect(systemMsg?.role).toBe('system');
expect(systemMsg?.toolCall?.status).toBe('pending_confirmation');
const messageCount = result.current.messages.length;
await act(async () => {
await result.current.confirmToolCall(1);
});
expect(client.confirmToolCall).toHaveBeenCalledWith('conversation-1', 1);
expect(result.current.messages).toHaveLength(messageCount);
expect(result.current.messages[1]?.toolCall?.status).toBe('completed');
expect(result.current.messages[1]?.toolCall?.images[0]?.objectKey).toBe(
'generated/result.png',
);
expect(onCanvasRefreshRequested).toHaveBeenCalledTimes(1);
});
it('marks a confirmation as executing before the request finishes', async () => {
const client = createClient();
const pendingMessage: EditorAgentMessage = {
id: 0,
role: 'system',
text: '需要生成一张图',
attachments: [],
toolCall: {
toolName: 'edit-image',
status: 'pending_confirmation',
args: { object_image_id: 'source-image', prompt: '换成像素风' },
displayArgs: createEditImageDisplayArgs('换成像素风'),
images: [],
error: null,
},
createdAt: '2026-07-03T00:00:00.000Z',
};
const completedMessage: EditorAgentMessage = {
...pendingMessage,
toolCall: {
...pendingMessage.toolCall!,
status: 'completed',
images: [],
},
};
let resolveConfirmation: ((message: EditorAgentMessage) => void) | undefined;
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:00.000Z',
});
vi.mocked(client.confirmToolCall).mockImplementation(
() =>
new Promise<EditorAgentMessage>((resolve) => {
resolveConfirmation = resolve;
}),
);
const { result } = renderHook(() =>
useEditorAgentConversation({ projectId: 'project-1', client }),
);
await waitFor(() => {
expect(result.current.messages[0]?.toolCall?.status).toBe(
'pending_confirmation',
);
});
act(() => {
void result.current.confirmToolCall(0);
});
await waitFor(() => {
expect(result.current.messages[0]?.toolCall?.status).toBe('executing');
});
await result.current.confirmToolCall(0);
expect(client.confirmToolCall).toHaveBeenCalledTimes(1);
await act(async () => {
resolveConfirmation?.(completedMessage);
});
await waitFor(() => {
expect(result.current.messages[0]?.toolCall?.status).toBe('completed');
});
});
it('cancels a pending tool call and keeps the replacement in the same position', async () => {
const client = createClient();
const pendingMessage = {
id: 0,
role: 'system' as const,
text: 'internal pending tool prompt',
attachments: [],
toolCall: {
toolName: 'edit-image',
status: 'pending_confirmation' as const,
args: { object_image_id: 'source-image', prompt: '换成像素风' },
displayArgs: createEditImageDisplayArgs('换成像素风'),
images: [],
error: null,
},
createdAt: '2026-07-03T00:00:00.000Z',
};
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:00.000Z',
});
vi.mocked(client.cancelToolCall).mockResolvedValue({
...pendingMessage,
text: 'internal cancelled tool output',
toolCall: {
...pendingMessage.toolCall,
status: 'cancelled',
},
});
const onCanvasRefreshRequested = vi.fn();
const { result } = renderHook(() =>
useEditorAgentConversation({
projectId: 'project-1',
client,
onCanvasRefreshRequested,
}),
);
await waitFor(() => {
expect(result.current.messages[0]?.toolCall?.status).toBe(
'pending_confirmation',
);
});
await act(async () => {
await result.current.cancelToolCall(0);
});
expect(client.cancelToolCall).toHaveBeenCalledWith('conversation-1', 0);
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0]?.toolCall?.status).toBe('cancelled');
expect(onCanvasRefreshRequested).not.toHaveBeenCalled();
});
it('handles fetch error as failed stage', async () => {
const client = createClient();
vi.mocked(client.sendMessage).mockRejectedValue(new Error('Network error'));
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('test')).resolves.toBeUndefined();
});
expect(result.current.errorMessage).toBe('Network error');
});
it('aborts the active request and resets state on stopCurrentTurn', async () => {
const client = createClient();
let capturedSignal: AbortSignal | null = null;
vi.mocked(client.sendMessage).mockImplementation(
(_conversationId, _payload, options) =>
new Promise<EditorAgentMessageResponse>((resolve, reject) => {
capturedSignal = options.signal ?? null;
capturedSignal?.addEventListener('abort', () => {
reject(new DOMException('Aborted', 'AbortError'));
});
}),
);
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.isWaiting).toBe(true);
});
act(() => {
result.current.stopCurrentTurn();
});
await waitFor(() => {
expect(capturedSignal?.aborted).toBe(true);
});
expect(result.current.isWaiting).toBe(false);
});
});