diff --git a/screen.js b/screen.js index 3cd7952..4055811 100644 --- a/screen.js +++ b/screen.js @@ -2101,4 +2101,14 @@ window.slopsmithViz_drums = createFactory; // slopsmith→feedBack rename: host viz picker looks up `window.feedBackViz_`. window.feedBackViz_drums = window.slopsmithViz_drums; +// Node-only export hook for tests; browsers keep the window.*Viz_drums wiring. +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + noteToMidi, _rgbStr, _validateCustomMapping, _drumTabHitsToNotes, + _applyLanePreset, _getActiveDrumMap, _midiToLaneIdx, _songNoteToLaneIdx, + _midiResolveSaved, DRUM_LANES, PIECE_DEFAULT_MIDI, + matchesArrangement: createFactory.matchesArrangement, + }; +} + })(); diff --git a/tests/screen.test.js b/tests/screen.test.js new file mode 100644 index 0000000..54a025b --- /dev/null +++ b/tests/screen.test.js @@ -0,0 +1,140 @@ +'use strict'; +// Coverage for pure helpers in screen.js: MIDI mapping, lane presets, +// drum-tab hit conversion, custom-mapping validation, arrangement matching. +// Runs under the org reusable CI as `node tests/screen.test.js`. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +function freshPlugin() { + global.window = {}; + global.localStorage = { getItem: () => null, setItem: () => {} }; + global.document = { addEventListener: () => {} }; + const file = path.join(__dirname, '..', 'screen.js'); + delete require.cache[require.resolve(file)]; + return require(file); +} + +test('noteToMidi maps string/fret to a MIDI number (24 semitones per string)', () => { + const mod = freshPlugin(); + assert.equal(mod.noteToMidi(0, 0), 0); + assert.equal(mod.noteToMidi(1, 12), 36); +}); + +test('_rgbStr formats rgb()/rgba() from 0..1 channel floats', () => { + const mod = freshPlugin(); + assert.equal(mod._rgbStr(1, 0, 0), 'rgb(255,0,0)'); + assert.equal(mod._rgbStr(1, 0, 0, 0.5), 'rgba(255,0,0,0.5)'); +}); + +test('_validateCustomMapping keeps only in-range MIDI keys with known lane ids', () => { + const mod = freshPlugin(); + const clean = mod._validateCustomMapping({ '38': 'snare', '200': 'kick', '40': 'nope-lane' }); + assert.deepEqual({ ...clean }, { 38: 'snare' }); // clean has a null prototype +}); + +test('_validateCustomMapping rejects non-objects, arrays, and prototype-poisoning keys', () => { + const mod = freshPlugin(); + assert.equal(mod._validateCustomMapping(null), null); + assert.equal(mod._validateCustomMapping('nope'), null); + assert.equal(mod._validateCustomMapping([1, 2]), null); + // Empty after filtering -> null, not {}. + assert.equal(mod._validateCustomMapping({ '__proto__': 'kick' }), null); + assert.equal(mod._validateCustomMapping({}), null); +}); + +test('_drumTabHitsToNotes converts known piece-ids to {t,s,f} and sorts by time', () => { + const mod = freshPlugin(); + const notes = mod._drumTabHitsToNotes([ + { p: 'snare', t: 1.0, v: 120 }, + { p: 'kick', t: 0.5, v: 80 }, + ]); + assert.equal(notes.length, 2); + assert.equal(notes[0].t, 0.5); + assert.equal(notes[0]._piece, 'kick'); + assert.equal(notes[0].ac, false); // v=80 < 100 + assert.equal(notes[1].ac, true); // v=120 >= 100 +}); + +test('_drumTabHitsToNotes drops unknown piece-ids and non-finite/negative timestamps', () => { + const mod = freshPlugin(); + const notes = mod._drumTabHitsToNotes([ + { p: 'cowbell', t: 1.0 }, // unknown piece + { p: 'snare', t: -1 }, // negative time + { p: 'snare', t: NaN }, // non-finite + { p: 'snare', t: 2.0 }, // kept + ]); + assert.equal(notes.length, 1); + assert.equal(notes[0].t, 2.0); +}); + +test('_drumTabHitsToNotes emits a leading grace note for flams', () => { + const mod = freshPlugin(); + const notes = mod._drumTabHitsToNotes([{ p: 'snare', t: 1.0, v: 100, f: true }]); + assert.equal(notes.length, 2); + const [grace, main] = notes; + assert.equal(grace._noScore, true); + assert.ok(grace.t < main.t); + assert.equal(main.t, 1.0); +}); + +test('_drumTabHitsToNotes ignores a non-array payload', () => { + const mod = freshPlugin(); + assert.deepEqual(mod._drumTabHitsToNotes(null), []); + assert.deepEqual(mod._drumTabHitsToNotes('nope'), []); +}); + +test('lane preset switch rebuilds DRUM_LANES and the default map', () => { + const mod = freshPlugin(); + mod._applyLanePreset('rb4'); + assert.deepEqual(mod.DRUM_LANES.map(l => l.id), ['hihat', 'snare', 'tom1', 'tom3', 'crash', 'ride', 'kick']); + // In rb4, mid-tom notes 45/47 fold into tom1 (no separate tom2 lane). + assert.equal(mod._midiToLaneIdx(45), mod.DRUM_LANES.findIndex(l => l.id === 'tom1')); + + mod._applyLanePreset('phase_shift_8'); + assert.deepEqual(mod.DRUM_LANES.map(l => l.id), + ['hihat', 'snare', 'tom1', 'tom2', 'tom3', 'crash', 'ride', 'kick']); +}); + +test('_applyLanePreset falls back to phase_shift_8 for an unknown preset name', () => { + const mod = freshPlugin(); + mod._applyLanePreset('not-a-real-preset'); + assert.deepEqual(mod.DRUM_LANES.map(l => l.id), + ['hihat', 'snare', 'tom1', 'tom2', 'tom3', 'crash', 'ride', 'kick']); +}); + +test('_midiToLaneIdx/_songNoteToLaneIdx resolve unmapped notes to -1', () => { + const mod = freshPlugin(); + assert.equal(mod._midiToLaneIdx(999), -1); + assert.equal(mod._songNoteToLaneIdx(999), -1); +}); + +test('_midiResolveSaved matches by stored key first, falls back to legacy bare id', () => { + const mod = freshPlugin(); + const sources = [{ id: 'dev1', key: 'webmidi:dev1' }]; + assert.equal(mod._midiResolveSaved('webmidi:dev1', sources), 'webmidi:dev1'); + assert.equal(mod._midiResolveSaved('dev1', sources), 'webmidi:dev1'); + assert.equal(mod._midiResolveSaved('nope', sources), null); +}); + +test('matchesArrangement trusts has_drum_tab regardless of arrangement name', () => { + const mod = freshPlugin(); + assert.equal(mod.matchesArrangement({ has_drum_tab: true, arrangement: 'Lead' }), true); +}); + +test('matchesArrangement matches drum-pattern arrangement names', () => { + const mod = freshPlugin(); + assert.equal(mod.matchesArrangement({ arrangement: 'Drums' }), true); + assert.equal(mod.matchesArrangement({ arrangement: 'Lead Guitar' }), false); +}); + +test('matchesArrangement rejects falsy songInfo', () => { + const mod = freshPlugin(); + assert.equal(mod.matchesArrangement(null), false); +}); + +test('PIECE_DEFAULT_MIDI covers the canonical piece set used by _drumTabHitsToNotes', () => { + const mod = freshPlugin(); + assert.equal(mod.PIECE_DEFAULT_MIDI.kick, 36); + assert.equal(mod.PIECE_DEFAULT_MIDI.snare, 38); +});