Files
ProdDash/modules/vista-cues/vista.test.js
T

372 lines
13 KiB
JavaScript
Raw Normal View History

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