From 2faccf3482922385c2de47f210e48a8d2968762f Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sun, 7 Jun 2026 00:21:04 +0900 Subject: Slice 2 wave 2: IndexedDB cache adapter + chat feature - adapters/idb: createIdbChunkStore implements the ConversationChunkStore port over IndexedDB (compound [conversationId,seq] key, idempotent append, meta store for lastAccess); 8 tests with fake-indexeddb - features/chat: createChatStore (runes-thin over the core/chunks reducer, all effects injected via ChatTransport/HistorySync/ConversationCache ports) + ChatView/Composer svelte-thin UI; folds chat.delta, syncs on turn-sealed, hydrates from cache then catches up; 25 tests Verified green: svelte-check 0/0, vitest 202, biome clean, build ok. --- src/features/chat/test-helpers.ts | 80 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 src/features/chat/test-helpers.ts (limited to 'src/features/chat/test-helpers.ts') diff --git a/src/features/chat/test-helpers.ts b/src/features/chat/test-helpers.ts new file mode 100644 index 0000000..e58818a --- /dev/null +++ b/src/features/chat/test-helpers.ts @@ -0,0 +1,80 @@ +import type { StoredChunk } from "@dispatch/wire"; +import type { ConversationCache } from "../conversation-cache"; +import type { ChatTransport, HistorySync } from "./ports"; + +export interface FakeTransport { + readonly sent: import("@dispatch/transport-contract").ChatSendMessage[]; + readonly impl: ChatTransport; +} + +export function createFakeTransport(): FakeTransport { + const sent: import("@dispatch/transport-contract").ChatSendMessage[] = []; + return { + sent, + impl: { + send(msg) { + sent.push(msg); + }, + }, + }; +} + +export interface FakeHistorySync { + readonly calls: Array<{ conversationId: string; sinceSeq: number }>; + /** 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 }> = []; + let returnChunks: readonly StoredChunk[] = []; + return { + calls, + get returnChunks() { + return returnChunks; + }, + set returnChunks(v: readonly StoredChunk[]) { + returnChunks = v; + }, + impl: async (conversationId, sinceSeq) => { + calls.push({ conversationId, sinceSeq }); + const chunks = returnChunks; + const latestSeq = chunks.length > 0 ? Math.max(...chunks.map((c) => c.seq)) : sinceSeq; + return { chunks, latestSeq }; + }, + }; +} + +export interface FakeCache { + readonly store: Map; + readonly impl: ConversationCache; +} + +export function createFakeCache(): FakeCache { + const store = new Map(); + 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 []; + }, + }, + }; +} -- cgit v1.2.3