| 1 | import assert from "node:assert/strict"; |
| 2 | import { ChatMountedOrder } from "../lib/chatMountedOrder"; |
| 3 | import { ChatTurnJump, type TurnJumpDeps } from "../lib/chatTurnJump"; |
| 4 | import type { ChatScrollController } from "../lib/chatScrollController"; |
| 5 | import type { NavigateToTurn, TurnNavigationOutcome } from "../lib/historyTurnNavigation"; |
| 6 | import { findLoadedTurn, indexLoadedTurns } from "../lib/chatTurnRail"; |
| 7 | |
| 8 | function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>(done => { resolve = done; }); return { promise, resolve }; } |
| 9 | function setup(navigate: NavigateToTurn, clock?: TurnJumpDeps["clock"]) { |
| 10 | const mounts = new ChatMountedOrder(); const mounted = new Set<string>(); const writes: string[] = []; const readers = new Set<() => void>(); |
| 11 | const scroll = { stopFollowing() {}, jump(key: string) { writes.push(key); }, subscribeReaderIntent(fn: () => void) { readers.add(fn); return () => { readers.delete(fn); }; } } as unknown as ChatScrollController; |
| 12 | let refreshes = 0; |
| 13 | const jump = new ChatTurnJump({ mounts, scroll, resolveKey: target => mounted.has(target.messageId) ? target.messageId : undefined, |
| 14 | navigate, clock, isCurrent: () => true, refresh: async () => { refreshes++; } }); |
| 15 | return { jump, mounts, mounted, writes, refreshes: () => refreshes, read: () => { for (const fn of [...readers]) fn(); } }; |
| 16 | } |
| 17 | const target = (id: string) => ({ turn: id, resolve: async () => ({ messageId: id, generation: "g", snapshotSequence: 1 }) }); |
| 18 | { |
| 19 | let reads = 0; |
| 20 | const h = setup(async t => { reads++; h.mounted.add(t.messageId); return "loaded"; }); |
| 21 | await h.jump.jump(target("oldest")); |
| 22 | assert.equal(reads, 1, "far jump reads one target window"); |
| 23 | assert.deepEqual(h.writes, ["oldest"]); h.jump.dispose(); |
| 24 | } |
| 25 | { |
| 26 | const request = deferred<TurnNavigationOutcome>(); let valid!: () => boolean; |
| 27 | const h = setup(async (_target, current) => { valid = current; return request.promise; }); |
| 28 | const old = h.jump.jump(target("old")); await Promise.resolve(); |
| 29 | h.mounted.add("new"); h.jump.jumpTo("new"); |
| 30 | assert.equal(valid(), false); request.resolve("loaded"); await old; |
| 31 | assert.deepEqual(h.writes, ["new"]); h.jump.dispose(); |
| 32 | } |
| 33 | { |
| 34 | const request = deferred<TurnNavigationOutcome>(); let valid!: () => boolean; |
| 35 | const h = setup(async (_target, current) => { valid = current; return request.promise; }); |
| 36 | const run = h.jump.jump(target("old")); await Promise.resolve(); h.read(); |
| 37 | assert.equal(valid(), false); request.resolve("loaded"); await run; |
| 38 | assert.deepEqual(h.writes, []); h.jump.dispose(); |
| 39 | } |
| 40 | { |
| 41 | const identities: string[] = []; |
| 42 | const h = setup(async t => { identities.push(t.messageId); if (identities.length === 1) return "stale"; h.mounted.add(t.messageId); return "loaded"; }); |
| 43 | await h.jump.jump(target("same-message")); |
| 44 | assert.deepEqual(identities, ["same-message", "same-message"]); assert.equal(h.refreshes(), 1); h.jump.dispose(); |
| 45 | } |
| 46 | { |
| 47 | const h = setup(async () => "stale"); await h.jump.jump(target("gone")); |
| 48 | assert.equal(h.refreshes(), 1); assert.equal(h.jump.getSnapshot().reason, "snapshotExpired"); h.jump.dispose(); |
| 49 | } |
| 50 | { |
| 51 | const gate = deferred<TurnNavigationOutcome>(); const h = setup(() => gate.promise); |
| 52 | const run = h.jump.jump(target("later-mount")); await Promise.resolve(); gate.resolve("loaded"); await Promise.resolve(); await Promise.resolve(); |
| 53 | assert.deepEqual(h.writes, []); h.mounted.add("later-mount"); h.mounts.publish(["later-mount"]); await run; |
| 54 | assert.deepEqual(h.writes, ["later-mount"]); h.jump.dispose(); |
| 55 | } |
| 56 | const loaded = indexLoadedTurns(["optimistic"], () => ({ id: "optimistic", messageId: "stable" })); |
| 57 | { |
| 58 | let timeout!: () => void; |
| 59 | const h = setup(async () => "loaded", { |
| 60 | setTimeout: ((fn: () => void) => { timeout = fn; return 1; }) as unknown as typeof setTimeout, |
| 61 | clearTimeout: (() => {}) as typeof clearTimeout, |
| 62 | }); |
| 63 | const run = h.jump.jump(target("never-mounted")); |
| 64 | await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); |
| 65 | timeout(); await run; |
| 66 | assert.equal(h.jump.getSnapshot().reason, "mountTimeout"); |
| 67 | assert.deepEqual(h.writes, []); h.jump.dispose(); |
| 68 | } |
| 69 | { |
| 70 | const selected: string[] = []; |
| 71 | const h = setup(async t => { selected.push(t.messageId); return "unavailable"; }); |
| 72 | let id = "original"; |
| 73 | await h.jump.jump({ turn: "pending:1", resolve: async () => ({ messageId: id }) }); |
| 74 | id = "replacement"; |
| 75 | await h.jump.retry(); |
| 76 | assert.deepEqual(selected, ["original", "original"], "retry never resolves an ordinal to a different message"); |
| 77 | h.jump.dispose(); |
| 78 | } |
| 79 | assert.equal(findLoadedTurn(loaded, { id: "m:stable", messageId: "stable", turn: 1, order: 0, prompt: "" }), "optimistic"); |
| 80 | console.log("turn jump: direct read, identity, latest intent, native takeover and mount confirmation passed"); |
| 81 |