aboutsummaryrefslogtreecommitdiffstats
path: root/tools/feed-wheel-reverse-test.js
blob: be5ad54bef9cae785934abfb1d01d6e69ff8c6f6 (plain) (blame)
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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
// 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(inside = '') {
  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() {} };
  // the target sits in a feed page (or, for a thread case, a thread page), inside the given element
  const target = { closest: (selector) => selector.split(/,\s*/).some((p) => p === inside || (p === '.feed-page' && inside !== '.thread-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 gentler push than a notch: half a notch's travel, so
  // a reader who overshot settles back rather than flying the other way.
  const back = (before - f.y) * dir;
  assert(back > 130 && back < 170, 'a reverse notch against a coast travels half a notch: ' + back);
  // The next notch the same way is a plain one again, winding up from the
  // half push's speed: a reversal and a quick second notch travel about a
  // notch and a half.
  let pair;
  { 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); pair = (f.y - y) * dir;
    assert(pair > 440 && pair < 560, 'a reversal and a quick second notch: about a notch and a half: ' + pair); }
  // From rest the same two quick notches go further: a full first push, and the wind-up.
  { 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 > pair + 100, 'two quick notches from rest go further: ' + s + ' vs ' + pair); }
  // A spin that keeps going after a reversal winds up from its second
  // notch: a reversal plus four quick notches goes well past four 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 > 4 * 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 half push costs 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.3, '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: the half push.
  { f.wheel(dir); settle(f, 40); f.advance(100); const y = f.y; f.wheel(-dir); settle(f); const s = (y - f.y) * dir; assert(s > 130 && s < 170, 'reversing right after the coast dies is the half push: ' + s); }
  // Well after the coast died it is a plain notch from rest, whichever way.
  { f.advance(2500); const y = f.y; f.wheel(-dir); settle(f); const s = (y - f.y) * dir; assert(s > 280 && s < 330, 'a notch the other way from rest is a full push: ' + s); }
}
// A notch over a video (or an audio player) in the feed coasts like any
// other — the wheel does nothing to a player itself — and so does one on a
// thread page.
for (const sel of ['video', 'audio', '.thread-page']) {
  const f = fixture(sel);
  const y = f.y; f.wheel(1); settle(f);
  assert(f.y - y > 280 && f.y - y < 330, 'a notch over ' + sel + ' coasts a full push: ' + (f.y - y));
}
// ...while a notch in a text box, or in the media pager, is left alone.
for (const sel of ['textarea', '.media-pager']) {
  const f = fixture(sel);
  const y = f.y; f.wheel(1); settle(f);
  assert.equal(f.y, y, 'a notch in ' + sel + ' is not ours');
}
console.log('✓ immediate reversal both ways, stale frame timestamps, busy frame recovery, a reversal is a half push that winds up again from the next notch, videos and threads coast');