Files
ProdDash/modules/resi-cues/resi.test.js
T
christianandClaude Opus 5 a8d1b8e159 Resi Cues becomes a real module, and Companion keeps the stage timer
Yesterday's version of this was a Python script under `modules/` with a
LaunchAgent to keep it alive — which the guide rules out in as many words:
everything a module does runs inside its server entry, no companion programs
or daemons an operator starts by hand. It also could not put anything on a
dashboard, which is half of what it was wanted for. Both halves are now in
the right place.

`modules/resi-cues/` is an ordinary module. Its tile is one number as large as
the tile allows — the countdown to the next Resi cue, the cue's name above it,
the playhead and the cue just passed underneath, and the whole cue list behind
a tile switch. It also declares `"provides": ["timers"]` and serves `/timers`
and `/timers/stream`, so the countdown and the playhead turn up among the
ProPresenter timers in the Timers module without either side knowing about
the other.

The countdown ticks in the browser. The server sends the playhead with the
instant it was true and whether playback is running, and the tile adds the
elapsed time since; the Timers module extrapolates from `startedAt` the same
way. So a snapshot only has to be sent when something structural changes — a
new cue, a pause, a seek — rather than at 1 Hz to every open dashboard.

`resi.js` holds the part worth reading twice. Resi republishes a decoder's
position only every few seconds, on an irregular cadence, and each reading is
already stale when it appears; a countdown built straight from it sits seconds
behind and lurches. So every reading is kept as media time minus wall time and
the largest recent value is trusted — the reading that arrived with the least
delay — which a slow round trip cannot drag backwards. A reading outside the
link's measured noise floor counts as a seek: drop the lock, snap, and re-read
the cue list at once instead of on its timer. Playback is only called paused
once the playhead has been still for longer than Resi's own cadence, because a
fixed timeout mistakes a slow update for a stop and freezes the countdown
mid-service.

Driving the ProPresenter timer on the stage screen is Companion's job now. It
is already a service on that machine, so nothing new has to be kept alive, and
this module no longer has to reach ProPresenter at all — it only has to be
right about Resi, which is the hard part.

`resi.test.js` drives the estimator on a fake clock against nine simulated
Resi profiles — cadences of one to twelve seconds, staleness to three,
irregular timing, variable round trips — covering lock-on, a fired cue, a seek
backwards, a pause, and a pause longer than the offset window. A punctual
fixture proves nothing here: against one, three separate breakages of the
estimator go unnoticed. `server.test.js` runs the real server entry against a
canned Resi account and checks what the two consumers actually read, including
the `/timers` shape field by field. Every mutation tried against either turns
them red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 12:08:55 -04:00

211 lines
8.3 KiB
JavaScript

