diff options
| author | Adam Malczewski <[email protected]> | 2026-06-07 00:02:32 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-07 00:02:32 +0900 |
| commit | 5d9ae1849337b64af1b0d47c23b8c4950a55f792 (patch) | |
| tree | dd5fccaff7535bf1216457a986b8f95bd14fd61e /src/features/conversation-cache/cache.test.ts | |
| parent | fac44794432928d0341728642fd70eef87837da4 (diff) | |
| download | dispatch-web-5d9ae1849337b64af1b0d47c23b8c4950a55f792.tar.gz dispatch-web-5d9ae1849337b64af1b0d47c23b8c4950a55f792.zip | |
Slice 2 wave 1: transcript reducer, wire conformance, ws chat, cache core
- core/chunks: the one pure transcript reducer (foldEvent live deltas +
applyHistory seq-keyed reconcile + selectChunks/selectMessages); 27 tests
- core/wire: FE-side contract-conformance exhaustiveness guards + drift smoke
tests over wire/transport-contract unions (§2.9 drift signal); 10 tests
- adapters/ws: additively multiplex chat.send/chat.delta/chat.error on the
existing surface socket (onChat + widened send); surface API unchanged
- features/conversation-cache: pure reconcileCache/nextSinceSeq/selectEvictions
+ ConversationChunkStore port + injected createConversationCache; 26 tests
Verified green: svelte-check 0/0, vitest 169, biome clean, build ok.
Diffstat (limited to 'src/features/conversation-cache/cache.test.ts')
| -rw-r--r-- | src/features/conversation-cache/cache.test.ts | 173 |
1 files changed, 173 insertions, 0 deletions
diff --git a/src/features/conversation-cache/cache.test.ts b/src/features/conversation-cache/cache.test.ts new file mode 100644 index 0000000..c68ed0d --- /dev/null +++ b/src/features/conversation-cache/cache.test.ts @@ -0,0 +1,173 @@ +import type { StoredChunk } from "@dispatch/wire"; +import { describe, expect, it } from "vitest"; +import { createConversationCache } from "./cache"; +import type { ConversationCacheIndexEntry, ConversationChunkStore } from "./types"; + +const chunk = (seq: number, role: "user" | "assistant" = "user"): StoredChunk => ({ + seq, + role, + chunk: { type: "text", text: `chunk-${seq}` }, +}); + +/** + * In-memory fake ConversationChunkStore — the ONLY allowed fake. + * An outermost edge: simulates the storage port without any real I/O. + */ +function createFakeStore(): ConversationChunkStore { + const store = new Map<string, StoredChunk[]>(); + + return { + async load(conversationId) { + return store.get(conversationId) ?? []; + }, + + async append(conversationId, chunks) { + const existing = store.get(conversationId) ?? []; + const existingSeqs = new Set(existing.map((c) => c.seq)); + const toAdd = chunks.filter((c) => !existingSeqs.has(c.seq)); + store.set( + conversationId, + [...existing, ...toAdd].sort((a, b) => a.seq - b.seq), + ); + }, + + async delete(conversationId) { + store.delete(conversationId); + }, + + async index() { + const entries: ConversationCacheIndexEntry[] = []; + for (const [id, chunks] of store) { + if (chunks.length === 0) continue; + let maxSeq = 0; + for (const c of chunks) { + if (c.seq > maxSeq) maxSeq = c.seq; + } + entries.push({ + conversationId: id, + chunkCount: chunks.length, + maxSeq, + }); + } + return entries; + }, + }; +} + +describe("cache.load", () => { + it("returns stored chunks", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + await store.append("conv-1", [chunk(1), chunk(2)]); + const result = await cache.load("conv-1"); + expect(result).toEqual([chunk(1), chunk(2)]); + }); + + it("returns empty array for absent conversation", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + const result = await cache.load("nonexistent"); + expect(result).toEqual([]); + }); +}); + +describe("cache.commit", () => { + it("appends only new chunks", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + await store.append("conv-1", [chunk(1), chunk(2)]); + + const merged = await cache.commit("conv-1", [chunk(2), chunk(3)]); + expect(merged).toEqual([chunk(1), chunk(2), chunk(3)]); + + // Verify store has all chunks + const stored = await store.load("conv-1"); + expect(stored).toEqual([chunk(1), chunk(2), chunk(3)]); + }); + + it("returns full merged result", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + + const merged = await cache.commit("conv-1", [chunk(3), chunk(1)]); + expect(merged).toEqual([chunk(1), chunk(3)]); + }); + + it("is idempotent — re-committing same chunks is a no-op", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + + await cache.commit("conv-1", [chunk(1), chunk(2)]); + const merged = await cache.commit("conv-1", [chunk(1), chunk(2)]); + expect(merged).toEqual([chunk(1), chunk(2)]); + + const stored = await store.load("conv-1"); + expect(stored).toEqual([chunk(1), chunk(2)]); + }); +}); + +describe("cache.sinceSeq", () => { + it("returns max seq from cache", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + await store.append("conv-1", [chunk(1), chunk(5), chunk(3)]); + expect(await cache.sinceSeq("conv-1")).toBe(5); + }); + + it("returns 0 for empty conversation", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + expect(await cache.sinceSeq("conv-1")).toBe(0); + }); +}); + +describe("cache.evictIfOverBudget", () => { + it("deletes selected conversations", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store, { maxChunks: 5 }); + + await store.append("a", [chunk(1), chunk(2)]); + await store.append("b", [chunk(1), chunk(2)]); + await store.append("c", [chunk(1)]); + + // Total = 5, max = 5, under budget + const evicted = await cache.evictIfOverBudget(null); + expect(evicted).toEqual([]); + + // Add more to go over budget + await store.append("d", [chunk(1), chunk(2), chunk(3)]); + // Total = 8, max = 5, need to evict 3+ chunks + + const evicted2 = await cache.evictIfOverBudget(null); + expect(evicted2.length).toBeGreaterThan(0); + + // Verify evicted conversations are deleted + for (const id of evicted2) { + expect(await store.load(id)).toEqual([]); + } + }); + + it("never evicts the active conversation", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store, { maxChunks: 3 }); + + await store.append("active", [chunk(1), chunk(2), chunk(3)]); + await store.append("other", [chunk(1), chunk(2)]); + + // Total = 5, max = 3, need to evict 2+ chunks + const evicted = await cache.evictIfOverBudget("active"); + expect(evicted).not.toContain("active"); + expect(evicted).toContain("other"); + }); + + it("returns empty when under budget", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store, { maxChunks: 100 }); + + await store.append("a", [chunk(1)]); + await store.append("b", [chunk(1)]); + + const evicted = await cache.evictIfOverBudget(null); + expect(evicted).toEqual([]); + }); +}); |
