Files
ProdDash/modules/vista-cues/vista.test.js
T
christianandClaude Opus 5.5 2893316a1a Vista Cues 1.0.0: which Vista 3 cue lists are live, from the network MIDI that fires them
Vista 3 reports nothing about what it is playing — its OSC only takes
commands — so a booth with no lighting operator can't see the lighting
computer. ProPresenter, the tracks rig and Companion fire Vista's cue lists
with notes over macOS network MIDI sessions, and a session sends every note
to every participant, so the module joins those sessions as one more
listener (RTP-MIDI in plain Node, no helper) and hears what Vista hears.

The admin's cue sheet turns notes into Go / release / pause / back / flash
on named lists. Sheets with no release notes use [groups]: a Go replaces its
group's live list, and the first group is the headline — the main scene —
which takes over the look and clears the FX below it. The tile shows the
headline list large with how long ago and who fired it, the other live
lists as tags, recent notes, and which senders are connected.

The module invites each configured sender and keeps re-inviting, accepts
invitations from anyone, and advertises itself over Bonjour on macOS. A
busy port (a config save re-initing before the old sockets close) is
retried rather than failing. Default session port is 5040/5041, clear of
the 5009 session on NA Ingest.

tools/vista-midi-mock.js is a stand-in sender for development;
modules/vista-cues/vista.test.js covers the parser, packet decoding and
real loopback sessions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:15:45 -04:00

372 lines
13 KiB
JavaScript