'use strict';
/**
* Unit tests for the Resi playhead estimator.
*
* node modules/resi-cues/resi.test.js
* node modules/resi-cues/resi.test.js 7000 1000 4000 2000 # one profile
*
* Plain Node, no dependencies; exits non-zero on the first failure.
*
* The estimator's whole job is to be right about a feed that is coarse, late and
* irregular, so the fixture is a simulated Resi rather than a list of values: a
* decoder playing at 1x, and an API that republishes its position every so often,
* already stale, over a round trip that varies. Nine profiles span what the real
* one has been seen to do. Feeding it a kind, punctual API proves nothing —
* against one, three separate breakages of this code go unnoticed.
*/
const assert = require('assert');
const { createPlayhead, surroundingCues, positionToMs, msToPosition } = require('./resi');
let passed = 0;
function test(name, fn) {
fn();
passed++;
console.log(` ok ${name}`);
}
/* ── the simulated world ───────────────────────────────────────────────── */
function makeWorld({ cadence, lag, jitter, net }) {
let seed = 12345;
const rnd = () => {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
return seed / 0x7fffffff;
};
const w = {
t: 1700000000000,
media: 8 * 60000 + 48000,
playing: true,
published: 0,
lastPublish: 0,
nextCadence: cadence,
};
w.published = w.media - lag;
w.lastPublish = w.t;
w.advance = (ms) => {
for (let i = 0; i < ms; i += 100) {
w.t += 100;
if (w.playing) w.media += 100;
if (w.t - w.lastPublish >= w.nextCadence) {
w.lastPublish = w.t;
w.published = w.media - lag;
w.nextCadence = Math.max(500, cadence + Math.round((rnd() - 0.5) * 2 * jitter));
}
}
};
// A poll reads what Resi had when the request left, and the round trip costs time.
w.poll = () => {
const body = w.published;
if (net) w.advance(100 + Math.round(rnd() * net));
return body;
};
return w;
}
const CUES = [
{ name: 'Welcome', ms: 5 * 60000 },
{ name: 'Campus Host', ms: 12 * 60000 + 30000 },
{ name: 'Message', ms: 41 * 60000 },
{ name: 'Closing', ms: 45 * 60000 },
];
function runProfile(cadence, lag, jitter, net) {
const w = makeWorld({ cadence, lag, jitter, net });
const head = createPlayhead({ now: () => w.t });
const tol = lag + 1200 + jitter + net * 0.35;
const label = `cadence ${cadence} lag ${lag} jitter ${jitter} net ${net}`;
const second = () => { w.advance(1000); head.note(w.poll()); return head.read(); };
const seconds = (n) => { let r; for (let i = 0; i < n; i++) r = second(); return r; };
test(`${label}: locks on, and beats the raw reading it is built from`, () => {
seconds(40);
let worstEst = 0;
let worstRaw = 0;
for (let i = 0; i < 30; i++) {
const { estMs } = second();
worstEst = Math.max(worstEst, w.media - estMs);
worstRaw = Math.max(worstRaw, w.media - w.published);
}
assert.ok(worstEst <= tol, `estimate drifted ${Math.round(worstEst)}ms (allowed ${Math.round(tol)})`);
if (cadence >= 2000) {
assert.ok(worstRaw >= cadence * 0.4,
`fixture is too kind: the raw value only went ${Math.round(worstRaw)}ms stale`);
}
assert.ok(head.read().playing, 'should read as playing');
});
test(`${label}: ticks every second and never runs backwards`, () => {
let prev = head.read().estMs;
let stalls = 0;
let backwards = 0;
for (let i = 0; i < 200; i++) {
const { estMs } = second();
if (estMs === prev) stalls++;
if (estMs < prev) backwards++;
prev = estMs;
}
assert.strictEqual(stalls, 0, 'the playhead stalled');
assert.strictEqual(backwards, 0, 'the playhead went backwards');
});
test(`${label}: follows a fired cue, and rolls over the one it passes`, () => {
w.media = 40 * 60000 + 50000; // decoder jumps; Resi says nothing yet
let reported = false;
for (let i = 0; i < 60 && !reported; i++) {
second();
reported = Math.abs((head.read().estMs || 0) - w.media) < 8000;
}
assert.ok(reported, 'the jump was never picked up');
assert.ok(head.takeSeek(), 'the jump was not flagged as a seek');
const { estMs } = head.read();
assert.ok(Math.abs(estMs - w.media) <= tol + cadence / 2,
`resynced ${Math.round(w.media - estMs)}ms off`);
assert.ok(w.media - estMs >= -400, 'overshot the new playhead');
assert.strictEqual(surroundingCues(CUES, estMs).next.name, 'Message');
const after = seconds(15);
assert.strictEqual(surroundingCues(CUES, after.estMs).next.name, 'Closing');
assert.strictEqual(surroundingCues(CUES, after.estMs).prev.name, 'Message');
});
test(`${label}: follows a seek backwards`, () => {
w.media = 6 * 60000;
for (let i = 0; i < 60 && Math.abs((head.read().estMs || 0) - w.media) > 8000; i++) second();
const { estMs } = head.read();
assert.ok(Math.abs(estMs - w.media) <= tol + cadence / 2, 'did not follow the seek back');
assert.strictEqual(surroundingCues(CUES, estMs).next.name, 'Campus Host');
});
test(`${label}: freezes on a real pause and re-locks on resume`, () => {
w.playing = false;
const stoppedAt = w.media;
const wait = Math.ceil((Math.max(15000, Math.min(45000, (cadence + jitter) * 2.5 + 6000))
+ cadence + jitter + 6000) / 1000);
let r = seconds(wait);
assert.strictEqual(r.playing, false, 'did not notice the pause');
const held = r.estMs;
r = seconds(20);
assert.strictEqual(r.estMs, held, 'kept counting while paused');
w.playing = true;
r = seconds(25);
assert.strictEqual(r.playing, true, 'did not notice the resume');
assert.ok(Math.abs(r.estMs - w.media) <= tol + cadence, 're-locked in the wrong place');
assert.ok(held - stoppedAt <= 45000 + cadence + jitter + 2 * net + 3000,
'the frozen position crept instead of stopping');
});
test(`${label}: survives a pause longer than the offset window`, () => {
w.playing = false;
let r = seconds(150);
assert.strictEqual(r.playing, false, 'should still read as paused');
assert.ok(r.estMs !== null && r.estMs !== undefined, 'lost the position entirely');
w.playing = true;
head.takeSeek();
r = seconds(30);
assert.strictEqual(r.playing, true, 'did not recover');
assert.ok(Math.abs(r.estMs - w.media) <= tol + cadence, 're-locked in the wrong place');
assert.ok(head.takeSeek(), 'a resume after a long pause must flag as a seek');
});
}
/* ── the plain bits ────────────────────────────────────────────────────── */
test('positionToMs reads Resi positions, and rejects anything else', () => {
assert.strictEqual(positionToMs('00:00:00.000'), 0);
assert.strictEqual(positionToMs('01:02:03.500'), 3723500);
assert.strictEqual(positionToMs('00:00:01'), 1000);
assert.strictEqual(positionToMs(''), null);
assert.strictEqual(positionToMs('garbage'), null);
assert.strictEqual(positionToMs(null), null);
assert.strictEqual(positionToMs(undefined), null);
});
test('msToPosition round-trips', () => {
for (const ms of [0, 1500, 3723500, 36000000]) {
assert.strictEqual(positionToMs(msToPosition(ms)), ms);
}
});
test('surroundingCues puts a cue you are exactly on behind you', () => {
assert.strictEqual(surroundingCues(CUES, 12 * 60000 + 30000).next.name, 'Message');
assert.strictEqual(surroundingCues(CUES, 12 * 60000 + 30000).prev.name, 'Campus Host');
assert.strictEqual(surroundingCues(CUES, 0).prev, null);
assert.strictEqual(surroundingCues(CUES, 60 * 60000).next, null);
assert.strictEqual(surroundingCues([], 1000).next, null);
});
/* ── the matrix ────────────────────────────────────────────────────────── */
const argv = process.argv.slice(2).map(Number);
const PROFILES = argv.length >= 4 ? [argv.slice(0, 4)] : [
[5000, 500, 0, 0], [5000, 500, 0, 2500], [2000, 0, 0, 800],
[10000, 3000, 0, 2500], [7000, 1000, 4000, 2000], [3000, 500, 2000, 1000],
[12000, 2000, 6000, 3000], [1000, 0, 0, 0], [8000, 1500, 3000, 4000],
];
for (const [cadence, lag, jitter, net] of PROFILES) runProfile(cadence, lag, jitter, net);
console.log(`\n${passed} tests passed`);