Files

273 lines
13 KiB
JavaScript
Raw Permalink Normal View History

// Drives the real pollPlayer() + estimator against a simulated Resi that
// publishes `position` on a coarse cadence, already slightly stale.
var console = {
log: function () { print(Array.prototype.join.call(arguments, ' ')) },
error: function () { print('ERR ' + Array.prototype.join.call(arguments, ' ')) },
warn: function () { print('WARN ' + Array.prototype.join.call(arguments, ' ')) },
info: function () { print(Array.prototype.join.call(arguments, ' ')) },
debug: function () {},
};
var failures = [];
function check(c, m) { if (!c) { failures.push(m); print(' FAIL ' + m) } }
function eq(g, w, m) { check(g === w, m + ' (got ' + g + ', want ' + w + ')') }
function near(g, w, tol, m) {
check(Math.abs(g - w) <= tol, m + ' (got ' + Math.round(g) + ', want ' + Math.round(w) + ' +/-' + tol + ')');
}
function StubInstanceBase() {}
StubInstanceBase.prototype.updateStatus = function () {}
StubInstanceBase.prototype.log = function () {}
StubInstanceBase.prototype.setVariableValues = function () {}
var stubs = {
'@companion-module/base': {
InstanceBase: StubInstanceBase,
InstanceStatus: { Ok: 'ok', Connecting: 'c', ConnectionFailure: 'f', BadConfig: 'b' },
runEntrypoint: function () {}, combineRgb: function () { return 0 }, Regex: {},
},
'./config.js': { GetConfigFields: function () { return [] } },
'./variables.js': { UpdateVariableDefinitions: function () {} },
'./upgrades.js': { UpgradeScripts: [] },
'./actions.js': { UpdateActions: function () {} },
'./feedbacks.js': { UpdateFeedbacks: function () {} },
'./presets.js': { UpdatePresets: function () {} },
};
// ---- the simulated world -------------------------------------------------
var fakeNow = 1700000000000;
Date.now = function () { return fakeNow };
// cadence / lag / jitter can be overridden: jsc tick_test.js -- 10000 3000 4000
var ARGS = (typeof arguments !== 'undefined' ? arguments : []).map(Number);
var DIAG = true;
var RESI_CADENCE = ARGS[0] || 5000; // how often Resi refreshes `position`
var RESI_LAG = ARGS.length > 1 ? ARGS[1] : 500; // how stale it already is when it appears
var RESI_JITTER = ARGS[2] || 0; // how irregular the cadence is
var NET_JITTER = ARGS[3] || 0; // how variable the request/response round trip is
var START_INTO = ARGS[4] || 0; // how far into Resi's cycle the module starts polling
var seed = 12345;
function rnd() { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff }
var nextCadence = RESI_CADENCE;
var playing = true;
var trueMedia = 8 * 60000 + 48000; // where the decoder REALLY is
var published = trueMedia - RESI_LAG; // what the API currently returns
var lastPublish = fakeNow - START_INTO;
var extraPlayerFields = {};
var TOL = RESI_LAG + 1200 + RESI_JITTER;
function pad(n, w) { return String(n).padStart(w, '0') }
function fmt(ms) {
var t = Math.max(0, Math.floor(ms));
return pad(Math.floor(t / 3600000), 2) + ':' + pad(Math.floor((t % 3600000) / 60000), 2) + ':' +
pad(Math.floor((t % 60000) / 1000), 2) + '.' + pad(t % 1000, 3);
}
function advanceWorld(ms) {
for (var i = 0; i < ms; i += 100) {
fakeNow += 100;
if (playing) trueMedia += 100;
if (fakeNow - lastPublish >= nextCadence) {
lastPublish = fakeNow;
published = trueMedia - RESI_LAG;
nextCadence = RESI_CADENCE + Math.round((rnd() - 0.5) * 2 * RESI_JITTER);
if (nextCadence < 500) nextCadence = 500;
}
}
}
var fetch = function () {
// the body is whatever Resi had at the moment the request left
var one = { playerHardwareId: 'DEC1', userName: 'Lobby', position: fmt(published), eventId: 'EV1' };
for (var k in extraPlayerFields) one[k] = extraPlayerFields[k];
// ...and the round trip takes a while, during which the world moves on
if (NET_JITTER) advanceWorld(100 + Math.round(rnd() * NET_JITTER));
return Promise.resolve({ ok: true, json: function () { return Promise.resolve([one]) } });
};
var tickFn = null, tickMs = null;
var setInterval = function (fn, ms) { tickFn = fn; tickMs = ms; return {} };
var clearInterval = function () {};
var src = readFile(typeof TARGET !== 'undefined' ? TARGET : 'build/pkg/resi-studio-cues/dist/main.js');
var mod = { exports: {} };
(new Function('module', 'exports', 'require', 'setInterval', 'clearInterval', 'console', 'fetch', src))(
mod, mod.exports,
function (id) { if (!stubs[id]) throw new Error('unstubbed require: ' + id); return stubs[id] },
setInterval, clearInterval, console, fetch);
var inst = Object.create(mod.exports.ModuleInstance.prototype);
var vars = {};
inst.setVariableValues = function (o) { for (var k in o) vars[k] = o[k] };
inst.log = function () {};
inst.config = {};
inst.selectedVenueUuid = 'V1';
inst.selectedPlayerHardwareId = 'DEC1';
inst.cookieHeader = 'stub';
inst.currentEventId = 'EV1';
inst.playerPollInFlight = false;
inst.ensureAuthenticated = function () { return Promise.resolve() };
inst.buildAuthHeaders = function () { return {} };
inst.refreshCuesIfDue = function () { return Promise.resolve() };
inst.cues = [
{ uuid: 'a', name: 'Welcome', positionMs: 5 * 60000 },
{ uuid: 'b', name: 'Campus Host', positionMs: 12 * 60000 + 30000 },
{ uuid: 'c', name: 'Message', positionMs: 41 * 60000 },
{ uuid: 'd', name: 'Closing', positionMs: 45 * 60000 },
];
// true once Resi has actually REPORTED a position near `target`
function reported_near(target) {
return Number.isFinite(inst.__lastSampleMediaMs) && Math.abs(inst.__lastSampleMediaMs - target) < 8000;
}
// one second of real operation: advance the world, poll, redraw
// any PAUSED while the decoder is really playing is a false pause: it freezes the countdown
var falsePauses = 0;
async function second() {
advanceWorld(1000); await inst.pollPlayer(); tickFn();
if (playing && vars.playback_state === 'PAUSED') falsePauses++;
}
async function seconds(n) { for (var i = 0; i < n; i++) await second() }
function timestampChecks() {
var now = Date.now();
eq(inst.detectSampleTime({ lastUpdated: new Date(now - 2000).toISOString() }), now - 2000,
'uses an ISO "lastUpdated" as the sample time');
eq(inst.detectSampleTime({ reportedAt: now - 1500 }), now - 1500,
'uses an epoch-ms "reportedAt" as the sample time');
eq(inst.detectSampleTime({ startTime: new Date(now - 120000).toISOString() }), undefined,
'ignores a timestamp too old to be this reading');
eq(inst.detectSampleTime({ userName: 'Lobby', position: '00:01:02.000' }), undefined,
'ignores fields that are not timestamps');
}
(async function () {
inst.startSmoothTick();
eq(tickMs, 1000, 'display ticks every second');
// ---- accuracy while playing -----------------------------------------
await seconds(40);
var err = trueMedia - inst.__lastEstMs;
check(err <= TOL + NET_JITTER * 0.35, 'estimate is not lagging (behind truth by ' + Math.round(err) + 'ms)');
check(err >= -300, 'estimate does not run ahead of the real playhead (' + Math.round(err) + 'ms)');
// the point of the whole exercise: compare against the raw value over a
// full publish cycle, not at one lucky instant
var maxEstErr = 0, maxRawErr = 0;
for (var w = 0; w < 30; w++) {
await second();
maxEstErr = Math.max(maxEstErr, trueMedia - inst.__lastEstMs);
maxRawErr = Math.max(maxRawErr, trueMedia - published);
}
if (RESI_CADENCE >= 2000)
check(maxRawErr >= RESI_CADENCE * 0.4,
'sanity: the raw Resi value goes badly stale within a cycle (' + Math.round(maxRawErr) + 'ms)');
check(maxEstErr <= TOL + NET_JITTER * 0.35,
'the estimate stays close all cycle (worst ' + Math.round(maxEstErr) + 'ms vs raw ' + Math.round(maxRawErr) + 'ms)');
// ---- ticks every second, never backwards ------------------------------
var prev = inst.__lastEstMs, stalls = 0, backwards = 0;
for (var i = 0; i < 60; i++) {
await second();
if (inst.__lastEstMs === prev) stalls++;
if (inst.__lastEstMs < prev) backwards++;
prev = inst.__lastEstMs;
}
eq(stalls, 0, 'no stalled seconds across a minute of playback');
eq(backwards, 0, 'the playhead never goes backwards');
eq(vars.playback_state, 'PLAYING', 'reported as playing');
check(trueMedia - inst.__lastEstMs <= TOL + NET_JITTER * 0.35, 'still accurate after a minute');
// ---- long run: the offset window is finite, so the best sample eventually
// ages out of it; that must not drag the displayed playhead backwards
var prev2 = inst.__lastEstMs, back2 = 0, worstBack = 0;
for (var L = 0; L < 200; L++) {
await second();
if (inst.__lastEstMs < prev2) {
back2++; worstBack = Math.max(worstBack, prev2 - inst.__lastEstMs);
if (typeof DIAG !== 'undefined')
print(' backward step ' + Math.round(prev2 - inst.__lastEstMs) + 'ms at L=' + L +
' state=' + vars.playback_state + ' offsets=' + (inst.__offsets || []).length +
' sinceNew=' + (Date.now() - inst.__lastNewSampleAt) + ' base=' + inst.__baseOffset);
}
prev2 = inst.__lastEstMs;
}
eq(back2, 0, 'no backward steps over 200s (worst was ' + Math.round(worstBack) + 'ms)');
eq(falsePauses, 0, 'never reported PAUSED while playing, startup included');
// ---- a fired cue / forward seek --------------------------------------
inst.lastCueRefreshMs = 999999;
trueMedia = 40 * 60000 + 50000; // decoder jumps; Resi says nothing yet
var jumpAt = fakeNow, sawIt = false;
for (var q = 0; q < 60 && !sawIt; q++) { await second(); sawIt = reported_near(trueMedia) }
check(sawIt, 'the jump is picked up at all');
var detectMs = fakeNow - jumpAt;
if (typeof DIAG !== 'undefined') print(' jump detected after ' + detectMs + 'ms');
check(detectMs <= RESI_CADENCE + RESI_JITTER + 2000,
'jump detected within one publish cycle (' + detectMs + 'ms)');
eq(inst.lastCueRefreshMs, 0, 'a jump forces the cue list to refresh immediately');
near(inst.__lastEstMs, trueMedia, TOL + RESI_CADENCE / 2, 'estimate resyncs to the new playhead at once');
if (typeof DIAG !== 'undefined') print(' post-seek error: ' + Math.round(trueMedia - inst.__lastEstMs) + 'ms');
check(trueMedia - inst.__lastEstMs >= -400,
'estimate does not overshoot after a seek (' + Math.round(trueMedia - inst.__lastEstMs) + 'ms)');
eq(vars.next_cue_name, 'Message', 'next cue is correct straight after the jump');
await seconds(15);
eq(vars.next_cue_name, 'Closing', 'rolled over after passing Message');
eq(vars.prev_cue_name, 'Message', 'passed cue became prev');
// ---- a backwards seek -------------------------------------------------
trueMedia = 6 * 60000;
for (var r = 0; r < 60 && !reported_near(trueMedia); r++) await second();
near(inst.__lastEstMs, trueMedia, TOL + RESI_CADENCE / 2, 'follows a backwards seek');
eq(vars.next_cue_name, 'Campus Host', 'cue targets recompute after seeking back');
// ---- pause ------------------------------------------------------------
playing = false;
// how long the module can legitimately take to call it a pause, given this feed
var pauseWait = Math.ceil((Math.max(15000, Math.min(45000, (RESI_CADENCE + RESI_JITTER) * 2.5 + 6000))
+ RESI_CADENCE + RESI_JITTER + 6000) / 1000);
await seconds(pauseWait);
eq(vars.playback_state, 'PAUSED', 'detects a real pause');
var held = inst.__lastEstMs;
await seconds(20);
eq(inst.__lastEstMs, held, 'paused playhead holds instead of running on');
// ---- resume -----------------------------------------------------------
playing = true;
await seconds(25);
eq(vars.playback_state, 'PLAYING', 'recovers after resume');
near(inst.__lastEstMs, trueMedia, TOL + 1500, 'estimate re-locks after resume');
// ---- operator-driven resync (beats waiting for Resi to republish) ----
var beforeEst = inst.__lastEstMs;
eq(inst.resyncToCue('No Such Cue'), false, 'resync to an unknown cue is refused');
eq(inst.__lastEstMs, beforeEst, 'a refused resync leaves the playhead alone');
inst.lastCueRefreshMs = 999999;
eq(inst.resyncToCue('Campus Host'), true, 'resync to a named cue succeeds');
eq(inst.__lastEstMs, 12 * 60000 + 30000, 'playhead snaps to that cue immediately');
eq(vars.next_cue_name, 'Message', 'next cue updates in the same instant');
eq(inst.lastCueRefreshMs, 0, 'resync also forces a cue-list refresh');
eq(inst.resyncToNextCue(), true, 'resync to the next cue succeeds');
eq(inst.__lastEstMs, 41 * 60000, 'playhead snaps to the following cue');
// a real sample after a resync must still be taken seriously
trueMedia = 30 * 60000; published = trueMedia - RESI_LAG; lastPublish = fakeNow;
await seconds(2);
for (var z = 0; z < 60 && !reported_near(trueMedia); z++) await second();
near(inst.__lastEstMs, trueMedia, TOL + RESI_CADENCE / 2, 'a real reading overrides a resync');
timestampChecks();
var b = failures.length;
eq(vars.next_cue_name, 'Not A Real Cue', 'deliberate failure');
if (failures.length !== b + 1) { print('HARNESS BROKEN'); throw new Error('harness') }
failures.pop();
if (failures.length) { print('\n' + failures.length + ' FAILED'); throw new Error('tests failed') }
print('\nall estimator tests passed [cadence ' + RESI_CADENCE + 'ms, lag ' + RESI_LAG + 'ms, jitter +/-' + RESI_JITTER + 'ms]');
})();