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