'use strict';
/**
* Tests for Vista Cues.
*
* node modules/vista-cues/vista.test.js
*
* Plain Node, no dependencies; exits non-zero on the first failure. Covers
* the cue-sheet parser, RTP-MIDI packet decoding, and a real session over
* loopback UDP: a fake sender invites the module's session, plays notes, and
* the server entry's state follows.
*/
const assert = require('assert');
const dgram = require('dgram');
const http = require('http');
const { parseMapping, match, noteFromName } = require('./mapping');
const { parseRtpMidi, describe, Session } = require('./rtpmidi');
let passed = 0;
async function test(name, fn) {
await fn();
passed++;
console.log(` ok ${name}`);
}
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
async function until(fn, ms = 3000) {
const end = Date.now() + ms;
while (Date.now() < end) {
if (fn()) return;
await wait(20);
}
throw new Error('timed out waiting');
}
/* ── a minimal independent sender ─────────────────────────────────────── */
function sessionPkt(cmd, token, ssrc, name) {
const n = name == null ? Buffer.alloc(0) : Buffer.from(`${name}\0`);
const b = Buffer.alloc(16 + n.length);
b.writeUInt16BE(0xffff, 0);
b.write(cmd, 2, 'ascii');
b.writeUInt32BE(2, 4);
b.writeUInt32BE(token, 8);
b.writeUInt32BE(ssrc, 12);
n.copy(b, 16);
return b;
}
function rtp(ssrc, seq, midiBytes, flags = 0) {
const midi = Buffer.from(midiBytes);
const b = Buffer.alloc(13 + midi.length);
b[0] = 0x80;
b[1] = 0x61;
b.writeUInt16BE(seq, 2);
b.writeUInt32BE(1234, 4);
b.writeUInt32BE(ssrc, 8);
b[12] = flags | midi.length;
midi.copy(b, 13);
return b;
}
/** A sender that accepts invitations (like a Mac session) and can play notes. */
async function fakeSender(port, name) {
const ssrc = 0x0badf00d;
const control = dgram.createSocket('udp4');
const data = dgram.createSocket('udp4');
const peers = [];
const syncs = [];
let seq = 100;
const handle = (sock, which) => (buf, r) => {
const cmd = buf.toString('ascii', 2, 4);
if (cmd === 'IN') {
sock.send(sessionPkt('OK', buf.readUInt32BE(8), ssrc, name), r.port, r.address);
if (which === 'data') peers.push({ address: r.address, port: r.port });
} else if (cmd === 'CK') {
syncs.push(buf[8]);
if (buf[8] === 0) {
const out = Buffer.from(buf);
out.writeUInt32BE(ssrc, 4);
out[8] = 1;
data.send(out, r.port, r.address);
}
}
};
control.on('message', handle(control, 'control'));
data.on('message', handle(data, 'data'));
await new Promise((r) => control.bind(port, '127.0.0.1', r));
await new Promise((r) => data.bind(port + 1, '127.0.0.1', r));
return {
peers,
syncs,
note(n, ch = 1) {
for (const p of peers) data.send(rtp(ssrc, seq++, [0x90 | (ch - 1), n, 100, 0x00, n, 0]), p.port, p.address);
},
close() { control.close(); data.close(); },
};
}
function freePort() {
return new Promise((resolve) => {
const s = dgram.createSocket('udp4');
s.bind(0, () => { const p = s.address().port; s.close(() => resolve(p)); });
});
}
/** Two adjacent free UDP ports, as a session needs. */
async function freePair() {
for (let i = 0; i < 20; i++) {
const p = await freePort();
if (p > 65000) continue;
const ok = await new Promise((resolve) => {
const s = dgram.createSocket('udp4');
s.on('error', () => resolve(false));
s.bind(p + 1, () => s.close(() => resolve(true)));
});
if (ok) return p;
}
throw new Error('no free port pair');
}
(async () => {
console.log('mapping');
await test('reads the cue sheet forms', () => {
const { rules, errors } = parseMapping([
'# comment',
'60 = Walk-in',
'C3 - Worship 1',
'61 release Walk-in',
'62\tPause\tWorship 1',
'127 release all',
'ch2:64 = Message # only on channel 2',
'pc5 = Baptism',
'note 66: Go: Song #2',
'67 = "Back Lights"',
'F#2 = Hazer',
].join('\n'));
assert.deepStrictEqual(errors, []);
const brief = rules.map((r) => [r.channel, r.kind, r.number, r.action, r.list]);
assert.deepStrictEqual(brief, [
[null, 'note', 60, 'go', 'Walk-in'],
[null, 'note', 60, 'go', 'Worship 1'],
[null, 'note', 61, 'release', 'Walk-in'],
[null, 'note', 62, 'pause', 'Worship 1'],
[null, 'note', 127, 'releaseAll', ''],
[2, 'note', 64, 'go', 'Message'],
[null, 'program', 5, 'go', 'Baptism'],
[null, 'note', 66, 'go', 'Song #2'],
[null, 'note', 67, 'go', 'Back Lights'],
[null, 'note', 54, 'go', 'Hazer'],
]);
});
await test('middle C naming', () => {
assert.strictEqual(noteFromName('C3', 'C3'), 60);
assert.strictEqual(noteFromName('C4', 'C4'), 60);
assert.strictEqual(noteFromName('C-2', 'C3'), 0);
assert.strictEqual(noteFromName('Bb3', 'C3'), 70);
});
await test('bad lines are reported, not fatal', () => {
const { rules, errors } = parseMapping('hello\n200 = Big\n61 release\nch17:3 = X\n60 = OK');
assert.strictEqual(rules.length, 1);
assert.deepStrictEqual(errors.map((e) => e.line), [1, 2, 3, 4]);
});
await test('groups and flashes', () => {
const { rules, groups, errors } = parseMapping('5 = Loose\n[Scene]\n0 = Pre Service\n8 = Message\n[Color]\n23 = Blue\n30 flash Blinder Flash 1\n[]\n9 = After');
assert.deepStrictEqual(errors, []);
assert.deepStrictEqual(groups, ['Scene', 'Color']);
assert.deepStrictEqual(rules.map((r) => [r.number, r.group, r.action]), [
[5, '', 'go'], [0, 'Scene', 'go'], [8, 'Scene', 'go'], [23, 'Color', 'go'], [30, 'Color', 'flash'], [9, '', 'go'],
]);
});
await test('the NA cue sheet parses cleanly', () => {
const fs = require('fs');
const path = require('path');
const file = path.join(__dirname, '..', '..', '..', 'NA cue sheet.txt');
if (!fs.existsSync(file)) return;
const { rules, errors, groups } = parseMapping(fs.readFileSync(file, 'utf8'));
assert.deepStrictEqual(errors, []);
assert.strictEqual(rules.length, 53); // every note marked TRUE for NA in the sheet
assert.deepStrictEqual(groups, ['Main', 'Color', 'Blinders', 'Build', 'House', 'Lekos']);
assert.strictEqual(new Set(rules.map((r) => r.number)).size, rules.length);
});
await test('matching respects channel and kind', () => {
const { rules } = parseMapping('ch2:64 = Two\n64 = Any\npc64 = Program');
assert.strictEqual(match(rules, { type: 'noteon', channel: 2, note: 64 }).list, 'Two');
assert.strictEqual(match(rules, { type: 'noteon', channel: 1, note: 64 }).list, 'Any');
assert.strictEqual(match(rules, { type: 'program', channel: 1, program: 64 }).list, 'Program');
assert.strictEqual(match(rules, { type: 'noteoff', channel: 1, note: 64 }), null);
});
console.log('rtp-midi');
await test('note on + running-status note off', () => {
const p = parseRtpMidi(rtp(7, 9, [0x90, 60, 100, 0x00, 60, 0]));
assert.strictEqual(p.ssrc, 7);
assert.strictEqual(p.seq, 9);
assert.deepStrictEqual(p.messages.map(describe), [
{ type: 'noteon', channel: 1, note: 60, velocity: 100 },
{ type: 'noteoff', channel: 1, note: 60 },
]);
});
await test('Z flag, multi-byte deltas, program change, sysex in between', () => {
const p = parseRtpMidi(rtp(7, 1, [0x05, 0xc2, 4, 0x81, 0x00, 0xf0, 0x7f, 0x01, 0xf7, 0x00, 0x93, 61, 9], 0x20));
assert.deepStrictEqual(p.messages.map(describe).filter(Boolean), [
{ type: 'program', channel: 3, program: 4 },
{ type: 'noteon', channel: 4, note: 61, velocity: 9 },
]);
});
await test('journal bytes after the command section are ignored', () => {
const b = Buffer.concat([rtp(7, 1, [0x90, 62, 1], 0x40), Buffer.from([0x80, 0x00, 0x05, 0x01, 0x02])]);
assert.deepStrictEqual(parseRtpMidi(b).messages.map(describe), [{ type: 'noteon', channel: 1, note: 62, velocity: 1 }]);
});
console.log('session');
await test('invites a sender, keeps sync, and hears its notes', async () => {
const senderPort = await freePair();
const sender = await fakeSender(senderPort, 'Fake PP');
const port = await freePair();
const s = new Session({ name: 'Test', port });
assert.ok(await s.start());
const heard = [];
s.on('midi', ({ participant, event }) => heard.push([participant.label, event.type, event.note]));
s.invite('127.0.0.1', senderPort, 'ProPresenter');
await until(() => s.list().some((p) => p.connected));
await until(() => sender.syncs.includes(2)); // we answered its count-1 with a count-2
sender.note(60);
await until(() => heard.length >= 2);
assert.deepStrictEqual(heard, [['ProPresenter', 'noteon', 60], ['ProPresenter', 'noteoff', 60]]);
s.stop();
sender.close();
await wait(80);
});
await test('a config save re-inits on the same port straight away', async () => {
const senderPort = await freePair();
const sender = await fakeSender(senderPort, 'Fake PP');
const port = await freePair();
const mod = require('./server');
const config = { sessionPort: port, advertise: false, source1: { host: '127.0.0.1', port: senderPort } };
let handle = mod.init({ config, log: () => {} });
await until(() => handle.health().status === 'ok');
handle.stop();
handle = mod.init({ config, log: () => {} }); // what the shell does on save: stop(), then init()
await until(() => handle.health().status === 'ok');
handle.stop();
sender.close();
await wait(80);
});
await test('a port taken by someone else is reported, then taken once free', async () => {
const port = await freePair();
const squatter = dgram.createSocket('udp4');
await new Promise((r) => squatter.bind(port + 1, r));
const s = new Session({ name: 'Test', port });
let listening = false;
s.on('listening', () => { listening = true; });
assert.strictEqual(await s.start(), false);
assert.match(s.error, /in use/);
squatter.close();
await until(() => listening, 5000);
assert.strictEqual(s.error, '');
s.stop();
});
await test('server entry: notes become live cue lists, over HTTP', async () => {
const senderPort = await freePair();
const sender = await fakeSender(senderPort, 'Fake PP');
const port = await freePair();
const mod = require('./server');
const handle = mod.init({
config: {
sessionPort: port,
advertise: false,
source1Name: 'ProPresenter',
source1: { host: '127.0.0.1', port: senderPort },
cueLists: '60 = Walk-in\n61 = Worship 1\n70 release Walk-in\n127 release all',
},
log: () => {},
});
const routes = mod.routes({});
const server = http.createServer((req, res) => {
const r = routes[`${req.method} ${req.url}`];
if (r) r(req, res); else { res.writeHead(404); res.end(); }
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const get = async (path, method = 'GET') => {
const res = await fetch(`http://127.0.0.1:${server.address().port}${path}`, { method });
return res.json();
};
const state = async () => (await get('/state')).state;
await until(() => sender.peers.length > 0);
await until(() => handle.health().status === 'ok');
sender.note(60);
await wait(100);
sender.note(61);
await wait(100);
let st = await state();
assert.strictEqual(st.current.name, 'Worship 1');
assert.deepStrictEqual(st.active.map((l) => l.name), ['Walk-in']);
assert.strictEqual(st.current.source, 'ProPresenter');
assert.strictEqual(st.sources[0].connected, true);
sender.note(70);
sender.note(99); // unmapped
await wait(150);
st = await state();
assert.deepStrictEqual(st.active, []);
assert.strictEqual(st.log[0].mapped, false);
assert.match(st.log[0].trigger, /Note 99/);
sender.note(127);
await wait(100);
st = await state();
assert.strictEqual(st.current, null);
sender.note(60);
await wait(100);
await get('/clear', 'POST');
st = await state();
assert.strictEqual(st.current, null);
assert.strictEqual(st.log[0].action, 'cleared');
handle.stop();
// Groups: a Go replaces its group's live list; the first group is the headline; flashes stay out.
const h2 = mod.init({
config: {
sessionPort: port, advertise: false, source1: { host: '127.0.0.1', port: senderPort },
cueLists: '[Scene]\n0 = Pre Service\n8 = Message\n[Color]\n22 = Red\n23 = Blue\n30 flash Blinder Flash 1',
},
log: () => {},
});
await until(() => h2.health().status === 'ok');
for (const n of [0, 22, 23, 30]) { sender.note(n); await wait(60); }
await wait(100);
st = await state();
assert.strictEqual(st.current.name, 'Pre Service');
assert.deepStrictEqual(st.active.map((l) => [l.group, l.name]), [['Color', 'Blue']]); // Blue replaced Red
assert.strictEqual(st.log[0].action, 'flash');
sender.note(8); // a new main scene takes over the look…
await wait(100);
st = await state();
assert.strictEqual(st.current.name, 'Message');
assert.deepStrictEqual(st.active, []);
sender.note(22); // …and FX fired after it layer on top again
await wait(100);
st = await state();
assert.deepStrictEqual(st.active.map((l) => l.name), ['Red']);
h2.stop();
server.close();
sender.close();
await wait(80);
});
console.log(`\n${passed} passed`);
process.exit(0);
})().catch((e) => {
console.error(e);
process.exit(1);
});