|
| 1 | +import assert from "node:assert/strict"; |
| 2 | +import test from "node:test"; |
| 3 | + |
| 4 | +import { CONVERGENCE_FRAME_CAP, convergenceStep } from "./scrollConvergence.ts"; |
| 5 | + |
| 6 | +function input(overrides) { |
| 7 | + return { |
| 8 | + targetMessageId: "target", |
| 9 | + indexByMessageId: new Map([["target", 100]]), |
| 10 | + lastIssuedIndex: null, |
| 11 | + librarySettled: false, |
| 12 | + stalledOffTarget: false, |
| 13 | + framesUsed: 0, |
| 14 | + ...overrides, |
| 15 | + }; |
| 16 | +} |
| 17 | + |
| 18 | +// --- re-aim / staleness guard ------------------------------------------------ |
| 19 | + |
| 20 | +test("convergenceStep: first frame aims at the resolved index, not yet done", () => { |
| 21 | + const step = convergenceStep(input({ lastIssuedIndex: null })); |
| 22 | + assert.equal(step.nextIndex, 100); |
| 23 | + assert.equal(step.done, false); |
| 24 | + assert.equal(step.converged, false); |
| 25 | +}); |
| 26 | + |
| 27 | +test("convergenceStep: re-resolves a shifted index from the map each frame", () => { |
| 28 | + // A prepend shifted the target from 100 to 105. The library is still chasing |
| 29 | + // the old index (lastIssuedIndex 100); the reducer must aim at the NEW index |
| 30 | + // so the adapter re-issues scrollToIndex(105). This is the staleness guard. |
| 31 | + const step = convergenceStep( |
| 32 | + input({ |
| 33 | + indexByMessageId: new Map([["target", 105]]), |
| 34 | + lastIssuedIndex: 100, |
| 35 | + }), |
| 36 | + ); |
| 37 | + assert.equal(step.nextIndex, 105); |
| 38 | + assert.equal(step.done, false); |
| 39 | + assert.equal(step.converged, false); |
| 40 | +}); |
| 41 | + |
| 42 | +test("convergenceStep: target removed mid-settle stops with converged=false", () => { |
| 43 | + // Target deleted from the map while the loop was chasing it. Terminate so the |
| 44 | + // adapter clears the highlight instead of chasing a vanished row. |
| 45 | + const step = convergenceStep( |
| 46 | + input({ |
| 47 | + indexByMessageId: new Map(), // target gone |
| 48 | + lastIssuedIndex: 100, |
| 49 | + framesUsed: 3, |
| 50 | + }), |
| 51 | + ); |
| 52 | + assert.equal(step.nextIndex, null); |
| 53 | + assert.equal(step.done, true); |
| 54 | + assert.equal(step.converged, false); |
| 55 | +}); |
| 56 | + |
| 57 | +// --- settle ------------------------------------------------------------------ |
| 58 | + |
| 59 | +test("convergenceStep: library settled while aiming at current index converges", () => { |
| 60 | + const step = convergenceStep( |
| 61 | + input({ lastIssuedIndex: 100, librarySettled: true }), |
| 62 | + ); |
| 63 | + assert.equal(step.nextIndex, 100); |
| 64 | + assert.equal(step.done, true); |
| 65 | + assert.equal(step.converged, true); |
| 66 | +}); |
| 67 | + |
| 68 | +test("convergenceStep: a settle reported WHILE re-aiming is ignored", () => { |
| 69 | + // The index just moved (105) but the library reports settled — that settle is |
| 70 | + // on the OLD index (100), so it must NOT count as convergence. The reducer |
| 71 | + // keeps going and aims at the new index. |
| 72 | + const step = convergenceStep( |
| 73 | + input({ |
| 74 | + indexByMessageId: new Map([["target", 105]]), |
| 75 | + lastIssuedIndex: 100, |
| 76 | + librarySettled: true, |
| 77 | + }), |
| 78 | + ); |
| 79 | + assert.equal(step.nextIndex, 105); |
| 80 | + assert.equal(step.done, false); |
| 81 | + assert.equal(step.converged, false); |
| 82 | +}); |
| 83 | + |
| 84 | +test("convergenceStep: aiming at current but not yet settled keeps waiting", () => { |
| 85 | + // Library is chasing the right index but its offset hasn't stabilized. The |
| 86 | + // reducer returns the same index (so the adapter re-issues NOTHING — issuing |
| 87 | + // would reset the library's stableFrames and prevent settling) and waits. |
| 88 | + const step = convergenceStep( |
| 89 | + input({ lastIssuedIndex: 100, librarySettled: false }), |
| 90 | + ); |
| 91 | + assert.equal(step.nextIndex, 100); |
| 92 | + assert.equal(step.reissue, false); |
| 93 | + assert.equal(step.done, false); |
| 94 | + assert.equal(step.converged, false); |
| 95 | +}); |
| 96 | + |
| 97 | +// --- off-target stall (liveness) --------------------------------------------- |
| 98 | + |
| 99 | +test("convergenceStep: stalled off-target while aiming at current re-issues same index", () => { |
| 100 | + // The library's offset stopped moving but never reached the current index's |
| 101 | + // target (its internal reconcile deadlocked after rows re-measured). The |
| 102 | + // reducer signals a same-index re-issue to kick it — the loop continues. |
| 103 | + const step = convergenceStep( |
| 104 | + input({ lastIssuedIndex: 100, stalledOffTarget: true }), |
| 105 | + ); |
| 106 | + assert.equal(step.nextIndex, 100); |
| 107 | + assert.equal(step.reissue, true); |
| 108 | + assert.equal(step.done, false); |
| 109 | + assert.equal(step.converged, false); |
| 110 | +}); |
| 111 | + |
| 112 | +test("convergenceStep: a stall reported WHILE re-aiming does not re-issue", () => { |
| 113 | + // The index just moved (105) but the library reports a stall on the OLD index |
| 114 | + // (100). The reducer re-aims at the new index normally; the stale stall must |
| 115 | + // NOT trigger a same-index kick (there is no current-index stall to kick). |
| 116 | + const step = convergenceStep( |
| 117 | + input({ |
| 118 | + indexByMessageId: new Map([["target", 105]]), |
| 119 | + lastIssuedIndex: 100, |
| 120 | + stalledOffTarget: true, |
| 121 | + }), |
| 122 | + ); |
| 123 | + assert.equal(step.nextIndex, 105); |
| 124 | + assert.equal(step.reissue, false); |
| 125 | + assert.equal(step.done, false); |
| 126 | + assert.equal(step.converged, false); |
| 127 | +}); |
| 128 | + |
| 129 | +test("convergenceStep: a settle takes priority over a concurrent stall flag", () => { |
| 130 | + // Defensive: the adapter computes settle and stall as mutually exclusive, but |
| 131 | + // if both arrive, a genuine settle must win (converge) rather than spin on a |
| 132 | + // pointless re-issue. |
| 133 | + const step = convergenceStep( |
| 134 | + input({ |
| 135 | + lastIssuedIndex: 100, |
| 136 | + librarySettled: true, |
| 137 | + stalledOffTarget: true, |
| 138 | + }), |
| 139 | + ); |
| 140 | + assert.equal(step.done, true); |
| 141 | + assert.equal(step.converged, true); |
| 142 | + assert.equal(step.reissue, false); |
| 143 | +}); |
| 144 | + |
| 145 | +// --- frame cap --------------------------------------------------------------- |
| 146 | + |
| 147 | +test("convergenceStep: terminates at the frame cap without converging", () => { |
| 148 | + // A row that never settles (librarySettled stays false) must still stop at the |
| 149 | + // cap rather than spin forever. |
| 150 | + const step = convergenceStep( |
| 151 | + input({ |
| 152 | + lastIssuedIndex: 100, |
| 153 | + librarySettled: false, |
| 154 | + framesUsed: CONVERGENCE_FRAME_CAP - 1, |
| 155 | + }), |
| 156 | + ); |
| 157 | + assert.equal(step.done, true); |
| 158 | + assert.equal(step.converged, false); |
| 159 | + assert.equal(step.nextIndex, 100); |
| 160 | +}); |
| 161 | + |
| 162 | +test("convergenceStep: frame cap bounds a perpetually shifting target", () => { |
| 163 | + // Drive the loop the way the adapter would: the target index moves every |
| 164 | + // frame, so the library never settles. The loop must terminate at the cap. |
| 165 | + let lastIssuedIndex = null; |
| 166 | + let framesUsed = 0; |
| 167 | + let done = false; |
| 168 | + let converged = true; |
| 169 | + |
| 170 | + while (framesUsed < CONVERGENCE_FRAME_CAP + 5) { |
| 171 | + const movingIndex = 100 + framesUsed; // shifts every frame |
| 172 | + const step = convergenceStep( |
| 173 | + input({ |
| 174 | + indexByMessageId: new Map([["target", movingIndex]]), |
| 175 | + lastIssuedIndex, |
| 176 | + librarySettled: false, |
| 177 | + framesUsed, |
| 178 | + }), |
| 179 | + ); |
| 180 | + lastIssuedIndex = step.nextIndex; |
| 181 | + framesUsed += 1; |
| 182 | + if (step.done) { |
| 183 | + done = step.done; |
| 184 | + converged = step.converged; |
| 185 | + break; |
| 186 | + } |
| 187 | + } |
| 188 | + |
| 189 | + assert.equal(done, true); |
| 190 | + assert.equal(converged, false); |
| 191 | + assert.ok(framesUsed <= CONVERGENCE_FRAME_CAP); |
| 192 | +}); |
| 193 | + |
| 194 | +test("convergenceStep: converges once a re-aimed index then settles", () => { |
| 195 | + // Realistic flow: frame 0 aims (lastIssued null -> 100), frame 1 the library |
| 196 | + // is chasing 100 and reports settled -> converged. |
| 197 | + const aim = convergenceStep(input({ lastIssuedIndex: null })); |
| 198 | + assert.equal(aim.nextIndex, 100); |
| 199 | + assert.equal(aim.done, false); |
| 200 | + |
| 201 | + const settle = convergenceStep( |
| 202 | + input({ |
| 203 | + lastIssuedIndex: aim.nextIndex, |
| 204 | + librarySettled: true, |
| 205 | + framesUsed: 1, |
| 206 | + }), |
| 207 | + ); |
| 208 | + assert.equal(settle.done, true); |
| 209 | + assert.equal(settle.converged, true); |
| 210 | +}); |
0 commit comments