163 lines
5.5 KiB
TypeScript
163 lines
5.5 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([]);
|
|
});
|
|
});
|