Repository navigation
Expand file tree
/
Copy pathscroll-ease.test.mjs
More file actions
110 lines (99 loc) · 3.97 KB
/
Copy pathscroll-ease.test.mjs
File metadata and controls
110 lines (99 loc) · 3.97 KB
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
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
// node --test scroll-ease.test.mjs
// The chat's own scroll glide must take the same time on every screen, whatever its refresh rate.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const { glideStep, RATE, REF_MS, MAX_DT_MS } = require('./scroll-ease.js');
const RATES = [30, 48, 60, 75, 90, 120, 144, 165, 240, 300, 360];
const DISTANCES = [100, 600, 4000];
/** Frames at a steady rate until the glide is within 1px of its target. Returns ms and the path. */
function settle(hz, dist) {
const dt = 1000 / hz;
let pos = 0;
let ms = 0;
const path = [0];
while (Math.abs(dist - pos) >= 1 && ms < 5000) {
pos = glideStep(pos, dist, dt);
ms += dt;
path.push(pos);
}
return { ms, path };
}
test('the glide takes the same time at every refresh rate', () => {
for (const dist of DISTANCES) {
// The moment the exact curve is within 1px of its target.
const ideal = (REF_MS * Math.log(dist)) / -Math.log(1 - RATE);
for (const hz of RATES) {
const { ms } = settle(hz, dist);
// A glide can only end on a frame: at most one frame later than the ideal moment, never sooner.
assert.ok(ms >= ideal - 1e-6 && ms <= ideal + 1000 / hz + 1e-6, `${dist}px at ${hz}Hz: ${Math.round(ms)}ms, ideal ${Math.round(ideal)}ms`);
}
}
});
test('a glide feels like a glide: a usable length, never a jump or a crawl', () => {
for (const hz of RATES) {
const { ms } = settle(hz, 600);
assert.ok(ms >= 150 && ms <= 450, `${hz}Hz took ${Math.round(ms)}ms`);
}
});
test('where the glide is depends on elapsed time only', () => {
const dist = 800;
for (const hz of RATES) {
const dt = 1000 / hz;
let pos = 0;
for (let n = 1; n <= 20; n++) {
pos = glideStep(pos, dist, dt);
const expected = dist * (1 - Math.pow(1 - RATE, (n * dt) / REF_MS));
assert.ok(Math.abs(pos - expected) < 1e-6, `${hz}Hz frame ${n}: ${pos} vs ${expected}`);
}
}
});
test('an uneven frame rate gives the same place as an even one', () => {
const dist = 600;
// 160 ms made of frames of 2 to 24 ms.
const frames = [3, 24, 5, 11, 2, 16, 7, 9, 4, 20, 8, 13, 6, 12, 5, 15];
const total = frames.reduce((a, b) => a + b, 0);
let pos = 0;
for (const dt of frames) pos = glideStep(pos, dist, dt);
const expected = dist * (1 - Math.pow(1 - RATE, total / REF_MS));
assert.ok(Math.abs(pos - expected) < 1e-6, `${pos} vs ${expected}`);
});
test('one stalled frame does not turn the glide into a jump', () => {
const dist = 600;
const stalled = glideStep(0, dist, 400); // the page was busy for 400 ms
const capped = glideStep(0, dist, MAX_DT_MS);
assert.equal(stalled, capped);
assert.ok(stalled < dist * 0.75, `jumped to ${stalled}`);
});
test('no time passing means no movement, and a negative time never moves backwards', () => {
assert.equal(glideStep(120, 600, 0), 120);
assert.equal(glideStep(120, 600, -5), 120);
});
test('it only ever moves toward the target, without overshooting, both ways', () => {
for (const hz of RATES) {
for (const [from, to] of [[0, 900], [900, 0], [350, 351], [351, 350]]) {
let pos = from;
for (let n = 0; n < 4000; n++) {
const next = glideStep(pos, to, 1000 / hz);
assert.ok(to >= from ? next >= pos && next <= to : next <= pos && next >= to, `${hz}Hz ${from}->${to} at ${next}`);
pos = next;
}
assert.ok(Math.abs(pos - to) < 0.01);
}
}
});
test('a tiny step at a fast refresh rate is kept, not rounded away', () => {
// Rounding what is drawn each frame (as the page does) must not stall short of the target when the
// exact position is kept separately.
for (const hz of [240, 300, 360]) {
const dt = 1000 / hz;
let pos = 0;
let drawn = 0;
for (let n = 0; n < 2000 && Math.abs(40 - pos) >= 0.5; n++) {
pos = glideStep(pos, 40, dt);
drawn = Math.round(pos);
}
assert.equal(drawn, 40, `${hz}Hz stopped at ${drawn}`);
}
});