feat: implement PI core chat timeline
This commit is contained in:
162
tests/unit/coding-conversation-timeline.test.tsx
Normal file
162
tests/unit/coding-conversation-timeline.test.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user