1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
|
// 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 hunt: a quarter of a notch's travel, not a flight the other way.
const back = (before - f.y) * dir;
assert(back > 65 && back < 90, 'a reverse notch against a coast is a short step: ' + back);
// Each notch on grows the step back towards a full one; the sixth is a plain notch again.
const steps = [];
for (let i = 0; i < 5; i++) { f.advance(200); const y = f.y; f.wheel(-dir); settle(f); steps.push((y - f.y) * dir); }
assert(steps.every((s, i) => i === 0 || s > steps[i - 1] * 1.08), 'hunting steps grow notch by notch: ' + steps.map(Math.round));
assert(steps[0] > 95 && steps[0] < 125 && steps[3] > 240 && steps[3] < 285 && steps[4] > 280 && steps[4] < 330, 'from about a third of a notch to a full one: ' + steps.map(Math.round));
{ f.advance(200); const y = f.y; f.wheel(-dir); settle(f); const s = (y - f.y) * dir; assert(s > 280 && s < 330, 'a few flicks on, the wheel flies again: ' + s); }
// A turn back while hunting starts the small steps over.
f.wheel(-dir); f.advance(17); f.frame(); f.advance(17); f.frame();
{ const y = f.y; f.wheel(dir); settle(f); const s = (f.y - y) * dir; assert(s > 65 && s < 90, 'a reversal while hunting is a short step again: ' + s); }
{ f.advance(300); const y = f.y; f.wheel(dir); settle(f); const s = (f.y - y) * dir; assert(s > 95 && s < 120, 'and the next grows from there: ' + s); }
// A pause ends the hunt: the next notch is the plain push from rest.
{ f.advance(2200); const y = f.y; f.wheel(dir); settle(f); const s = (f.y - y) * dir; assert(s > 280 && s < 330, 'after a pause a notch is a full push: ' + s); }
// A turn back against a coast that only just died hunts too.
{ f.wheel(dir); settle(f); f.advance(200); const y = f.y; f.wheel(-dir); settle(f); const s = (y - f.y) * dir; assert(s > 65 && s < 90, 'reversing right after the coast dies is a short step: ' + s); }
// ...but a notch from rest the other way, well after, is a full one.
{ 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); }
}
console.log('✓ immediate reversal both ways, stale frame timestamps, busy frame recovery, precision hunt after a reversal (short steps that grow, restart on another turn back, end on a pause)');
|