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 | 312ed5a9fe0d7f2660a66d61baf429b8e021ffb1 (patch) | |
| tree | dad46b8b88a6bc5629d50f18912ff944eaef4e95 /src/features/conversation-cache/logic.test.ts | |
| parent | a062045579de1bd0903f0f2c7f3e742162bcb7cc (diff) | |
| download | dispatch-web-312ed5a9fe0d7f2660a66d61baf429b8e021ffb1.tar.gz dispatch-web-312ed5a9fe0d7f2660a66d61baf429b8e021ffb1.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/logic.test.ts')
| -rw-r--r-- | src/features/conversation-cache/logic.test.ts | 140 |
1 files changed, 140 insertions, 0 deletions
diff --git a/src/features/conversation-cache/logic.test.ts b/src/features/conversation-cache/logic.test.ts new file mode 100644 index 0000000..858460a --- /dev/null +++ b/src/features/conversation-cache/logic.test.ts @@ -0,0 +1,140 @@ +import type { StoredChunk } from "@dispatch/wire"; +import { describe, expect, it } from "vitest"; +import { nextSinceSeq, reconcileCache, selectEvictions } from "./logic"; +import type { ConversationCacheIndexEntry } from "./types"; + +const chunk = (seq: number, role: "user" | "assistant" = "user"): StoredChunk => ({ + seq, + role, + chunk: { type: "text", text: `chunk-${seq}` }, +}); + +describe("reconcileCache", () => { + it("merges and dedupes by seq", () => { + const cached = [chunk(1), chunk(2)]; + const incoming = [chunk(2), chunk(3)]; + const result = reconcileCache(cached, incoming); + expect(result.merged).toEqual([chunk(1), chunk(2), chunk(3)]); + }); + + it("toAppend excludes already-cached seqs", () => { + const cached = [chunk(1), chunk(2)]; + const incoming = [chunk(2), chunk(3)]; + const result = reconcileCache(cached, incoming); + expect(result.toAppend).toEqual([chunk(3)]); + }); + + it("tolerates out-of-order incoming", () => { + const cached = [chunk(1)]; + const incoming = [chunk(5), chunk(3), chunk(2)]; + const result = reconcileCache(cached, incoming); + expect(result.merged).toEqual([chunk(1), chunk(2), chunk(3), chunk(5)]); + expect(result.toAppend).toEqual([chunk(5), chunk(3), chunk(2)]); + }); + + it("returns empty merged and toAppend when both inputs are empty", () => { + const result = reconcileCache([], []); + expect(result.merged).toEqual([]); + expect(result.toAppend).toEqual([]); + }); + + it("handles empty cached with incoming", () => { + const incoming = [chunk(3), chunk(1)]; + const result = reconcileCache([], incoming); + expect(result.merged).toEqual([chunk(1), chunk(3)]); + expect(result.toAppend).toEqual([chunk(3), chunk(1)]); + }); + + it("handles cached with empty incoming", () => { + const cached = [chunk(1), chunk(2)]; + const result = reconcileCache(cached, []); + expect(result.merged).toEqual([chunk(1), chunk(2)]); + expect(result.toAppend).toEqual([]); + }); + + it("is idempotent — re-reconciling same incoming produces same result", () => { + const cached = [chunk(1)]; + const incoming = [chunk(2), chunk(3)]; + const first = reconcileCache(cached, incoming); + const second = reconcileCache(first.merged, incoming); + expect(second.merged).toEqual(first.merged); + expect(second.toAppend).toEqual([]); + }); +}); + +describe("nextSinceSeq", () => { + it("returns max seq", () => { + const cached = [chunk(1), chunk(5), chunk(3)]; + expect(nextSinceSeq(cached)).toBe(5); + }); + + it("returns 0 when empty", () => { + expect(nextSinceSeq([])).toBe(0); + }); + + it("returns single seq for single chunk", () => { + expect(nextSinceSeq([chunk(42)])).toBe(42); + }); +}); + +describe("selectEvictions", () => { + it("never evicts the active conversation", () => { + const index: ConversationCacheIndexEntry[] = [ + { conversationId: "active", chunkCount: 100, maxSeq: 100, lastAccess: 1000 }, + { conversationId: "other", chunkCount: 50, maxSeq: 50, lastAccess: 1 }, + ]; + const result = selectEvictions(index, { maxChunks: 50, activeConversationId: "active" }); + expect(result).not.toContain("active"); + expect(result).toContain("other"); + }); + + it("evicts LRU until under budget", () => { + const index: ConversationCacheIndexEntry[] = [ + { conversationId: "a", chunkCount: 30, maxSeq: 30, lastAccess: 100 }, + { conversationId: "b", chunkCount: 30, maxSeq: 30, lastAccess: 50 }, + { conversationId: "c", chunkCount: 30, maxSeq: 30, lastAccess: 200 }, + { conversationId: "d", chunkCount: 30, maxSeq: 30, lastAccess: 10 }, + ]; + // Total = 120, max = 60, need to evict 60+ chunks + // LRU order: d(10), b(50), a(100), c(200) + const result = selectEvictions(index, { maxChunks: 60, activeConversationId: null }); + expect(result).toEqual(["d", "b"]); + }); + + it("is a no-op under budget", () => { + const index: ConversationCacheIndexEntry[] = [ + { conversationId: "a", chunkCount: 10, maxSeq: 10, lastAccess: 100 }, + { conversationId: "b", chunkCount: 10, maxSeq: 10, lastAccess: 50 }, + ]; + const result = selectEvictions(index, { maxChunks: 100, activeConversationId: null }); + expect(result).toEqual([]); + }); + + it("returns empty for empty index", () => { + const result = selectEvictions([], { maxChunks: 100, activeConversationId: null }); + expect(result).toEqual([]); + }); + + it("tie-breaks by smaller maxSeq when lastAccess is equal", () => { + const index: ConversationCacheIndexEntry[] = [ + { conversationId: "a", chunkCount: 30, maxSeq: 100, lastAccess: 50 }, + { conversationId: "b", chunkCount: 30, maxSeq: 50, lastAccess: 50 }, + { conversationId: "c", chunkCount: 30, maxSeq: 200, lastAccess: 50 }, + ]; + // Total = 90, max = 60, need to evict 30+ chunks + // All have same lastAccess, tie-break by maxSeq: b(50), a(100), c(200) + const result = selectEvictions(index, { maxChunks: 60, activeConversationId: null }); + expect(result).toEqual(["b"]); + }); + + it("handles missing lastAccess (treated as 0)", () => { + const index: ConversationCacheIndexEntry[] = [ + { conversationId: "a", chunkCount: 30, maxSeq: 30, lastAccess: 100 }, + { conversationId: "b", chunkCount: 30, maxSeq: 30 }, + ]; + // Total = 60, max = 30, need to evict 30+ chunks + // b has no lastAccess (0), a has 100 + const result = selectEvictions(index, { maxChunks: 30, activeConversationId: null }); + expect(result).toEqual(["b"]); + }); +}); |
