Files
ProdDash/modules/propresenter-timers/phase.selftest.js
T

206 lines
7.9 KiB
JavaScript
Raw Permalink Normal View History

'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`);