Files
steelseries-live-scale/test/live.test.ts
khannurien b8d01e3f59 Wire the Max for Live device to the keyboard
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>
2026-08-15 09:00:42 +00:00

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