summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat/test-helpers.ts
blob: 100449f11c491e4f59ebd5944f97615b107bdb73 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
import type { ChatQueueMessage, ChatSendMessage } from "@dispatch/transport-contract";
import type { StoredChunk } from "@dispatch/wire";
import type { ConversationCache } from "../conversation-cache";
import type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports";

export interface FakeTransport {
	/** All `chat.send` messages sent through the fake transport. */
	readonly sent: ChatSendMessage[];
	/** All `chat.queue` messages sent through the fake transport. */
	readonly sentQueue: ChatQueueMessage[];
	readonly impl: ChatTransport;
}

export function createFakeTransport(): FakeTransport {
	const sent: ChatSendMessage[] = [];
	const sentQueue: ChatQueueMessage[] = [];
	return {
		sent,
		sentQueue,
		impl: {
			send(msg) {
				if (msg.type === "chat.queue") {
					sentQueue.push(msg);
				} else {
					sent.push(msg);
				}
			},
		},
	};
}

export interface FakeHistorySync {
	readonly calls: Array<{ conversationId: string; sinceSeq: number; window?: HistoryWindow }>;
	/** Set the chunks to return on the next call. */
	returnChunks: readonly StoredChunk[];
	readonly impl: HistorySync;
}

export function createFakeHistorySync(): FakeHistorySync {
	const calls: Array<{ conversationId: string; sinceSeq: number; window?: HistoryWindow }> = [];
	let returnChunks: readonly StoredChunk[] = [];
	return {
		calls,
		get returnChunks() {
			return returnChunks;
		},
		set returnChunks(v: readonly StoredChunk[]) {
			returnChunks = v;
		},
		impl: async (conversationId, sinceSeq, window) => {
			calls.push({ conversationId, sinceSeq, ...(window !== undefined ? { window } : {}) });
			// Apply the CR-5 WINDOW semantics (`beforeSeq` bound, then newest-`limit`)
			// so store tests exercise the real windowed flows. `sinceSeq` filtering is
			// deliberately NOT applied — tests set `returnChunks` to the slice they
			// mean the server to hold past the cursor.
			let chunks = returnChunks;
			const before = window?.beforeSeq;
			if (before !== undefined) {
				chunks = chunks.filter((c) => c.seq < before);
			}
			if (window?.limit !== undefined && chunks.length > window.limit) {
				chunks = chunks.slice(-window.limit);
			}
			const latestSeq = chunks.length > 0 ? Math.max(...chunks.map((c) => c.seq)) : sinceSeq;
			return { chunks, latestSeq };
		},
	};
}

export interface FakeMetricsSync {
	readonly calls: string[];
	returnTurns: import("@dispatch/wire").TurnMetrics[];
	/** If set, the next call will reject with this error. */
	nextError: string | undefined;
	readonly impl: MetricsSync;
}

export function createFakeMetricsSync(): FakeMetricsSync {
	const calls: string[] = [];
	let returnTurns: import("@dispatch/wire").TurnMetrics[] = [];
	let nextError: string | undefined;
	return {
		calls,
		get returnTurns() {
			return returnTurns;
		},
		set returnTurns(v: import("@dispatch/wire").TurnMetrics[]) {
			returnTurns = v;
		},
		get nextError() {
			return nextError;
		},
		set nextError(v: string | undefined) {
			nextError = v;
		},
		impl: async (conversationId) => {
			calls.push(conversationId);
			if (nextError !== undefined) {
				const err = nextError;
				nextError = undefined;
				throw new Error(err);
			}
			return { turns: returnTurns };
		},
	};
}

export interface FakeCache {
	readonly store: Map<string, StoredChunk[]>;
	readonly impl: ConversationCache;
}

export function createFakeCache(): FakeCache {
	const store = new Map<string, StoredChunk[]>();
	return {
		store,
		impl: {
			async load(conversationId) {
				return store.get(conversationId) ?? [];
			},
			async commit(conversationId, incoming) {
				const existing = store.get(conversationId) ?? [];
				const seen = new Set(existing.map((c) => c.seq));
				const toAppend = incoming.filter((c) => !seen.has(c.seq));
				const merged = [...existing, ...toAppend].sort((a, b) => a.seq - b.seq);
				store.set(conversationId, merged);
				return merged;
			},
			async sinceSeq(conversationId) {
				const chunks = store.get(conversationId) ?? [];
				if (chunks.length === 0) return 0;
				return Math.max(...chunks.map((c) => c.seq));
			},
			async evictIfOverBudget() {
				return [];
			},
			async delete(conversationId) {
				store.delete(conversationId);
			},
		},
	};
}