Step 3 of the brief: the device now holds a ScaleLighting and pushes every
scale Live reports to the Apex 7, instead of only printing it.
The controller is created lazily and injected (start(max, { createLights })),
so the device stays testable with no Max and no hardware. Around the call:
lighting work is serialized on one queue so a burst of LOM changes cannot
interleave two POSTs, an unchanged scale is never re-sent, and a GameSense
that is missing or restarted is printed once rather than on every scale
change — the scale keeps being tracked so a later retry lands on the right one.
New messages: lights 0|1, retry, address <host:port>, shutdown. The patcher
gets boxes for them plus closebang -> shutdown, and the launcher blanks the
board on SIGTERM/SIGINT, so deleting the device hands lighting back to GG.
The test runner now supports async tests, since the lighting half is async.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
558 lines
19 KiB
TypeScript
558 lines
19 KiB
TypeScript
import assert from 'assert';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
import {
|
|
LIVE_SCALE_INTERVALS,
|
|
describeScale,
|
|
intervalsForScaleName,
|
|
keyDiagram,
|
|
keysOf,
|
|
normalizeScaleName,
|
|
pitchClassesOf,
|
|
resolveScale,
|
|
sameScale,
|
|
summarizeScale,
|
|
toBoolean,
|
|
toIntervals,
|
|
toPitchClass,
|
|
toScaleSource,
|
|
} from '../src/live';
|
|
import type { LiveScale } from '../src/live';
|
|
import { start } from '../src/max/device';
|
|
import type { Device, Lights, MaxApi } from '../src/max/device';
|
|
import { SCALES } from '../src/scale';
|
|
import type { PitchClass } from '../src/scale';
|
|
|
|
/**
|
|
* Tests run in order on one promise chain: the device's lighting work is async,
|
|
* so a test may need to `await device.idle()` before asserting.
|
|
*/
|
|
let passed = 0;
|
|
let chain: Promise<void> = Promise.resolve();
|
|
|
|
function test(name: string, fn: () => void | Promise<void>): void {
|
|
chain = chain.then(async () => {
|
|
try {
|
|
await fn();
|
|
passed++;
|
|
console.log(` ok ${name}`);
|
|
} catch (err) {
|
|
console.error(` FAIL ${name}\n ${err instanceof Error ? err.message : err}`);
|
|
process.exitCode = 1;
|
|
}
|
|
});
|
|
}
|
|
|
|
function section(name: string): void {
|
|
chain = chain.then(() => console.log(name));
|
|
}
|
|
|
|
const REPO = path.join(__dirname, '..', '..');
|
|
|
|
section('Live scale names');
|
|
|
|
test('Live scale names resolve, however they are spelled', () => {
|
|
assert.deepStrictEqual(intervalsForScaleName('Major'), [0, 2, 4, 5, 7, 9, 11]);
|
|
assert.deepStrictEqual(intervalsForScaleName('major'), [0, 2, 4, 5, 7, 9, 11]);
|
|
assert.deepStrictEqual(intervalsForScaleName('Whole Tone'), [0, 2, 4, 6, 8, 10]);
|
|
assert.deepStrictEqual(intervalsForScaleName('whole_tone'), [0, 2, 4, 6, 8, 10]);
|
|
assert.deepStrictEqual(intervalsForScaleName('Half-whole Dim.'), [0, 1, 3, 4, 6, 7, 9, 10]);
|
|
assert.deepStrictEqual(intervalsForScaleName('Minor Pentatonic'), [0, 3, 5, 7, 10]);
|
|
assert.strictEqual(intervalsForScaleName('Bebop Dominant'), null);
|
|
assert.strictEqual(intervalsForScaleName(''), null);
|
|
});
|
|
|
|
test('aliases point at the same intervals', () => {
|
|
assert.deepStrictEqual(intervalsForScaleName('Aeolian'), LIVE_SCALE_INTERVALS.Minor);
|
|
assert.deepStrictEqual(intervalsForScaleName('Ionian'), LIVE_SCALE_INTERVALS.Major);
|
|
assert.deepStrictEqual(intervalsForScaleName('Blues'), LIVE_SCALE_INTERVALS['Minor Blues']);
|
|
});
|
|
|
|
test('the fallback table agrees with the CLI presets', () => {
|
|
assert.deepStrictEqual(intervalsForScaleName('Major'), SCALES.major);
|
|
assert.deepStrictEqual(intervalsForScaleName('Minor'), SCALES.minor);
|
|
assert.deepStrictEqual(intervalsForScaleName('Dorian'), SCALES.dorian);
|
|
assert.deepStrictEqual(intervalsForScaleName('Harmonic Minor'), SCALES['harmonic-minor']);
|
|
assert.deepStrictEqual(intervalsForScaleName('Minor Blues'), SCALES.blues);
|
|
assert.deepStrictEqual(intervalsForScaleName('Chromatic'), SCALES.chromatic);
|
|
});
|
|
|
|
test('every scale in the table is a sane interval set', () => {
|
|
for (const [name, intervals] of Object.entries(LIVE_SCALE_INTERVALS)) {
|
|
assert.ok(intervals.length >= 5, `${name} is suspiciously short`);
|
|
assert.strictEqual(intervals[0], 0, `${name} does not start on the root`);
|
|
assert.strictEqual(new Set(intervals).size, intervals.length, `${name} has duplicates`);
|
|
for (const i of intervals) {
|
|
assert.ok(Number.isInteger(i) && i >= 0 && i < 12, `${name} has a bad interval ${i}`);
|
|
}
|
|
const ascending = [...intervals].sort((a, b) => a - b);
|
|
assert.deepStrictEqual(intervals, ascending, `${name} is not ascending`);
|
|
}
|
|
});
|
|
|
|
test('name normalization collapses Live punctuation', () => {
|
|
assert.strictEqual(normalizeScaleName(' Half-whole Dim. '), 'half whole dim');
|
|
assert.strictEqual(normalizeScaleName('Major_Pentatonic'), 'major pentatonic');
|
|
assert.strictEqual(normalizeScaleName('Dorian #4'), 'dorian #4');
|
|
});
|
|
|
|
section('coercion of Max atoms');
|
|
|
|
test('atoms coerce the way Max sends them', () => {
|
|
assert.strictEqual(toPitchClass(0), 0);
|
|
assert.strictEqual(toPitchClass(7.0), 7);
|
|
assert.strictEqual(toPitchClass('11'), 11);
|
|
assert.strictEqual(toPitchClass('F#'), 6);
|
|
assert.throws(() => toPitchClass(12));
|
|
|
|
assert.deepStrictEqual(toIntervals([0, 2, 4, 5, 7, 9, 11]), [0, 2, 4, 5, 7, 9, 11]);
|
|
assert.deepStrictEqual(toIntervals(['0', '3', '7']), [0, 3, 7]);
|
|
assert.throws(() => toIntervals([]));
|
|
|
|
assert.strictEqual(toBoolean(1), true);
|
|
assert.strictEqual(toBoolean(0), false);
|
|
assert.strictEqual(toBoolean('0'), false);
|
|
assert.strictEqual(toBoolean('off'), false);
|
|
assert.strictEqual(toBoolean(undefined), true);
|
|
assert.strictEqual(toBoolean(undefined, false), false);
|
|
|
|
assert.strictEqual(toScaleSource('song'), 'song');
|
|
assert.strictEqual(toScaleSource('Clip'), 'clip');
|
|
assert.throws(() => toScaleSource('track'));
|
|
});
|
|
|
|
section('resolving what Live reports');
|
|
|
|
test('intervals from Live win over the name', () => {
|
|
const scale = resolveScale({
|
|
root: 2,
|
|
name: 'Major',
|
|
// A user-edited scale in Live 12.1 keeps the name but changes the notes.
|
|
intervals: [0, 2, 4, 5, 7, 9, 10],
|
|
scaleMode: true,
|
|
source: 'song',
|
|
});
|
|
assert.deepStrictEqual(scale.intervals, [0, 2, 4, 5, 7, 9, 10]);
|
|
});
|
|
|
|
test('a missing scale_intervals falls back to the name (pre-12.1 Live)', () => {
|
|
const scale = resolveScale({ root: 9, name: 'Dorian' });
|
|
assert.deepStrictEqual(scale.intervals, [0, 2, 3, 5, 7, 9, 10]);
|
|
assert.strictEqual(scale.source, 'song');
|
|
assert.strictEqual(scale.scaleMode, true);
|
|
});
|
|
|
|
test('unresolvable input explains itself', () => {
|
|
assert.throws(() => resolveScale({}), /root note/);
|
|
assert.throws(() => resolveScale({ root: 0 }), /neither/);
|
|
assert.throws(() => resolveScale({ root: 0, name: 'Bebop' }), /not a scale we know/);
|
|
});
|
|
|
|
test('scale equality ignores nothing that matters', () => {
|
|
const base: LiveScale = {
|
|
root: 0,
|
|
intervals: [0, 2, 4, 5, 7, 9, 11],
|
|
name: 'Major',
|
|
scaleMode: true,
|
|
source: 'song',
|
|
};
|
|
assert.ok(sameScale(base, { ...base }));
|
|
assert.ok(!sameScale(base, { ...base, root: 1 }));
|
|
assert.ok(!sameScale(base, { ...base, source: 'clip' }));
|
|
assert.ok(!sameScale(base, { ...base, scaleMode: false }));
|
|
assert.ok(!sameScale(base, { ...base, intervals: [0, 2, 4, 5, 7, 9, 10] }));
|
|
assert.ok(!sameScale(base, null));
|
|
assert.ok(sameScale(null, null));
|
|
});
|
|
|
|
section('reporting');
|
|
|
|
test('C major from the LOM lights the home row', () => {
|
|
const scale = resolveScale({ root: 0, name: 'Major', intervals: SCALES.major });
|
|
assert.deepStrictEqual(pitchClassesOf(scale), [0, 2, 4, 5, 7, 9, 11]);
|
|
assert.deepStrictEqual(keysOf(scale), ['A', 'S', 'D', 'F', 'G', 'H', 'J', 'K']);
|
|
assert.match(summarizeScale(scale), /^C Major \(song\) — A S D F G H J K$/);
|
|
});
|
|
|
|
test('the key diagram lines up with the mapping table in the brief', () => {
|
|
const [header, marks] = keyDiagram(pitchClassesOf(
|
|
resolveScale({ root: 0, name: 'Major', intervals: SCALES.major })
|
|
));
|
|
assert.strictEqual(header, 'A W S E D F T G Y H U J K');
|
|
assert.strictEqual(marks, '* . * . * * . * . * . * *');
|
|
assert.strictEqual(header.length, marks.length);
|
|
});
|
|
|
|
test('F# major marks the black keys', () => {
|
|
const scale = resolveScale({ root: 6, name: 'Major', intervals: SCALES.major });
|
|
assert.deepStrictEqual(keysOf(scale), ['W', 'E', 'F', 'T', 'Y', 'U', 'J']);
|
|
});
|
|
|
|
test('describeScale flags scale mode being off', () => {
|
|
const on = describeScale(resolveScale({ root: 0, name: 'Major', scaleMode: true }));
|
|
const off = describeScale(resolveScale({ root: 0, name: 'Major', scaleMode: false }));
|
|
assert.ok(!on[0].includes('OFF'));
|
|
assert.ok(off[0].includes('OFF'));
|
|
assert.strictEqual(on.length, 6);
|
|
});
|
|
|
|
section('the Node for Max device');
|
|
|
|
interface FakeMax extends MaxApi {
|
|
handlers: Map<string, (...args: any[]) => void>;
|
|
posts: string[];
|
|
outlets: unknown[][];
|
|
send(name: string, ...args: unknown[]): void;
|
|
}
|
|
|
|
function fakeMax(): FakeMax {
|
|
const handlers = new Map<string, (...args: any[]) => void>();
|
|
const posts: string[] = [];
|
|
const outlets: unknown[][] = [];
|
|
return {
|
|
handlers,
|
|
posts,
|
|
outlets,
|
|
post: (...args: unknown[]) => posts.push(args.map((a) => String(a)).join(' ')),
|
|
outlet: (...args: unknown[]) => outlets.push(args),
|
|
addHandler: (name, fn) => handlers.set(name, fn),
|
|
send(name, ...args) {
|
|
const handler = handlers.get(name);
|
|
if (!handler) throw new Error(`no handler for ${name}`);
|
|
handler(...args);
|
|
},
|
|
};
|
|
}
|
|
|
|
/** A stand-in for ScaleLighting: records what the keyboard was asked to do. */
|
|
interface FakeLights extends Omit<Lights, 'isStarted'> {
|
|
isStarted: boolean;
|
|
starts: number;
|
|
stops: number;
|
|
frames: { root: PitchClass; intervals: readonly number[] }[];
|
|
/** When set, every call rejects with it — a GameSense that is not there. */
|
|
fail: string | null;
|
|
}
|
|
|
|
function fakeLights(): FakeLights {
|
|
const lights: FakeLights = {
|
|
isStarted: false,
|
|
starts: 0,
|
|
stops: 0,
|
|
frames: [],
|
|
fail: null,
|
|
async start() {
|
|
if (lights.fail) throw new Error(lights.fail);
|
|
lights.starts++;
|
|
lights.isStarted = true;
|
|
},
|
|
async showScale(root, intervals) {
|
|
if (lights.fail) throw new Error(lights.fail);
|
|
lights.frames.push({ root, intervals });
|
|
return {};
|
|
},
|
|
async stop() {
|
|
lights.stops++;
|
|
lights.isStarted = false;
|
|
},
|
|
};
|
|
return lights;
|
|
}
|
|
|
|
/** The device, never touching real hardware. */
|
|
function startDevice(max: FakeMax, lights: FakeLights = fakeLights()): Device {
|
|
return start(max, { createLights: () => lights });
|
|
}
|
|
|
|
/** What max/scale-observer.js emits for one scale. */
|
|
function observe(max: FakeMax, fields: Record<string, unknown[]>): void {
|
|
max.send('reset');
|
|
for (const [name, args] of Object.entries(fields)) max.send(name, ...args);
|
|
max.send('commit');
|
|
}
|
|
|
|
test('the device registers every message the observer sends', () => {
|
|
const max = fakeMax();
|
|
startDevice(max);
|
|
for (const name of ['reset', 'source', 'root', 'mode', 'name', 'intervals', 'commit']) {
|
|
assert.ok(max.handlers.has(name), `missing handler: ${name}`);
|
|
}
|
|
assert.ok(max.posts.some((p) => p.includes('ready')));
|
|
});
|
|
|
|
test('a burst from the observer resolves to a scale', () => {
|
|
const max = fakeMax();
|
|
const device = startDevice(max);
|
|
observe(max, {
|
|
source: ['song'],
|
|
root: [2],
|
|
mode: [1],
|
|
name: ['Dorian'],
|
|
intervals: [0, 2, 3, 5, 7, 9, 10],
|
|
});
|
|
|
|
assert.ok(device.scale);
|
|
assert.strictEqual(device.scale.root, 2);
|
|
assert.strictEqual(device.scale.name, 'Dorian');
|
|
assert.deepStrictEqual(device.pending, {}, 'commit should clear the pending fields');
|
|
|
|
// D dorian is the white keys, so the notes outlet is what pins the root down.
|
|
const notes = max.outlets.find((o) => o[0] === 'notes');
|
|
assert.deepStrictEqual(notes, ['notes', 'C', 'D', 'E', 'F', 'G', 'A', 'B']);
|
|
const keys = max.outlets.find((o) => o[0] === 'keys');
|
|
assert.deepStrictEqual(keys, ['keys', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K']);
|
|
assert.ok(max.posts.some((p) => p.includes('D Dorian')));
|
|
});
|
|
|
|
test('a multi-word scale name survives being split into atoms', () => {
|
|
const max = fakeMax();
|
|
const device = startDevice(max);
|
|
observe(max, { root: [0], name: ['Whole', 'Tone'] });
|
|
assert.strictEqual(device.scale?.name, 'Whole Tone');
|
|
assert.deepStrictEqual(device.scale?.intervals, [0, 2, 4, 6, 8, 10]);
|
|
});
|
|
|
|
test('an unchanged scale is not re-reported, a changed one is', () => {
|
|
const max = fakeMax();
|
|
startDevice(max);
|
|
const fields = { root: [0], name: ['Major'], intervals: [0, 2, 4, 5, 7, 9, 11] };
|
|
|
|
observe(max, fields);
|
|
const afterFirst = max.posts.length;
|
|
observe(max, fields);
|
|
assert.strictEqual(max.posts.length, afterFirst, 'identical scale re-printed');
|
|
|
|
observe(max, { ...fields, root: [5] });
|
|
assert.ok(max.posts.length > afterFirst, 'changed scale not printed');
|
|
});
|
|
|
|
test('verbose reports every commit', () => {
|
|
const max = fakeMax();
|
|
startDevice(max);
|
|
const fields = { root: [0], name: ['Major'] };
|
|
|
|
observe(max, fields);
|
|
max.send('verbose', 1);
|
|
const before = max.posts.length;
|
|
observe(max, fields);
|
|
assert.ok(max.posts.length > before);
|
|
});
|
|
|
|
test('garbage from a message box is reported, not thrown', () => {
|
|
const max = fakeMax();
|
|
const device = startDevice(max);
|
|
|
|
max.send('root', 99);
|
|
max.send('source', 'track');
|
|
max.send('intervals', 'x');
|
|
max.send('commit');
|
|
|
|
assert.strictEqual(device.scale, null);
|
|
assert.ok(max.posts.some((p) => p.includes('root:')));
|
|
assert.ok(max.posts.some((p) => p.includes('source:')));
|
|
assert.ok(max.posts.some((p) => p.includes('intervals:')));
|
|
assert.ok(max.posts.some((p) => p.includes('commit:')));
|
|
});
|
|
|
|
test('status prints the current scale, or says there is none', () => {
|
|
const max = fakeMax();
|
|
startDevice(max);
|
|
max.send('status');
|
|
assert.ok(max.posts.some((p) => p.includes('no scale yet')));
|
|
|
|
observe(max, { root: [0], name: ['Major'] });
|
|
const before = max.posts.length;
|
|
max.send('status');
|
|
assert.ok(max.posts.length > before);
|
|
});
|
|
|
|
section('driving the keyboard');
|
|
|
|
const C_MAJOR = { root: [0], name: ['Major'], intervals: [0, 2, 4, 5, 7, 9, 11] };
|
|
|
|
test('a commit from Live reaches the keyboard', async () => {
|
|
const max = fakeMax();
|
|
const lights = fakeLights();
|
|
const device = startDevice(max, lights);
|
|
|
|
observe(max, { source: ['song'], root: [2], name: ['Dorian'], intervals: [0, 2, 3, 5, 7, 9, 10] });
|
|
await device.idle();
|
|
|
|
assert.strictEqual(lights.starts, 1, 'GameSense should be started once, lazily');
|
|
assert.deepStrictEqual(lights.frames, [{ root: 2, intervals: [0, 2, 3, 5, 7, 9, 10] }]);
|
|
assert.ok(max.posts.some((p) => p.includes('lighting on')));
|
|
assert.ok(max.outlets.some((o) => o[0] === 'lighting' && o[1] === 1));
|
|
});
|
|
|
|
test('an unchanged scale is not re-sent, a changed one is', async () => {
|
|
const max = fakeMax();
|
|
const lights = fakeLights();
|
|
const device = startDevice(max, lights);
|
|
|
|
observe(max, C_MAJOR);
|
|
observe(max, C_MAJOR);
|
|
await device.idle();
|
|
assert.strictEqual(lights.frames.length, 1, 'the same scale was pushed twice');
|
|
assert.strictEqual(lights.starts, 1, 'GameSense was re-registered');
|
|
|
|
observe(max, { ...C_MAJOR, root: [5] });
|
|
await device.idle();
|
|
assert.deepStrictEqual(lights.frames.map((f) => f.root), [0, 5]);
|
|
});
|
|
|
|
test('lights 0 hands the board back, lights 1 takes it again', async () => {
|
|
const max = fakeMax();
|
|
const lights = fakeLights();
|
|
const device = startDevice(max, lights);
|
|
|
|
observe(max, C_MAJOR);
|
|
await device.idle();
|
|
|
|
max.send('lights', 0);
|
|
await device.idle();
|
|
assert.strictEqual(lights.stops, 1);
|
|
assert.strictEqual(lights.isStarted, false);
|
|
assert.strictEqual(device.lightsEnabled, false);
|
|
assert.ok(max.outlets.some((o) => o[0] === 'lighting' && o[1] === 0));
|
|
|
|
// Live keeps changing scale while the lighting is off; nothing is sent...
|
|
observe(max, { ...C_MAJOR, root: [5] });
|
|
await device.idle();
|
|
assert.strictEqual(lights.frames.length, 1);
|
|
assert.ok(device.scale, 'the scale should still be tracked');
|
|
|
|
// ...but turning it back on catches the board up with the current scale.
|
|
max.send('lights', 1);
|
|
await device.idle();
|
|
assert.strictEqual(lights.starts, 2);
|
|
assert.deepStrictEqual(lights.frames.map((f) => f.root), [0, 5]);
|
|
});
|
|
|
|
test('a GameSense failure is reported once, and retry recovers', async () => {
|
|
const max = fakeMax();
|
|
const lights = fakeLights();
|
|
lights.fail = 'coreProps.json not found. Is SteelSeries GG running?';
|
|
const device = startDevice(max, lights);
|
|
|
|
observe(max, C_MAJOR);
|
|
await device.idle();
|
|
const complaints = () => max.posts.filter((p) => p.includes('coreProps.json')).length;
|
|
assert.strictEqual(complaints(), 1);
|
|
assert.ok(max.posts.some((p) => p.includes('retry')), 'no hint about how to recover');
|
|
|
|
// The same failure on every commit must not fill the Max console.
|
|
observe(max, { ...C_MAJOR, root: [5] });
|
|
observe(max, C_MAJOR);
|
|
await device.idle();
|
|
assert.strictEqual(complaints(), 1);
|
|
assert.ok(device.scale, 'a dead GameSense must not stop the device tracking Live');
|
|
|
|
lights.fail = null;
|
|
max.send('retry');
|
|
await device.idle();
|
|
assert.deepStrictEqual(lights.frames, [{ root: 0, intervals: C_MAJOR.intervals }]);
|
|
assert.ok(max.posts.some((p) => p.includes('recovered')));
|
|
});
|
|
|
|
test('address re-points the controller and re-sends', async () => {
|
|
const max = fakeMax();
|
|
const lights = fakeLights();
|
|
const addresses: (string | undefined)[] = [];
|
|
const device = start(max, {
|
|
createLights: (opts) => {
|
|
addresses.push(opts.address);
|
|
return lights;
|
|
},
|
|
});
|
|
|
|
observe(max, C_MAJOR);
|
|
await device.idle();
|
|
max.send('address', '127.0.0.1:51000');
|
|
await device.idle();
|
|
|
|
assert.deepStrictEqual(addresses, [undefined, '127.0.0.1:51000']);
|
|
assert.strictEqual(lights.stops, 1, 'the old registration should be removed');
|
|
assert.strictEqual(lights.frames.length, 2, 'the scale should be re-sent');
|
|
});
|
|
|
|
test('shutdown blanks the board', async () => {
|
|
const max = fakeMax();
|
|
const lights = fakeLights();
|
|
const device = startDevice(max, lights);
|
|
|
|
observe(max, C_MAJOR);
|
|
await device.idle();
|
|
await device.shutdown();
|
|
|
|
assert.strictEqual(lights.stops, 1);
|
|
assert.strictEqual(lights.isStarted, false);
|
|
assert.strictEqual(device.lights, null);
|
|
});
|
|
|
|
test('status says whether the board is being driven', async () => {
|
|
const max = fakeMax();
|
|
const device = startDevice(max);
|
|
|
|
observe(max, C_MAJOR);
|
|
await device.idle();
|
|
max.send('status');
|
|
assert.ok(max.posts.some((p) => p.endsWith('lighting on')));
|
|
|
|
max.send('lights', 0);
|
|
await device.idle();
|
|
max.send('status');
|
|
assert.ok(max.posts.some((p) => p.endsWith('lighting off')));
|
|
});
|
|
|
|
section('Max patcher');
|
|
|
|
test('scale-lighting.maxpat is valid JSON with the objects we wired', () => {
|
|
const raw = fs.readFileSync(path.join(REPO, 'max', 'scale-lighting.maxpat'), 'utf8');
|
|
const patch = JSON.parse(raw) as {
|
|
patcher: {
|
|
boxes: { box: { id: string; text?: string } }[];
|
|
lines: { patchline: { source: [string, number]; destination: [string, number] } }[];
|
|
};
|
|
};
|
|
|
|
const boxes = patch.patcher.boxes.map((b) => b.box);
|
|
const texts = boxes.map((b) => b.text ?? '');
|
|
const expectedTexts = [
|
|
'live.thisdevice',
|
|
'js scale-observer.js',
|
|
'midiin',
|
|
'midiout',
|
|
// Step 3: the lighting controls, and blanking the board on device deletion.
|
|
'lights 1',
|
|
'lights 0',
|
|
'retry',
|
|
'closebang',
|
|
'shutdown',
|
|
];
|
|
for (const expected of expectedTexts) {
|
|
assert.ok(texts.includes(expected), `patcher is missing ${expected}`);
|
|
}
|
|
assert.ok(
|
|
texts.some((t) => t.startsWith('node.script scale-device.js')),
|
|
'patcher is missing node.script'
|
|
);
|
|
|
|
// Every patch cord must point at a box that exists.
|
|
const ids = new Set(boxes.map((b) => b.id));
|
|
for (const { patchline } of patch.patcher.lines) {
|
|
assert.ok(ids.has(patchline.source[0]), `dangling source ${patchline.source[0]}`);
|
|
assert.ok(ids.has(patchline.destination[0]), `dangling destination ${patchline.destination[0]}`);
|
|
}
|
|
});
|
|
|
|
test('the scripts the patcher names exist next to it', () => {
|
|
for (const file of ['scale-observer.js', 'scale-device.js']) {
|
|
assert.ok(fs.existsSync(path.join(REPO, 'max', file)), `max/${file} is missing`);
|
|
}
|
|
});
|
|
|
|
void chain.then(() => {
|
|
console.log(`\n${passed} passed${process.exitCode ? ', with failures' : ''}`);
|
|
});
|