import assert from 'assert'; import { splitAddress } from '../src/gamesense'; import { ALL_KEYS, HID } from '../src/hid'; import { BACKGROUND_FRAME_KEY, BACKGROUND_KEYS, buildBlackFrame, buildFrame, buildHandlers, parseColor, } from '../src/lighting'; import { NOTE_KEYS, NOTE_KEY_HIDS, SCALES, keysFor, noteName, parseIntervals, parseRoot, parseScale, pitchClassesFor, } from '../src/scale'; let passed = 0; function test(name: string, fn: () => void): void { try { fn(); passed++; console.log(` ok ${name}`); } catch (err) { console.error(` FAIL ${name}\n ${err instanceof Error ? err.message : err}`); process.exitCode = 1; } } console.log('scale math'); test('root parsing accepts names, accidentals and numbers', () => { assert.strictEqual(parseRoot('C'), 0); assert.strictEqual(parseRoot('c'), 0); assert.strictEqual(parseRoot('F#'), 6); assert.strictEqual(parseRoot('Gb'), 6); assert.strictEqual(parseRoot('Bb'), 10); assert.strictEqual(parseRoot('B'), 11); assert.strictEqual(parseRoot(7), 7); assert.strictEqual(parseRoot('11'), 11); assert.throws(() => parseRoot('H')); assert.throws(() => parseRoot(12)); }); test('scale names normalize', () => { assert.deepStrictEqual(parseScale('major'), [0, 2, 4, 5, 7, 9, 11]); assert.deepStrictEqual(parseScale('Harmonic Minor'), [0, 2, 3, 5, 7, 8, 11]); assert.deepStrictEqual(parseScale('minor_pentatonic'), [0, 3, 5, 7, 10]); assert.throws(() => parseScale('bebop')); }); test('raw interval lists parse', () => { assert.deepStrictEqual(parseIntervals('0,2,4,7,9'), [0, 2, 4, 7, 9]); assert.deepStrictEqual(parseIntervals([0, 3, 7]), [0, 3, 7]); assert.throws(() => parseIntervals('')); assert.throws(() => parseIntervals('0,x')); }); test('C major lights the seven white keys', () => { const pcs = pitchClassesFor(0, SCALES.major); assert.deepStrictEqual(pcs, [0, 2, 4, 5, 7, 9, 11]); assert.deepStrictEqual(pcs.map((pc) => noteName(pc)), ['C', 'D', 'E', 'F', 'G', 'A', 'B']); // Home row plus the octave key. assert.deepStrictEqual(keysFor(pcs), ['a', 's', 'd', 'f', 'g', 'h', 'j', 'k']); }); test('F# major wraps around the octave correctly', () => { const pcs = pitchClassesFor(6, SCALES.major); assert.deepStrictEqual(pcs, [1, 3, 6, 8, 10, 11, 5].sort((a, b) => a - b)); assert.deepStrictEqual( pcs.map((pc) => noteName(pc)).sort(), ['A#', 'B', 'C#', 'D#', 'F', 'F#', 'G#'] ); }); test('A minor is the same pitch classes as C major', () => { assert.deepStrictEqual( pitchClassesFor(9, SCALES.minor), pitchClassesFor(0, SCALES.major) ); }); test('chromatic lights every note key', () => { const pcs = pitchClassesFor(0, SCALES.chromatic); assert.strictEqual(pcs.length, 12); assert.strictEqual(keysFor(pcs).length, NOTE_KEYS.length); }); console.log('key mapping'); test('note keys match the Ableton layout table in the brief', () => { const expected = [ ['a', 0], ['w', 1], ['s', 2], ['e', 3], ['d', 4], ['f', 5], ['t', 6], ['g', 7], ['y', 8], ['h', 9], ['u', 10], ['j', 11], ['k', 0], ]; assert.deepStrictEqual( NOTE_KEYS.map((n) => [n.key, n.pitchClass]), expected ); }); test('HID codes match the USB HID keyboard page', () => { const expected: Record = { a: 0x04, w: 0x1a, s: 0x16, e: 0x08, d: 0x07, f: 0x09, t: 0x17, g: 0x0a, y: 0x1c, h: 0x0b, u: 0x18, j: 0x0d, k: 0x0e, }; for (const note of NOTE_KEYS) { assert.strictEqual(note.hid, expected[note.key], `HID for ${note.key}`); } }); test('HID table has no duplicate codes', () => { assert.strictEqual(new Set(ALL_KEYS).size, ALL_KEYS.length); }); test('frame keys are unique', () => { const keys = NOTE_KEYS.map((n) => n.frameKey); assert.strictEqual(new Set(keys).size, keys.length); assert.ok(!keys.includes(BACKGROUND_FRAME_KEY)); }); console.log('handlers and frames'); test('background zone covers every key except the note keys', () => { assert.strictEqual(BACKGROUND_KEYS.length, ALL_KEYS.length - NOTE_KEY_HIDS.length); for (const hid of NOTE_KEY_HIDS) { assert.ok(!BACKGROUND_KEYS.includes(hid), `note key ${hid} leaked into background`); } assert.ok(BACKGROUND_KEYS.includes(HID.space)); assert.ok(BACKGROUND_KEYS.includes(HID.z), 'octave-down key is background'); }); test('handlers: one background + one per note key, all context-color', () => { const handlers = buildHandlers(); assert.strictEqual(handlers.length, 1 + NOTE_KEYS.length); for (const h of handlers) { assert.strictEqual(h['device-type'], 'rgb-per-key-zones'); assert.strictEqual(h.mode, 'context-color'); assert.ok(Array.isArray(h['custom-zone-keys'])); assert.ok(h['custom-zone-keys'].length > 0); assert.strictEqual(typeof h['context-frame-key'], 'string'); } // Every context-frame-key a handler asks for must exist in a frame. const frame = buildFrame({ pitchClasses: [0, 4, 7], root: 0 }); for (const h of handlers) { assert.ok( h['context-frame-key'] in frame, `frame is missing ${h['context-frame-key']}` ); } }); test('frame colors the root, the scale and the rest distinctly', () => { const colors = { background: '#0a0a0f', scale: '#00b4ff', root: '#ff5a00', off: '#000000', }; const frame = buildFrame({ pitchClasses: [0, 4, 7], root: 0, colors }); assert.deepStrictEqual(frame[BACKGROUND_FRAME_KEY], { red: 10, green: 10, blue: 15 }); assert.deepStrictEqual(frame['note-a'], { red: 255, green: 90, blue: 0 }); // C = root assert.deepStrictEqual(frame['note-d'], { red: 0, green: 180, blue: 255 }); // E assert.deepStrictEqual(frame['note-g'], { red: 0, green: 180, blue: 255 }); // G assert.deepStrictEqual(frame['note-w'], { red: 0, green: 0, blue: 0 }); // C# unlit // The octave key follows C, but is not treated as the root. assert.deepStrictEqual(frame['note-k'], { red: 255, green: 90, blue: 0 }); }); test('frame accepts a Set and a null root', () => { const frame = buildFrame({ pitchClasses: new Set([2, 5]), root: null }); assert.deepStrictEqual(frame['note-s'], frame['note-f']); }); test('black frame blanks everything', () => { const frame = buildBlackFrame(); for (const value of Object.values(frame)) { assert.deepStrictEqual(value, { red: 0, green: 0, blue: 0 }); } }); console.log('parsing helpers'); test('color parsing handles #rgb, rrggbb and objects', () => { assert.deepStrictEqual(parseColor('#00b4ff'), { red: 0, green: 180, blue: 255 }); assert.deepStrictEqual(parseColor('00B4FF'), { red: 0, green: 180, blue: 255 }); assert.deepStrictEqual(parseColor('#f0a'), { red: 255, green: 0, blue: 170 }); assert.deepStrictEqual(parseColor({ red: 300, green: -5, blue: 1.4 }), { red: 255, green: 0, blue: 1, }); assert.throws(() => parseColor('#gggggg')); assert.throws(() => parseColor('blue')); }); test('address parsing', () => { assert.deepStrictEqual(splitAddress('127.0.0.1:52384'), { host: '127.0.0.1', port: 52384, }); assert.deepStrictEqual(splitAddress('http://127.0.0.1:1'), { host: '127.0.0.1', port: 1, }); assert.throws(() => splitAddress('127.0.0.1')); }); console.log(`\n${passed} passed${process.exitCode ? ', with failures' : ''}`);