Files
makelore/tests/unit/coding-conversation-timeline.test.tsx

290 lines
11 KiB
TypeScript

import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { Profiler } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createProductSnapshot } from '../fixtures/coding-conversation-product-fixtures';
const attachmentApi = vi.hoisted(() => ({ load: vi.fn() }));
vi.mock('@/lib/coding-attachments', () => ({
loadCodingAttachmentPreview: attachmentApi.load,
}));
describe('CodingConversationTimeline', () => {
afterEach(() => vi.unstubAllGlobals());
it('resolves attachment refs into temporary object URLs without base64 state', async () => {
const createObjectURL = vi.fn(() => 'blob:authenticated-preview');
const revokeObjectURL = vi.fn();
class TestURL extends URL {}
TestURL.createObjectURL = createObjectURL;
TestURL.revokeObjectURL = revokeObjectURL;
vi.stubGlobal('URL', TestURL);
attachmentApi.load.mockResolvedValue({
bytes: new Uint8Array([1, 2, 3]),
mime: 'image/png',
});
const { codingConversationStore } = await import('@/stores/coding-conversations');
const { CodingConversationTimeline } = await import(
'@/pages/Chat/CodingConversationTimeline'
);
const base = createProductSnapshot('conversation-attachment', 1);
const snapshot = {
...base,
nodes: [{
kind: 'message' as const,
id: 'message-1',
role: 'user' as const,
status: 'complete' as const,
blocks: [{
kind: 'image' as const,
id: 'image-1',
attachmentId: 'attachment-1',
mime: 'image/png',
}],
}],
};
codingConversationStore.getState().applySnapshotEvent({
type: 'snapshot',
conversationId: 'conversation-attachment',
workerGeneration: 1,
seq: snapshot.cursor.seq,
snapshot,
});
const view = render(
<CodingConversationTimeline conversationId="conversation-attachment" />,
);
await waitFor(() => expect(screen.getByRole('img', { name: '对话图片附件' }))
.toHaveAttribute('src', 'blob:authenticated-preview'));
expect(attachmentApi.load).toHaveBeenCalledWith('attachment-1');
expect(createObjectURL).toHaveBeenCalledOnce();
const blob = createObjectURL.mock.calls[0][0] as Blob;
expect(blob.type).toBe('image/png');
expect(JSON.stringify(codingConversationStore.getState())).not.toContain('base64');
view.unmount();
expect(revokeObjectURL).toHaveBeenCalledWith('blob:authenticated-preview');
});
it('windows long timelines and loads earlier nodes only on demand', async () => {
const { codingConversationStore } = await import('@/stores/coding-conversations');
const { CodingConversationTimeline } = await import(
'@/pages/Chat/CodingConversationTimeline'
);
const base = createProductSnapshot('conversation-window', 1);
const snapshot = {
...base,
nodes: Array.from({ length: 150 }, (_, index) => ({
kind: 'notice' as const,
id: `notice-${index}`,
code: `NOTICE_${index}`,
level: 'info' as const,
message: `Notice ${index}`,
})),
};
codingConversationStore.getState().applySnapshotEvent({
type: 'snapshot',
conversationId: 'conversation-window',
workerGeneration: 1,
seq: snapshot.cursor.seq,
snapshot,
});
render(<CodingConversationTimeline conversationId="conversation-window" />);
expect(screen.queryByText('Notice 0')).not.toBeInTheDocument();
expect(screen.getByText('Notice 30')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '加载更早内容' }));
expect(screen.getByText('Notice 0')).toBeInTheDocument();
});
it('does not commit the selected timeline when a hidden Conversation streams', async () => {
const { codingConversationStore } = await import('@/stores/coding-conversations');
const { CodingConversationTimeline } = await import(
'@/pages/Chat/CodingConversationTimeline'
);
const selected = createProductSnapshot('conversation-selected', 1);
const hidden = createProductSnapshot('conversation-hidden', 1);
codingConversationStore.getState().applySnapshotEvent({
type: 'snapshot',
conversationId: 'conversation-selected',
workerGeneration: 1,
seq: selected.cursor.seq,
snapshot: selected,
});
codingConversationStore.getState().applySnapshotEvent({
type: 'snapshot',
conversationId: 'conversation-hidden',
workerGeneration: 1,
seq: hidden.cursor.seq,
snapshot: hidden,
});
const commits: number[] = [];
render(
<Profiler id="selected-timeline" onRender={(_id, _phase, duration) => commits.push(duration)}>
<CodingConversationTimeline conversationId="conversation-selected" />
</Profiler>,
);
commits.length = 0;
act(() => {
codingConversationStore.getState().applyPatchBatchEvent({
type: 'patch-batch',
conversationId: 'conversation-hidden',
workerGeneration: 1,
fromSeq: 1,
toSeq: 1,
items: [{
seq: 1,
at: 1,
patch: {
op: 'message.upsert',
node: {
kind: 'message',
id: 'hidden-message',
role: 'assistant',
status: 'streaming',
blocks: [{
kind: 'text',
id: 'hidden-text',
text: 'hidden token',
status: 'streaming',
}],
},
},
}],
});
});
expect(commits).toEqual([]);
});
it('renders nested subagents, compacted retry state, inline tool output, and explicit branch wording', async () => {
const { codingConversationStore } = await import('@/stores/coding-conversations');
const { CodingConversationTimeline } = await import(
'@/pages/Chat/CodingConversationTimeline'
);
const base = createProductSnapshot('conversation-feature-ui', 1);
const snapshot = {
...base,
nodes: [
{
kind: 'message' as const,
id: 'message-user-feature',
sourceEntryId: 'entry-user-feature',
role: 'user' as const,
status: 'complete' as const,
blocks: [{ kind: 'text' as const, id: 'text-user-feature', text: 'Build it', status: 'complete' as const }],
},
{
kind: 'message' as const,
id: 'message-feature',
sourceEntryId: 'entry-assistant-feature',
role: 'assistant' as const,
status: 'complete' as const,
blocks: [{ kind: 'text' as const, id: 'text-feature', text: 'Ready', status: 'complete' as const }],
},
{
kind: 'message' as const,
id: 'message-optimistic-feature',
sourceEntryId: 'entry-optimistic-feature',
clientRequestId: 'request-optimistic-feature',
role: 'user' as const,
status: 'optimistic' as const,
blocks: [{ kind: 'text' as const, id: 'text-optimistic-feature', text: 'Pending', status: 'complete' as const }],
},
{
kind: 'message' as const,
id: 'message-without-entry-feature',
role: 'user' as const,
status: 'complete' as const,
blocks: [{ kind: 'text' as const, id: 'text-without-entry-feature', text: 'Local only', status: 'complete' as const }],
},
{
kind: 'tool' as const,
id: 'tool-feature',
toolCallId: 'call-feature',
toolName: 'read_file',
title: '读取文件',
inputText: 'src/app.ts',
status: 'complete' as const,
output: [{ kind: 'text' as const, id: 'tool-output', text: 'inline result', status: 'complete' as const }],
details: { schema: 'changed-file.v1' as const, paths: ['src/app.ts'] },
},
{
kind: 'subagent' as const,
id: 'subagent-feature',
runId: 'run-feature',
details: {
schema: 'subagent.v1' as const,
dispatchId: 'dispatch-feature',
mode: 'parallel' as const,
tasks: [
{ taskId: 'task-a', agentId: 'reader', toolProfile: 'read-only' as const, status: 'complete' as const, summary: 'Read complete' },
{ taskId: 'task-b', agentId: 'builder', toolProfile: 'coding' as const, status: 'error' as const, errorCode: 'BUILD_FAILED' },
],
},
},
{
kind: 'subagent' as const,
id: 'subagent-single',
runId: 'run-feature',
details: {
schema: 'subagent.v1' as const,
dispatchId: 'dispatch-single',
mode: 'single' as const,
tasks: [{ taskId: 'task-single', agentId: 'reviewer', toolProfile: 'read-only' as const, status: 'aborted' as const }],
},
},
{
kind: 'subagent' as const,
id: 'subagent-chain',
runId: 'run-feature',
details: {
schema: 'subagent.v1' as const,
dispatchId: 'dispatch-chain',
mode: 'chain' as const,
tasks: [
{ taskId: 'task-chain-a', agentId: 'planner', toolProfile: 'read-only' as const, status: 'complete' as const },
{ taskId: 'task-chain-b', agentId: 'implementer', toolProfile: 'coding' as const, status: 'skipped' as const },
],
},
},
{
kind: 'compaction' as const,
id: 'compact-feature',
runId: 'run-feature',
source: 'automatic' as const,
status: 'error' as const,
willRetry: true,
summary: '保留了本轮关键上下文。',
},
],
};
codingConversationStore.getState().applySnapshotEvent({
type: 'snapshot',
conversationId: 'conversation-feature-ui',
workerGeneration: 1,
seq: snapshot.cursor.seq,
snapshot,
});
const onFork = vi.fn();
render(<CodingConversationTimeline conversationId="conversation-feature-ui" onFork={onFork} />);
const forkActions = screen.getAllByRole('button', { name: '从这里创建新对话分支' });
expect(forkActions).toHaveLength(1);
fireEvent.click(forkActions[0]!);
expect(onFork).toHaveBeenCalledWith('entry-user-feature');
expect(screen.getByText('并行子任务')).toBeInTheDocument();
expect(screen.getByText('单个子任务')).toBeInTheDocument();
expect(screen.getByText('串行子任务')).toBeInTheDocument();
expect(screen.getByText('reader')).toBeInTheDocument();
expect(screen.getByText('BUILD_FAILED')).toBeInTheDocument();
expect(screen.getByText('系统会自动重试。')).toBeInTheDocument();
expect(screen.getByText('inline result').closest('[data-node-kind="tool"]')).not.toBeNull();
expect(screen.queryByText(/回滚|revert/i)).not.toBeInTheDocument();
});
});