// Exercise the real input/coast handlers with controlled frame timing. import assert from 'node:assert/strict'; import { runInNewContext } from 'node:vm'; const source = await Bun.file('src/app.js').text(); const start = source.indexOf('(() => {', source.indexOf('// The feed scrolls like a phone')); const end = source.indexOf("\nwindow.addEventListener('load'", start); assert(start > 0 && end > start); function fixture() { let now = 100, id = 0; const handlers = new Map(), frames = new Map(), steps = []; const window = { scrollY: 5000, addEventListener: (name, fn) => handlers.set(name, fn), scrollBy: ({ top }) => { steps.push(top); window.scrollY += top; }, }; const document = { body: {}, addEventListener() {}, removeEventListener() {} }; const target = { closest: (selector) => selector.includes('.feed-page') ? target : null, scrollHeight: 100, clientHeight: 100, parentElement: document.body }; runInNewContext(source.slice(start, end), { window, document, matchMedia: () => ({ matches: false }), performance: { now: () => now }, requestAnimationFrame: (fn) => { frames.set(++id, fn); return id; }, cancelAnimationFrame: (id) => frames.delete(id), setTimeout, clearTimeout, }); return { get y() { return window.scrollY; }, steps, wheel(dir) { handlers.get('wheel')({ deltaY: dir * 100, wheelDeltaY: -dir * 120, deltaMode: 0, deltaX: 0, target, timeStamp: now, preventDefault() {} }); }, advance(ms) { now += ms; }, frame(stamp = now) { const callbacks = [...frames.values()]; frames.clear(); for (const fn of callbacks) fn(stamp); }, }; } const settle = (f, n = 70) => { for (let i = 0; i < n; i++) { f.advance(17); f.frame(); } }; for (const dir of [1, -1]) { const f = fixture(); f.wheel(dir); f.advance(17); f.frame(); f.wheel(dir); f.advance(17); f.frame(); // A busy main thread has missed several frames when the reverse arrives. f.advance(120); const before = f.y; f.wheel(-dir); assert((f.y - before) * dir < 0, 'first reverse notch responds immediately'); assert(Math.abs(f.y - before) < 20, 'reverse starts with a small bounded step'); const reversed = f.y; f.frame(0); // a queued animation frame can carry an older timestamp assert.equal(f.y, reversed, 'old frame time cannot revive the previous direction'); f.advance(17); f.frame(); assert((f.y - reversed) * dir < 0, 'next frame continues in the new direction'); assert(Math.abs(f.y - reversed) < 20, 'new velocity is not applied to the old frame delay'); settle(f); // The turn back is a plain notch from rest: its full travel, no flight the other way. const back = (before - f.y) * dir; assert(back > 280 && back < 330, 'a reverse notch against a coast travels one notch: ' + back); // For a moment after a reversal notches don't wind the speed up: a // reversal and a quick second notch travel about two notches, not a flight. let calm; { f.wheel(-dir); f.advance(17); f.frame(); f.wheel(-dir); f.advance(120); f.frame(); // coasting again, the other way const y = f.y; f.wheel(dir); f.advance(60); f.frame(); f.wheel(dir); settle(f); calm = (f.y - y) * dir; assert(calm > 540 && calm < 700, 'a reversal and a quick second notch: about two notches of travel: ' + calm); } // From rest, well after, the wind-up is back: the same two quick notches go further. { f.advance(2500); const y = f.y; f.wheel(dir); f.advance(60); f.frame(); f.wheel(dir); settle(f); const s = (f.y - y) * dir; assert(s > calm + 40, 'two quick notches with the wind-up go further: ' + s + ' vs ' + calm); } // A spin that keeps going after a reversal winds up again from its third // notch: a reversal plus four quick notches goes well past five plain ones. let spin; { f.wheel(-dir); f.advance(17); f.frame(); f.wheel(-dir); f.advance(120); f.frame(); const y = f.y; f.wheel(dir); for (let i = 0; i < 4; i++) { f.advance(60); f.frame(); f.wheel(dir); } settle(f); spin = (f.y - y) * dir; assert(spin > 5 * 300 + 150, 'a spin on after a reversal winds up again: ' + spin); } // ...and the same five quick notches from rest go about as far (the two calm ones cost little). { f.advance(2500); const y = f.y; f.wheel(dir); for (let i = 0; i < 4; i++) { f.advance(60); f.frame(); f.wheel(dir); } settle(f); const s = (f.y - y) * dir; assert(s < spin * 1.25, 'five from rest vs five after a reversal: ' + s + ' vs ' + spin); settle(f, 200); } // From rest a notch either way is a full push. { f.advance(2500); const y = f.y; f.wheel(dir); settle(f); const s = (f.y - y) * dir; assert(s > 280 && s < 330, 'from rest a notch either way is a full push: ' + s); } // A turn back against a coast that only just died is a reversal too (caught, no wind-up). { f.wheel(dir); settle(f, 40); f.advance(100); const y = f.y; f.wheel(-dir); f.advance(60); f.frame(); f.wheel(-dir); settle(f); const s = (y - f.y) * dir; assert(s > 520 && s < 700, 'reversing right after the coast dies: no wind-up: ' + s); } } console.log('✓ immediate reversal both ways, stale frame timestamps, busy frame recovery, a reversal is a plain notch, two calm notches, then the wind-up is back');