| 1 | import assert from "node:assert/strict"; |
| 2 | import { ChatMountedOrder } from "../lib/chatMountedOrder"; |
| 3 | import { ChatTurnJump } from "../lib/chatTurnJump"; |
| 4 | import type { ChatScrollController } from "../lib/chatScrollController"; |
| 5 | import { alignOutlineEntries, findLoadedTurn, indexLoadedTurns, recordIdOf } from "../lib/chatTurnRail"; |
| 6 | import type { TranscriptOutlineEntry } from "../lib/transcriptProtocol"; |
| 7 | |
| 8 | // Node has no animation frame; the mount-settle path is driven by the mounted |
| 9 | // store in these tests, so a recorded no-op keeps it deterministic. |
| 10 | const frames: FrameRequestCallback[] = []; |
| 11 | (globalThis as unknown as { requestAnimationFrame: (cb: FrameRequestCallback) => number }).requestAnimationFrame = |
| 12 | (callback) => frames.push(callback); |
| 13 | (globalThis as unknown as { cancelAnimationFrame: (id: number) => void }).cancelAnimationFrame = () => {}; |
| 14 | |
| 15 | function flushFrames(): void { |
| 16 | const pending = frames.splice(0); |
| 17 | for (const callback of pending) callback(0); |
| 18 | } |
| 19 | |
| 20 | function deferred<T>() { |
| 21 | let resolve!: (value: T) => void; |
| 22 | let reject!: (error: unknown) => void; |
| 23 | const promise = new Promise<T>((done, fail) => { resolve = done; reject = fail; }); |
| 24 | return { promise, resolve, reject }; |
| 25 | } |
| 26 | |
| 27 | /** Records the writes a jump asks the shared gateway to make. */ |
| 28 | function fakeScroll() { |
| 29 | const jumps: string[] = []; |
| 30 | const readers = new Set<() => void>(); |
| 31 | let stopped = false; |
| 32 | const scroll = { |
| 33 | stopFollowing: () => { stopped = true; }, |
| 34 | jump: (key: string) => { jumps.push(key); }, |
| 35 | subscribeReaderIntent: (listener: () => void) => { readers.add(listener); return () => { readers.delete(listener); }; }, |
| 36 | subscribe: () => () => {}, |
| 37 | getSnapshot: () => ({ following: false, activeKey: "" }), |
| 38 | }; |
| 39 | return { |
| 40 | scroll: scroll as unknown as ChatScrollController, |
| 41 | jumps, |
| 42 | stopped: () => stopped, |
| 43 | // Reader intent is what a wheel, touch, key press, or return-to-bottom |
| 44 | // reports through the controller's dedicated channel. |
| 45 | readerIntent: () => { for (const listener of [...readers]) listener(); }, |
| 46 | readerCount: () => readers.size, |
| 47 | }; |
| 48 | } |
| 49 | |
| 50 | function jumpFor(mounts: ChatMountedOrder, options: { |
| 51 | mounted: Set<string>; |
| 52 | pages?: string[][]; |
| 53 | hasOlder?: () => boolean; |
| 54 | current?: () => boolean; |
| 55 | snapshotId?: () => string; |
| 56 | refresh?: (entry: TranscriptOutlineEntry) => Promise<TranscriptOutlineEntry | undefined>; |
| 57 | drainMs?: number; |
| 58 | }) { |
| 59 | const fake = fakeScroll(); |
| 60 | let pageIndex = 0; |
| 61 | const loads: number[] = []; |
| 62 | const jump = new ChatTurnJump({ |
| 63 | mounts, |
| 64 | scroll: fake.scroll, |
| 65 | loadOlder: async () => { |
| 66 | loads.push(pageIndex); |
| 67 | const revealed = options.pages?.[pageIndex] ?? []; |
| 68 | pageIndex += 1; |
| 69 | for (const key of revealed) options.mounted.add(key); |
| 70 | // One page also advances the progressive mount. |
| 71 | mounts.publish([...options.mounted]); |
| 72 | return revealed.length > 0 ? "loaded" as const : "empty" as const; |
| 73 | }, |
| 74 | hasOlder: options.hasOlder ?? (() => true), |
| 75 | resolveKey: (entry) => (options.mounted.has(entry.id) ? entry.id : undefined), |
| 76 | currentSnapshotId: options.snapshotId ?? (() => "cut"), |
| 77 | refreshSnapshot: options.refresh ?? (async (entry) => entry), |
| 78 | isCurrent: options.current ?? (() => true), |
| 79 | drainMs: options.drainMs, |
| 80 | }); |
| 81 | return { jump, fake, loads, mounted: options.mounted }; |
| 82 | } |
| 83 | |
| 84 | function target(id: string): TranscriptOutlineEntry { |
| 85 | return { id, messageId: id.replace("m:", ""), turn: 1, order: 0, prompt: "p", answer: "a" }; |
| 86 | } |
| 87 | |
| 88 | async function main() { |
| 89 | { |
| 90 | // An already-mounted target must not page at all. |
| 91 | const mounts = new ChatMountedOrder(); |
| 92 | const state = jumpFor(mounts, { mounted: new Set(["m:1"]) }); |
| 93 | await state.jump.jump(target("m:1")); |
| 94 | assert.deepEqual(state.fake.jumps, ["m:1"], "a loaded target scrolls immediately"); |
| 95 | assert.deepEqual(state.loads, [], "a loaded target does not page history"); |
| 96 | assert.equal(state.jump.getSnapshot().status, "idle", "the jump completes"); |
| 97 | assert.ok(state.fake.stopped(), "a jump leaves tail following before it writes"); |
| 98 | } |
| 99 | |
| 100 | { |
| 101 | // An unloaded target pages until its node is really mounted, then scrolls. |
| 102 | const mounts = new ChatMountedOrder(); |
| 103 | const state = jumpFor(mounts, { mounted: new Set(["m:9"]), pages: [["m:5"], ["m:3"], ["m:1"]] }); |
| 104 | await state.jump.jump(target("m:1")); |
| 105 | assert.deepEqual(state.loads, [0, 1, 2], "history is paged one batch at a time"); |
| 106 | assert.deepEqual(state.fake.jumps, ["m:1"], "the write happens only after the node mounts"); |
| 107 | assert.equal(state.jump.getSnapshot().status, "idle"); |
| 108 | } |
| 109 | |
| 110 | { |
| 111 | // Exhausting history without reaching the target is a specific failure. |
| 112 | const mounts = new ChatMountedOrder(); |
| 113 | const state = jumpFor(mounts, { mounted: new Set(["m:9"]), pages: [["m:9"], ["m:8"]] }); |
| 114 | await state.jump.jump(target("m:404")); |
| 115 | assert.deepEqual(state.fake.jumps, [], "an unreachable target never moves the viewport"); |
| 116 | assert.equal(state.jump.getSnapshot().status, "failed"); |
| 117 | assert.equal(state.jump.getSnapshot().reason, "turnUnavailable"); |
| 118 | } |
| 119 | |
| 120 | { |
| 121 | // No older history at all fails instead of looping. The wait for a |
| 122 | // progressively mounting last page is still honoured first. |
| 123 | const mounts = new ChatMountedOrder(); |
| 124 | const state = jumpFor(mounts, { mounted: new Set(), hasOlder: () => false, drainMs: 0 }); |
| 125 | await state.jump.jump(target("m:404")); |
| 126 | assert.deepEqual(state.loads, [], "a missing target with no history is not retried"); |
| 127 | assert.equal(state.jump.getSnapshot().status, "failed"); |
| 128 | assert.equal(state.jump.getSnapshot().reason, "turnUnavailable"); |
| 129 | } |
| 130 | |
| 131 | { |
| 132 | // Exhausted history is not the same as an unreachable turn: the last page |
| 133 | // mounts progressively, so the target can still appear afterwards. |
| 134 | const mounts = new ChatMountedOrder(); |
| 135 | const state = jumpFor(mounts, { mounted: new Set(["m:9"]), pages: [["m:5"]], hasOlder: () => false }); |
| 136 | const pending = state.jump.jump(target("m:1")); |
| 137 | let settled = false; |
| 138 | void pending.then(() => { settled = true; }); |
| 139 | // The reveal lands several frames after the page that carried it. |
| 140 | for (let i = 0; i < 50 && !settled; i++) { |
| 141 | await Promise.resolve(); |
| 142 | state.mounted.add("m:1"); |
| 143 | flushFrames(); |
| 144 | } |
| 145 | await pending; |
| 146 | assert.deepEqual(state.fake.jumps, ["m:1"], "a target that mounts after the last page is still reached"); |
| 147 | assert.equal(state.jump.getSnapshot().status, "idle"); |
| 148 | } |
| 149 | |
| 150 | { |
| 151 | // Budget exhaustion is reported as such, not as a missing turn. |
| 152 | const mounts = new ChatMountedOrder(); |
| 153 | const state = jumpFor(mounts, { mounted: new Set(), pages: Array.from({ length: 500 }, (_, index) => [`m:${index}`]) }); |
| 154 | await state.jump.jump(target("m:nowhere")); |
| 155 | assert.equal(state.jump.getSnapshot().status, "failed"); |
| 156 | assert.equal(state.jump.getSnapshot().reason, "pageBudgetExhausted", "a page budget is not a missing turn"); |
| 157 | } |
| 158 | |
| 159 | { |
| 160 | // A recycled snapshot ends the jump instead of silently replacing the body. |
| 161 | const mounts = new ChatMountedOrder(); |
| 162 | const fake = fakeScroll(); |
| 163 | const jump = new ChatTurnJump({ |
| 164 | mounts, scroll: fake.scroll, |
| 165 | loadOlder: async () => "stale" as const, |
| 166 | hasOlder: () => true, |
| 167 | resolveKey: () => undefined, |
| 168 | currentSnapshotId: () => "cut", |
| 169 | refreshSnapshot: async (entry) => entry, |
| 170 | isCurrent: () => true, |
| 171 | }); |
| 172 | await jump.jump(target("m:1")); |
| 173 | assert.equal(jump.getSnapshot().status, "failed"); |
| 174 | assert.equal(jump.getSnapshot().reason, "snapshotExpired", "a recycled cut is its own outcome"); |
| 175 | assert.deepEqual(fake.jumps, [], "a recycled cut never moves the viewport"); |
| 176 | } |
| 177 | |
| 178 | { |
| 179 | // Retrying a recycled cut must install a fresh snapshot first: re-running |
| 180 | // the same jump against the same dead cut just fails again. |
| 181 | const mounts = new ChatMountedOrder(); |
| 182 | let snapshot = "cut"; |
| 183 | let refreshes = 0; |
| 184 | let stale = true; |
| 185 | const fake = fakeScroll(); |
| 186 | let mounted: string | undefined; |
| 187 | const jump = new ChatTurnJump({ |
| 188 | mounts, scroll: fake.scroll, |
| 189 | loadOlder: async () => { |
| 190 | if (stale) return "stale" as const; |
| 191 | // The retry's page is productive and advances the mount, as a real one |
| 192 | // does; the recycled cut never got this far. |
| 193 | mounted = "m:1"; |
| 194 | mounts.publish(["m:1"]); |
| 195 | return "loaded" as const; |
| 196 | }, |
| 197 | hasOlder: () => true, |
| 198 | resolveKey: () => mounted, |
| 199 | currentSnapshotId: () => snapshot, |
| 200 | refreshSnapshot: async (entry) => { refreshes += 1; stale = false; snapshot = "cut:2"; return entry; }, |
| 201 | isCurrent: () => true, |
| 202 | drainMs: 0, |
| 203 | }); |
| 204 | await jump.jump(target("m:1")); |
| 205 | assert.equal(jump.getSnapshot().reason, "snapshotExpired", "the recycled cut is reported"); |
| 206 | await jump.retry(); |
| 207 | assert.equal(refreshes, 1, "the retry installs a fresh snapshot"); |
| 208 | assert.deepEqual(fake.jumps, ["m:1"], "the retry reaches the target on the new cut"); |
| 209 | assert.equal(jump.getSnapshot().status, "idle", "a successful retry ends the transaction"); |
| 210 | } |
| 211 | |
| 212 | { |
| 213 | // A newer click during the refresh abandons the retry, like any other |
| 214 | // pending transaction. |
| 215 | const mounts = new ChatMountedOrder(); |
| 216 | const refreshed = deferred<TranscriptOutlineEntry | undefined>(); |
| 217 | let refreshStarted = false; |
| 218 | const fake = fakeScroll(); |
| 219 | const jump = new ChatTurnJump({ |
| 220 | mounts, scroll: fake.scroll, |
| 221 | loadOlder: async () => "stale" as const, |
| 222 | hasOlder: () => true, |
| 223 | resolveKey: () => undefined, |
| 224 | currentSnapshotId: () => "cut", |
| 225 | refreshSnapshot: async () => { refreshStarted = true; return refreshed.promise; }, |
| 226 | isCurrent: () => true, |
| 227 | }); |
| 228 | const entry = target("m:1"); |
| 229 | await jump.jump(entry); |
| 230 | const pending = jump.retry(); |
| 231 | assert.equal(refreshStarted, true, "the retry is waiting inside snapshot refresh"); |
| 232 | jump.jumpTo("u9"); |
| 233 | refreshed.resolve(entry); |
| 234 | await pending; |
| 235 | assert.deepEqual(fake.jumps, ["u9"], "a click during the refresh wins"); |
| 236 | assert.equal(jump.getSnapshot().status, "idle"); |
| 237 | } |
| 238 | |
| 239 | { |
| 240 | // A transient refresh failure keeps the original target retryable. The |
| 241 | // next retry must call the refresher again and can then reach the turn. |
| 242 | const mounts = new ChatMountedOrder(); |
| 243 | const fake = fakeScroll(); |
| 244 | let refreshes = 0; |
| 245 | let stale = true; |
| 246 | let mounted: string | undefined; |
| 247 | const jump = new ChatTurnJump({ |
| 248 | mounts, scroll: fake.scroll, |
| 249 | loadOlder: async () => { |
| 250 | if (stale) return "stale" as const; |
| 251 | mounted = "m:1"; |
| 252 | mounts.publish(["m:1"]); |
| 253 | return "loaded" as const; |
| 254 | }, |
| 255 | hasOlder: () => true, |
| 256 | resolveKey: () => mounted, |
| 257 | currentSnapshotId: () => stale ? "cut" : "cut:2", |
| 258 | refreshSnapshot: async (entry) => { |
| 259 | refreshes += 1; |
| 260 | if (refreshes === 1) throw new Error("network down"); |
| 261 | stale = false; |
| 262 | return entry; |
| 263 | }, |
| 264 | isCurrent: () => true, |
| 265 | }); |
| 266 | await jump.jump(target("m:1")); |
| 267 | await jump.retry(); |
| 268 | assert.equal(jump.getSnapshot().status, "failed", "a failed refresh returns to a retryable state"); |
| 269 | assert.equal(jump.getSnapshot().reason, "snapshotExpired"); |
| 270 | assert.ok(jump.getSnapshot().retry, "the failed refresh retains its target"); |
| 271 | await jump.retry(); |
| 272 | assert.equal(refreshes, 2, "the second retry invokes snapshot refresh again"); |
| 273 | assert.deepEqual(fake.jumps, ["m:1"], "the second retry can reach the refreshed target"); |
| 274 | } |
| 275 | |
| 276 | { |
| 277 | // A replaced snapshot invalidates the locators this jump was resolved |
| 278 | // against, so it stops rather than continuing against the new body. |
| 279 | const mounts = new ChatMountedOrder(); |
| 280 | let snapshot = "cut"; |
| 281 | const state = jumpFor(mounts, { mounted: new Set(), pages: [["m:5"], ["m:1"]], snapshotId: () => snapshot }); |
| 282 | const pending = state.jump.jump(target("m:1")); |
| 283 | await Promise.resolve(); |
| 284 | snapshot = "cut:2"; |
| 285 | await pending; |
| 286 | assert.deepEqual(state.fake.jumps, [], "a replaced snapshot cancels the pending jump"); |
| 287 | assert.equal(state.loads.length, 1, "no page is requested against the replaced snapshot"); |
| 288 | // Stopping is not enough: the transaction must also release the state it |
| 289 | // still owns, or the mark pulses forever and the reader stays subscribed. |
| 290 | assert.equal(state.jump.getSnapshot().status, "idle", "a replaced snapshot ends the loading state"); |
| 291 | assert.equal(state.fake.readerCount(), 0, "a replaced snapshot releases the reader subscription"); |
| 292 | } |
| 293 | |
| 294 | { |
| 295 | // Clicking an already-loaded turn must supersede a pending jump rather than |
| 296 | // race it: both go through the same transaction. |
| 297 | const mounts = new ChatMountedOrder(); |
| 298 | const state = jumpFor(mounts, { mounted: new Set(["u4"]), pages: [["m:5"], ["m:1"]] }); |
| 299 | const pending = state.jump.jump(target("m:1")); |
| 300 | await Promise.resolve(); |
| 301 | state.jump.jumpTo("u4"); |
| 302 | await pending; |
| 303 | assert.deepEqual(state.fake.jumps, ["u4"], "the newest click wins and the pending jump is abandoned"); |
| 304 | assert.equal(state.jump.getSnapshot().status, "idle"); |
| 305 | } |
| 306 | |
| 307 | { |
| 308 | // A page that adds nothing stops the loop rather than spinning the network. |
| 309 | const mounts = new ChatMountedOrder(); |
| 310 | const state = jumpFor(mounts, { mounted: new Set(), pages: [[]] }); |
| 311 | await state.jump.jump(target("m:404")); |
| 312 | assert.equal(state.loads.length, 1, "an unproductive page ends the jump"); |
| 313 | assert.equal(state.jump.getSnapshot().status, "failed"); |
| 314 | } |
| 315 | |
| 316 | { |
| 317 | // Reader intent preempts a pending jump and no later page takes the viewport. |
| 318 | const mounts = new ChatMountedOrder(); |
| 319 | const state = jumpFor(mounts, { mounted: new Set(), pages: [["m:5"], ["m:1"]] }); |
| 320 | const pending = state.jump.jump(target("m:1")); |
| 321 | await Promise.resolve(); |
| 322 | state.fake.readerIntent(); |
| 323 | await pending; |
| 324 | assert.deepEqual(state.fake.jumps, [], "a preempted jump never scrolls"); |
| 325 | assert.equal(state.loads.length, 1, "no page is requested after the reader takes over"); |
| 326 | assert.equal(state.jump.getSnapshot().status, "idle", "preemption clears the busy state"); |
| 327 | assert.equal(state.fake.readerCount(), 0, "the reader subscription is released"); |
| 328 | } |
| 329 | |
| 330 | { |
| 331 | // A newer target supersedes the pending one; only the newest may scroll. |
| 332 | const mounts = new ChatMountedOrder(); |
| 333 | const state = jumpFor(mounts, { mounted: new Set(), pages: [["m:5"], ["m:2"], ["m:7"]] }); |
| 334 | const first = state.jump.jump(target("m:1")); |
| 335 | await Promise.resolve(); |
| 336 | const second = state.jump.jump(target("m:7")); |
| 337 | await Promise.all([first, second]); |
| 338 | assert.deepEqual(state.fake.jumps, ["m:7"], "only the newest target takes scroll control"); |
| 339 | assert.equal(state.jump.getSnapshot().status, "idle"); |
| 340 | } |
| 341 | |
| 342 | { |
| 343 | // A replaced session leaves no late callback able to move the viewport. |
| 344 | const mounts = new ChatMountedOrder(); |
| 345 | let current = true; |
| 346 | const state = jumpFor(mounts, { mounted: new Set(), pages: [["m:5"], ["m:1"]], current: () => current }); |
| 347 | const pending = state.jump.jump(target("m:1")); |
| 348 | await Promise.resolve(); |
| 349 | current = false; |
| 350 | await pending; |
| 351 | assert.deepEqual(state.fake.jumps, [], "a replaced session cannot take scroll control back"); |
| 352 | assert.equal(state.loads.length, 1, "no further page is requested for a replaced session"); |
| 353 | } |
| 354 | |
| 355 | { |
| 356 | // Cancelling explicitly ends the pending transaction. |
| 357 | const mounts = new ChatMountedOrder(); |
| 358 | const state = jumpFor(mounts, { mounted: new Set(), pages: [["m:5"], ["m:1"]] }); |
| 359 | const pending = state.jump.jump(target("m:1")); |
| 360 | await Promise.resolve(); |
| 361 | state.jump.cancel(); |
| 362 | await pending; |
| 363 | assert.deepEqual(state.fake.jumps, [], "a cancelled jump never scrolls"); |
| 364 | assert.equal(state.jump.getSnapshot().status, "idle"); |
| 365 | } |
| 366 | |
| 367 | { |
| 368 | // The bounded frame budget resolves the settle wait even when nothing |
| 369 | // publishes, so a jump cannot hang on an idle mount. |
| 370 | const mounts = new ChatMountedOrder(); |
| 371 | const state = jumpFor(mounts, { mounted: new Set() }); |
| 372 | let pages = 0; |
| 373 | const jump = new ChatTurnJump({ |
| 374 | mounts, |
| 375 | scroll: state.fake.scroll, |
| 376 | loadOlder: async () => { pages += 1; return "loaded" as const; }, |
| 377 | hasOlder: () => pages < 3, |
| 378 | resolveKey: () => undefined, |
| 379 | currentSnapshotId: () => "cut", |
| 380 | refreshSnapshot: async (entry) => entry, |
| 381 | isCurrent: () => true, |
| 382 | }); |
| 383 | let settled = false; |
| 384 | const pending = jump.jump(target("m:1")).then(() => { settled = true; }); |
| 385 | // Drive microtasks and frames together: the settle wait must expire on its |
| 386 | // own frame budget even though nothing ever publishes a mount. |
| 387 | for (let i = 0; i < 20_000 && !settled; i++) { |
| 388 | await Promise.resolve(); |
| 389 | flushFrames(); |
| 390 | } |
| 391 | await pending; |
| 392 | assert.ok(settled, "the mount wait is bounded and the jump terminates"); |
| 393 | assert.equal(pages, 3, "paging stops as soon as history is exhausted"); |
| 394 | assert.equal(jump.getSnapshot().status, "failed", "an unreachable target ends as a failure, not a hang"); |
| 395 | } |
| 396 | |
| 397 | { |
| 398 | // Identity resolution. The node's own identity decides, never the shape of |
| 399 | // its anchor key: a question written in this app session keeps its |
| 400 | // optimistic `u<seq>` id after the authoritative message arrives and only |
| 401 | // gains a messageId, so a key-shaped match would miss the turns the reader |
| 402 | // just wrote — the exact regression this covers. |
| 403 | const settled: TranscriptOutlineEntry = { id: "m:abc", messageId: "abc", turn: 1, order: 0, prompt: "", answer: "" }; |
| 404 | const nodes = new Map<string, { id: string; messageId?: string }>([ |
| 405 | ["u7", { id: "u7", messageId: "abc" }], |
| 406 | ["m:other", { id: "m:other" }], |
| 407 | ]); |
| 408 | const index = (order: string[]) => indexLoadedTurns(order, (key) => nodes.get(key)); |
| 409 | assert.equal(findLoadedTurn(index(["u7"]), settled), "u7", "an optimistically submitted question is found by its message ID"); |
| 410 | assert.equal(findLoadedTurn(index([]), settled), undefined, "an unmounted question has no key"); |
| 411 | assert.equal(findLoadedTurn(index(["m:other"]), settled), undefined, "an unrelated node is not claimed"); |
| 412 | |
| 413 | const uncommitted: TranscriptOutlineEntry = { id: "m:tmp", turn: 2, order: 2, prompt: "", answer: "" }; |
| 414 | nodes.set("m:tmp", { id: "m:tmp" }); |
| 415 | assert.equal(findLoadedTurn(index(["m:tmp"]), uncommitted), "m:tmp", "an uncommitted question resolves by record ID"); |
| 416 | |
| 417 | // History that carries no message id is keyed `record:<recordId>` by the |
| 418 | // transcript, while the outline carries the bare record id. Comparing the |
| 419 | // two item keys directly never matches, so the conversion is part of the |
| 420 | // contract rather than an accident of the fixture. |
| 421 | nodes.set("record:m:xyz", { id: "record:m:xyz" }); |
| 422 | const historical: TranscriptOutlineEntry = { id: "m:xyz", turn: 3, order: 4, prompt: "", answer: "" }; |
| 423 | assert.equal(findLoadedTurn(index(["record:m:xyz"]), historical), "record:m:xyz", |
| 424 | "a history record without a message ID resolves through its record key"); |
| 425 | assert.equal(recordIdOf({ id: "record:m:xyz" }), "m:xyz", "the item key converts back to the outline identity"); |
| 426 | assert.equal(recordIdOf({ id: "u7", messageId: "abc" }), "m:abc", "a settled question converts through its message ID"); |
| 427 | |
| 428 | // A message ID match wins even when a record-ID-only match appears earlier |
| 429 | // in the mounted order, so settlement cannot move a mark to a stale node. |
| 430 | nodes.set("m:abc:legacy", { id: "m:abc" }); |
| 431 | assert.equal(findLoadedTurn(index(["m:abc:legacy", "u7"]), settled), "u7", |
| 432 | "a message ID match outranks an earlier record ID match"); |
| 433 | |
| 434 | // Indexing once and looking up per entry is what keeps a long conversation |
| 435 | // linear instead of quadratic. |
| 436 | const wide = Array.from({ length: 4000 }, (_, i) => `u${i}`); |
| 437 | for (let i = 0; i < 4000; i++) nodes.set(`u${i}`, { id: `u${i}`, messageId: `${i}` }); |
| 438 | const wideIndex = indexLoadedTurns(wide, (key) => nodes.get(key)); |
| 439 | const started = Date.now(); |
| 440 | for (let i = 0; i < 4000; i++) { |
| 441 | findLoadedTurn(wideIndex, { id: `m:${i}`, messageId: `${i}`, turn: i, order: i, prompt: "", answer: "" }); |
| 442 | } |
| 443 | const elapsed = Date.now() - started; |
| 444 | assert.ok(elapsed < 500, `4000 lookups over 4000 nodes took ${elapsed}ms; the merge must not rescan per entry`); |
| 445 | |
| 446 | const aligned = alignOutlineEntries([ |
| 447 | { id: "m:b", turn: 2, order: 2, prompt: "", answer: "" }, |
| 448 | { id: "m:a", turn: 1, order: 0, prompt: "", answer: "" }, |
| 449 | { id: "m:b", turn: 2, order: 2, prompt: "duplicate", answer: "" }, |
| 450 | ]); |
| 451 | assert.deepEqual(aligned.map(item => item.id), ["m:a", "m:b"], "entries are ordered by snapshot position and de-duplicated"); |
| 452 | } |
| 453 | |
| 454 | console.log("chat turn jump: mount-confirmed paging, preemption, supersession, replacement, cancel and rail identity passed"); |
| 455 | } |
| 456 | |
| 457 | await main(); |
| 458 |