Files
Nextcue/source/config_test.js
T
christianandClaude Opus 5.5 0327d6a9f8 Companion module 0.1.5: settings save even before Resi answers
While the connection wasn't connected, nothing in its settings could be
saved. The saved decoder showed as "??" because the list only fills once
Resi answers, and a cue refresh that had never been saved showed as 0, below
its minimum. Either blocks Save, including the login change that would have
fixed the connection. That's how North Attleboro's connection stayed on a
locked login.

- The saved decoder is always a choice, "(not connected to Resi yet)" until
  Resi answers.
- An upgrade script fills settings an older connection never saved with
  their defaults (and leaves a complete one alone).
- Event details are read once per event instead of on every cue refresh:
  one Resi request fewer every few seconds on a shared account.
- Cue requests share the login/rate-limit hold-off; "Loaded N cues" and
  repeated cue-refresh failures are logged once, not every few seconds.
- "inf0" in the decoder info poll label.

source/config_test.js covers all of it (6 tests; the first fails on 0.1.4).
backoff_test.js 5/5 and the estimator matrix still pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:27:34 -04:00

144 lines
6.2 KiB
JavaScript

// The settings form, the upgrade script and the cue refresh (0.1.5).
//
// Seen at North Attleboro: the connection's settings couldn't be saved at all while
// it wasn't connected -- the saved decoder showed as "??" (not in the list) and a
// cue refresh that had never been saved showed as 0 (below its minimum), and either
// one blocks Save, so the login that would have fixed it couldn't be changed.
// Runs the real module code against a fake Resi; touches nothing real.
//
// node config_test.js (defaults to dist/)
// node config_test.js path/to/dist
'use strict';
const Module = require('module');
const path = require('path');
const assert = require('assert');
const dist = path.resolve(process.argv[2] || path.join(__dirname, 'dist'));
const stubs = {
'@companion-module/base': {
InstanceBase: class { updateStatus() {} log() {} setVariableValues() {} },
InstanceStatus: { Ok: 'ok', Connecting: 'c', ConnectionFailure: 'f', BadConfig: 'b' },
runEntrypoint() {}, combineRgb() { return 0 }, Regex: {},
},
'./variables.js': { UpdateVariableDefinitions() {} },
'./actions.js': { UpdateActions() {} },
'./feedbacks.js': { UpdateFeedbacks() {} },
'./presets.js': { UpdatePresets() {} },
};
const realLoad = Module._load;
Module._load = function (req, parent, isMain) {
if (stubs[req]) return stubs[req];
return realLoad.apply(this, arguments);
};
let fetches = [];
let cues = [{ uuid: 'a', name: 'Welcome', position: '00:05:00' }, { uuid: 'b', name: 'Message', position: '00:30:00' }];
let cueStatus = 200;
const reply = (status, body) => ({
ok: status >= 200 && status < 300, status,
headers: { get: () => null, getSetCookie: () => [] },
json: async () => body, text: async () => JSON.stringify(body),
});
global.fetch = async (url) => {
const p = String(url).replace('https://central.resi.io', '').split('?')[0];
fetches.push(p);
if (/\/events\/[^/]+$/.test(p)) return reply(200, { eventProfileId: 'P1', startTime: '2026-10-07T13:30:00Z' });
if (p.endsWith('/cues')) return reply(cueStatus, cues);
return reply(404, {});
};
const { ModuleInstance } = require(path.join(dist, 'main.js'));
const { UpgradeScripts } = require(path.join(dist, 'upgrades.js'));
function fresh() {
const inst = Object.create(ModuleInstance.prototype);
inst.config = {};
inst.secrets = { password: 'pw' };
inst.venues = [];
inst.playersByVenueUuid = {};
inst.updateStatus = () => {};
inst.setVariableValues = () => {};
inst.infos = [];
inst.warns = [];
inst.log = (lvl, m) => { (lvl === 'info' ? inst.infos : inst.warns).push(m); };
return inst;
}
let passed = 0;
async function test(name, fn) { await fn(); passed++; console.log(` ok ${name}`); }
(async () => {
await test('the saved decoder is a valid choice even before Resi answers', () => {
const inst = fresh();
inst.config = { defaultPlayerHardwareId: 'DEC1' };
const field = inst.getConfigFields().find((f) => f.id === 'defaultPlayerHardwareId');
const ids = field.choices.map((c) => c.id);
assert.ok(ids.includes('DEC1'), `choices were ${JSON.stringify(ids)}`);
assert.match(field.choices.find((c) => c.id === 'DEC1').label, /not connected/);
});
await test('once connected, the decoder is listed once, under its real name', () => {
const inst = fresh();
inst.config = { defaultPlayerHardwareId: 'DEC1' };
inst.venues = [{ uuid: 'V1', userName: 'North Attleboro' }];
inst.playersByVenueUuid = { V1: [{ playerHardwareId: 'DEC1', userName: 'North Attleboro' }] };
const field = inst.getConfigFields().find((f) => f.id === 'defaultPlayerHardwareId');
const dec = field.choices.filter((c) => c.id === 'DEC1');
assert.strictEqual(dec.length, 1);
assert.match(dec[0].label, /North Attleboro/);
});
await test('the upgrade fills settings an older connection never saved', () => {
const r = UpgradeScripts[0]({}, { config: { username: 'x', playerPollMs: 3000 }, actions: [], feedbacks: [] });
assert.ok(r.updatedConfig, 'config should be updated');
assert.strictEqual(r.updatedConfig.cuePollMs, 5000);
assert.strictEqual(r.updatedConfig.listPollMs, 120000);
assert.strictEqual(r.updatedConfig.positionTrimMs, 0);
assert.strictEqual(r.updatedConfig.playerPollMs, 3000, 'a saved value is kept');
});
await test('the upgrade leaves a complete connection alone', () => {
const r = UpgradeScripts[0]({}, {
config: { playerPollMs: 3000, positionTrimMs: 0, cuePollMs: 5000, listPollMs: 120000 }, actions: [], feedbacks: [],
});
assert.strictEqual(r.updatedConfig, null);
});
await test('event details are read once per event, and the cue log only speaks on a change', async () => {
const inst = fresh();
inst.customerId = 'C';
inst.currentEventId = 'EV1';
inst.cookieHeader = 'livingas1=T';
inst.lastLoginMs = Date.now();
fetches = [];
for (let i = 0; i < 5; i++) await inst.refreshEventProfileAndCues();
assert.strictEqual(fetches.filter((p) => p.endsWith('/events/EV1')).length, 1, 'event detail once');
assert.strictEqual(fetches.filter((p) => p.endsWith('/cues')).length, 5, 'cues every refresh');
assert.strictEqual(inst.infos.filter((m) => /Loaded/.test(m)).length, 1, `logged ${inst.infos.length} times`);
cues = cues.concat({ uuid: 'c', name: 'Closing', position: '00:45:00' });
await inst.refreshEventProfileAndCues();
assert.strictEqual(inst.infos.filter((m) => /Loaded/.test(m)).length, 2, 'a changed list is logged');
inst.currentEventId = 'EV2';
await inst.refreshEventProfileAndCues();
assert.strictEqual(fetches.filter((p) => p.endsWith('/events/EV2')).length, 1, 'a new event is read');
});
await test('a rate-limited cue request holds off every Resi call', async () => {
const inst = fresh();
inst.customerId = 'C';
inst.currentEventId = 'EV1';
inst.cookieHeader = 'livingas1=T';
inst.lastLoginMs = Date.now();
inst.config = { username: 'a@b.c' };
cueStatus = 429;
await assert.rejects(inst.refreshEventProfileAndCues(), /cues failed \(429\)/);
await assert.rejects(inst.ensureAuthenticated(false), /rate-limiting.*retrying in \d+s/);
cueStatus = 200;
});
console.log(`\n${passed} tests passed`);
})().catch((e) => { console.error(e); process.exit(1); });