| 1 | import { initialState, reducer } from "../lib/useController"; |
| 2 | import { applyReadStatusFrame, readStatusLabel } from "../lib/readStatus"; |
| 3 | import { readPauseItem, upsertReadPause, type WireReadPause } from "../lib/readPause"; |
| 4 | |
| 5 | function equal(actual: unknown, expected: unknown, message: string) { |
| 6 | if (actual !== expected) throw new Error(`${message}: got ${String(actual)}, want ${String(expected)}`); |
| 7 | } |
| 8 | |
| 9 | const frame = (seq: number, state: string, extra: Record<string, unknown> = {}) => ({ |
| 10 | kind: "read_status" as const, |
| 11 | turnId: "turn-1", |
| 12 | readStatus: { |
| 13 | readId: "ir-1", seq, path: "/w/a.go", state, active: true, |
| 14 | covered: [[1, 10]] as [number, number][], |
| 15 | ...extra, |
| 16 | }, |
| 17 | }); |
| 18 | |
| 19 | let s = reducer(initialState, { type: "event", e: frame(1, "needs_more") }); |
| 20 | equal(Object.keys(s.readStatuses ?? {}).length, 1, "one logical read keeps one status entry"); |
| 21 | s = reducer(s, { type: "event", e: frame(2, "needs_more") }); |
| 22 | equal(Object.keys(s.readStatuses ?? {}).length, 1, "a later page upserts the same entry"); |
| 23 | equal(s.readStatuses?.["ir-1"]?.seq, 2, "the newest sequence wins"); |
| 24 | |
| 25 | const stale = reducer(s, { type: "event", e: frame(1, "satisfied", { active: false }) }); |
| 26 | equal(stale.readStatuses?.["ir-1"]?.state, "needs_more", "a re-ordered frame never moves a read backwards"); |
| 27 | |
| 28 | s = reducer(s, { |
| 29 | type: "event", |
| 30 | e: { kind: "read_status", turnId: "turn-1", readStatus: { readId: "ir-2", seq: 1, path: "/w/b.go", state: "needs_more", active: true } }, |
| 31 | }); |
| 32 | equal(Object.keys(s.readStatuses ?? {}).length, 2, "independent reads keep independent entries"); |
| 33 | |
| 34 | const next = reducer(s, { type: "event", e: { kind: "turn_started", turnId: "turn-2", status: "in_progress" } }); |
| 35 | equal(next.readStatuses, undefined, "a new turn starts from no live read status"); |
| 36 | |
| 37 | // A hundred pages of one read still leave exactly one status entry. |
| 38 | let many = initialState; |
| 39 | for (let page = 1; page <= 100; page++) { |
| 40 | many = reducer(many, { type: "event", e: frame(page, "needs_more", { covered: [[1, page * 10]] as [number, number][] }) }); |
| 41 | } |
| 42 | equal(Object.keys(many.readStatuses ?? {}).length, 1, "a hundred pages still render one status"); |
| 43 | equal(many.readStatuses?.["ir-1"]?.seq, 100, "the latest page wins"); |
| 44 | |
| 45 | console.log("read status upsert tests passed"); |
| 46 | |
| 47 | const current = { readId: "r", generation: 2, seq: 1, path: "a.go", state: "needs_more", active: true }; |
| 48 | const scoped = { readStatuses: { r: current } }; |
| 49 | equal(applyReadStatusFrame(scoped, { ...current, generation: 1, seq: 99 }).readStatuses.r.generation, 2, "old generation cannot overwrite new state"); |
| 50 | equal(applyReadStatusFrame(scoped, { ...current, generation: 3, seq: 0 }).readStatuses.r.generation, 3, "new generation may restart its sequence"); |
| 51 | const label = readStatusLabel({ r: { ...current, hasMore: true, covered: [[0, 10], [100, 110]] } }, (_key, vars) => String(vars?.range)); |
| 52 | equal(label, "1–10, 101–110", "coverage gaps are not displayed as read"); |
| 53 | const paused = readStatusLabel({ r: { ...current, state: "blocked", reason: "no_progress" } }, (key) => key); |
| 54 | equal(paused.includes("composer.readStatusStalled"), true, "pause explains the cause"); |
| 55 | equal(paused.includes("composer.readStatusRecovery"), true, "pause gives an action"); |
| 56 | const started = reducer(initialState, { type: "event", e: { kind: "turn_started", turnId: "current", status: "in_progress" } }); |
| 57 | const live = reducer(started, { type: "event", e: { ...frame(1, "needs_more"), turnId: "current" } }); |
| 58 | equal(reducer(live, { type: "event", e: { ...frame(99, "blocked"), turnId: "old" } }).readStatuses?.["ir-1"].seq, 1, "another turn cannot update read status"); |
| 59 | const done = reducer(live, { type: "event", e: { kind: "turn_done", turnId: "current" } }); |
| 60 | equal(done.readStatuses, undefined, "completion clears live status"); |
| 61 | equal(reducer(done, { type: "event", e: { ...frame(99, "blocked"), turnId: "current" } }).readStatuses, undefined, "late result cannot revive a completed turn"); |
| 62 | |
| 63 | const receipt: WireReadPause = { id: "receipt", reads: [{ readId: "r", path: "file", reason: "page_budget", covered: [[0, 10]], missing: [[10, 20]] }] }; |
| 64 | const readDone = reducer(live, { type: "event", e: { kind: "turn_done", turnId: "current", outcome: "incomplete_read", readPause: receipt } }); |
| 65 | equal(readDone.turnActive, false, "a read pause releases the composer"); |
| 66 | equal(readDone.readStatuses, undefined, "live progress is cleared after retaining the receipt"); |
| 67 | equal(readDone.items.filter(it => it.kind === "notice" && it.code === "incomplete_read").length, 1, "one durable read pause"); |
| 68 | equal(upsertReadPause(readDone.items, receipt, "unused").length, readDone.items.length, "replayed pause is idempotent"); |
| 69 | const historyNotice = readPauseItem(receipt, "unused"); |
| 70 | equal(historyNotice.kind === "notice" && historyNotice.text.includes("budget"), true, "pause cause is visible without expanding details"); |
| 71 | const liveNotice = readDone.items.find(it => it.id === historyNotice.id)!; |
| 72 | const { turnId: noticeTurnId, ...presentation } = liveNotice; |
| 73 | equal(noticeTurnId, "current", "live receipt retains the explicit event turn for pending-bubble positioning"); |
| 74 | equal(JSON.stringify(presentation), JSON.stringify(historyNotice), "live and history use identical presentation"); |
| 75 | const nextTurn = reducer(readDone, { type: "event", e: { kind: "turn_started", turnId: "next", status: "in_progress" } }); |
| 76 | equal(reducer(nextTurn, { type: "event", e: { kind: "turn_done", turnId: "current", outcome: "incomplete_read", readPause: receipt } }), nextTurn, "stale terminal result cannot stop a new turn"); |
| 77 |