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