ProPresenter reports running timers as truncated whole seconds, and the poller re-anchored the tiles' local count at the bottom of that second on every 500 ms poll — so the displayed digit ran up to a second ahead and got yanked back a step whenever a fresh poll sampled the next flip later than the last one did. Tiles flickered ...12:33 → 12:34 → 12:33... about once a second. phase.js turns the quantized reports into one stable anchor per timer: the only information in a poll is a FLIP (the report stepping one second in the direction of travel), which dates the true boundary to the midpoint of the two polls that straddle it. While reports stay consistent with the anchor (±450 ms) the anchor holds, so tiles count along one steady clock; a report the anchor can't explain (reset, seek, resume, accumulated drift) re-anchors. Timers carry the anchor as a per-timer asOf the client extrapolates from (optional in the provider contract, documented in the guide); the client additionally refuses to tick a running card backwards over sub-1.5 s jitter. phase.selftest.js (66 checks) drives a simulated ProPresenter — truncated reports, jittered polls — through the tracker and the client's render math: digits never step backwards, dwells hold 1 s (the ~2 s zero-crossing dwell is trunc's honest behavior), anchors stay within tolerance of the true value, resets snap, pause/resume re-locks, 0.2% clock drift is absorbed. Verified live against tools/pp-timers-mock.js: 78 s of 100 ms DOM sampling across four running cards — every step ±1 in the card's own direction, dwells 998–1002 ms, zero regressions; upstream kill/restart still recovers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
206 lines
7.9 KiB
JavaScript
206 lines
7.9 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* Self-test for phase.js — the whole-second phase lock — plus the client's
|
||
* never-tick-backwards guard, driven by a simulated ProPresenter:
|
||
*
|
||
* node modules/propresenter-timers/phase.selftest.js
|
||
*
|
||
* The simulation runs a true continuous timer, reports it the way
|
||
* ProPresenter does (truncated whole seconds, sampled every ~500 ms with
|
||
* jitter), feeds the reports through createTimerPhase(), then renders the
|
||
* displayed value at 25 ms steps exactly the way client.js does (extrapolate
|
||
* from { valueMs, asOf }, clamp regressions under 1.5 s). Asserts:
|
||
*
|
||
* - the displayed digits NEVER step against the direction of travel
|
||
* (the reported bug: countdown tiles flicking back to the previous
|
||
* second about once a second),
|
||
* - each displayed second dwells ~1 s,
|
||
* - the published anchor stays within tolerance of the true value,
|
||
* - operator resets/seeks snap within a poll or two,
|
||
* - pause → resume re-locks,
|
||
* - a ProPresenter whose clock drifts against the server's stays locked.
|
||
*/
|
||
|
||
const { createTimerPhase, TOL_MS } = require('./phase');
|
||
|
||
let checks = 0;
|
||
let failures = 0;
|
||
function ok(name, cond, detail) {
|
||
checks += 1;
|
||
if (!cond) {
|
||
failures += 1;
|
||
console.error(`FAIL ${name}${detail ? ` — ${detail}` : ''}`);
|
||
}
|
||
}
|
||
|
||
/** Deterministic pseudo-random (so a failure reproduces). */
|
||
function makeRand(seed) {
|
||
let s = seed >>> 0;
|
||
return () => {
|
||
s = (s * 1664525 + 1013904223) >>> 0;
|
||
return s / 2 ** 32;
|
||
};
|
||
}
|
||
|
||
/** ProPresenter's report: whole seconds, truncated toward zero. */
|
||
const trunc = (ms) => Math.trunc(ms / 1000) * 1000;
|
||
|
||
/**
|
||
* Run one scenario. `trueValueAt(t)` is the continuous value (countdown
|
||
* remaining or elapsed) on the SERVER's clock; null = not reported (paused
|
||
* timers report a frozen value via `frozen`).
|
||
*/
|
||
function simulate({ name, kind, seed, durationMs, trueValueAt, runningAt = () => true, pollMs = 500, jitterMs = 120 }) {
|
||
const rand = makeRand(seed);
|
||
const phase = createTimerPhase();
|
||
const dir = kind === 'countdown' ? -1 : 1;
|
||
|
||
const polls = [];
|
||
for (let t = 0; t <= durationMs;) {
|
||
const running = runningAt(t);
|
||
const value = trueValueAt(t);
|
||
polls.push({ t, out: phase.observe({ kind, running, wholeMs: value === null ? null : trunc(value), pollAt: t }), running });
|
||
t += pollMs + Math.floor(rand() * jitterMs);
|
||
}
|
||
|
||
// Render like client.js: between polls, extrapolate from the latest
|
||
// anchor; clamp sub-1.5 s regressions while running.
|
||
const shown = []; // { t, sec, err }
|
||
let mono = null;
|
||
let p = 0;
|
||
for (let t = polls[0].t; t <= durationMs; t += 25) {
|
||
while (p + 1 < polls.length && polls[p + 1].t <= t) p += 1;
|
||
const { out, running } = polls[p];
|
||
if (!running || out.valueMs === null) {
|
||
mono = null;
|
||
continue;
|
||
}
|
||
let value = out.valueMs + dir * (t - out.asOf);
|
||
if (mono !== null && (value - mono) * dir < 0 && Math.abs(value - mono) < 1500) value = mono;
|
||
mono = value;
|
||
const truth = trueValueAt(t);
|
||
shown.push({ t, sec: Math.trunc(value / 1000), value, err: truth === null ? 0 : value - truth });
|
||
}
|
||
return { name, dir, shown };
|
||
}
|
||
|
||
/** Assertions shared by the steady scenarios. `allowOdd` admits dwell pairs
|
||
from legitimate corrections (absorbing clock drift re-places a boundary
|
||
by up to TOL_MS once in a while). */
|
||
function assertSteady({ name, dir, shown }, { settleMs = 1600, maxErr = 700, allowOdd = 0 } = {}) {
|
||
let regressions = 0;
|
||
let maxAbsErr = 0;
|
||
const changes = [];
|
||
for (let i = 1; i < shown.length; i += 1) {
|
||
const step = shown[i].sec - shown[i - 1].sec;
|
||
if (step !== 0 && Math.sign(step) !== dir) regressions += 1;
|
||
if (step !== 0) changes.push(shown[i].t);
|
||
if (shown[i].t - shown[0].t > settleMs) maxAbsErr = Math.max(maxAbsErr, Math.abs(shown[i].err));
|
||
}
|
||
// Whole dwells only (drop the partial first and trailing segments), after
|
||
// the lock settles — the first observed flip corrects the provisional
|
||
// mid-interval anchor once, stretching or squeezing a boundary in the
|
||
// timer's first moments, which no one can see on a wall. A countdown
|
||
// crossing zero legitimately shows "0" for ~2 s — trunc keeps the digit
|
||
// for the whole (−1 s, +1 s) span, like ProPresenter's own display — so
|
||
// allow one ~2 s dwell; everything else must be ~1 s.
|
||
const settled = shown.length ? shown[0].t + 2500 : 0;
|
||
const dwells = changes.slice(1).map((t, i) => t - changes[i]).filter((d, i) => changes[i + 1] > settled + d);
|
||
const near = (d, target) => Math.abs(d - target) <= 200;
|
||
const long = dwells.filter((d) => near(d, 2000)).length;
|
||
const bad = dwells.filter((d) => !near(d, 1000) && !near(d, 2000)).length;
|
||
ok(`${name}: no digit ever steps backwards`, regressions === 0, `${regressions} regressions`);
|
||
ok(`${name}: seconds dwell ~1 s (${dwells.length} sampled)`, dwells.length > 5 && bad <= allowOdd && long <= 1,
|
||
`${bad} odd dwells, ${long} ~2 s dwells [${dwells.join(',')}]`);
|
||
ok(`${name}: locked within ${maxErr} ms of the true value`, maxAbsErr <= maxErr, `max ${Math.round(maxAbsErr)} ms`);
|
||
}
|
||
|
||
/* 1. Plain countdown, many sub-second phases and poll seeds. */
|
||
for (let i = 0; i < 12; i += 1) {
|
||
const phase0 = (i * 83) % 1000;
|
||
const run = simulate({
|
||
name: `countdown q=${phase0}ms seed=${i}`,
|
||
kind: 'countdown',
|
||
seed: 1000 + i,
|
||
durationMs: 30000,
|
||
trueValueAt: (t) => 20000 + phase0 - t, // runs through zero into overrun
|
||
});
|
||
assertSteady(run);
|
||
}
|
||
|
||
/* 2. Elapsed timer (counts up). */
|
||
for (let i = 0; i < 6; i += 1) {
|
||
const run = simulate({
|
||
name: `elapsed seed=${i}`,
|
||
kind: 'elapsed',
|
||
seed: 2000 + i,
|
||
durationMs: 25000,
|
||
trueValueAt: (t) => 400 + i * 137 + t,
|
||
});
|
||
assertSteady(run);
|
||
}
|
||
|
||
/* 3. Operator reset mid-run: value jumps back up to full length. */
|
||
{
|
||
const run = simulate({
|
||
name: 'reset',
|
||
kind: 'countdown',
|
||
seed: 7,
|
||
durationMs: 24000,
|
||
trueValueAt: (t) => (t < 12000 ? 15000 - t : 60000 - (t - 12000)),
|
||
});
|
||
// After the reset the shown value must reach the new region within ~2 polls.
|
||
const after = run.shown.filter((s) => s.t >= 13400);
|
||
ok('reset: snaps to the new value', after.length && after.every((s) => Math.abs(s.err) <= 1300), after.length ? `err ${Math.round(Math.max(...after.map((s) => Math.abs(s.err))))} ms` : 'no samples');
|
||
// Before and after the jump, digits stay monotonic within each phase.
|
||
const pre = { ...run, shown: run.shown.filter((s) => s.t < 12000) };
|
||
assertSteady(pre, { maxErr: 700 });
|
||
}
|
||
|
||
/* 4. Pause, then resume: no stale-anchor explosion. */
|
||
{
|
||
const frozen = 8000;
|
||
const run = simulate({
|
||
name: 'pause/resume',
|
||
kind: 'countdown',
|
||
seed: 11,
|
||
durationMs: 26000,
|
||
trueValueAt: (t) => (t < 6000 ? 14000 - t : t < 14000 ? frozen : frozen - (t - 14000)),
|
||
runningAt: (t) => t < 6000 || t >= 14000,
|
||
});
|
||
const resumed = { ...run, shown: run.shown.filter((s) => s.t >= 14000) };
|
||
assertSteady(resumed);
|
||
}
|
||
|
||
/* 5. ProPresenter's clock drifts 0.2% against the server's: the anchor
|
||
re-locks over time instead of accumulating a visible error. */
|
||
{
|
||
const run = simulate({
|
||
name: 'clock drift',
|
||
kind: 'countdown',
|
||
seed: 13,
|
||
durationMs: 120000,
|
||
trueValueAt: (t) => 130000 - t * 1.002,
|
||
});
|
||
// Drift accumulates ~2 ms/s; every ~4 min the anchor re-places a boundary
|
||
// by up to TOL_MS to absorb it — a slightly long/short dwell pair, never
|
||
// a backwards step (the separate regression check still demands zero).
|
||
assertSteady(run, { maxErr: 700 + TOL_MS, allowOdd: 2 });
|
||
}
|
||
|
||
/* 6. Idle timers pass through untouched. */
|
||
{
|
||
const phase = createTimerPhase();
|
||
const a = phase.observe({ kind: 'countdown', running: false, wholeMs: 300000, pollAt: 1000 });
|
||
ok('idle passes through', a.valueMs === 300000 && a.asOf === 1000);
|
||
const b = phase.observe({ kind: 'countdown', running: false, wholeMs: null, pollAt: 1500 });
|
||
ok('missing value passes through', b.valueMs === null && b.asOf === 1500);
|
||
}
|
||
|
||
if (failures) {
|
||
console.error(`\n${failures} of ${checks} checks FAILED`);
|
||
process.exit(1);
|
||
}
|
||
console.log(`phase.selftest: all ${checks} checks passed`);
|