diff --git a/screen.js b/screen.js index a30d9d6..cd3cb81 100644 --- a/screen.js +++ b/screen.js @@ -4041,4 +4041,15 @@ try { // so all `let` bindings the function touches (e.g. _followerAudio) are // past their temporal dead zone by the time the function executes. if (FOLLOWER) bootFollowerMode(); + + // Node-only export hook for tests. Placed last so every `let`/`const` + // binding referenced by the exported functions has already run its + // initializer; does not affect the browser (module is undefined there). + if (typeof module !== 'undefined' && module.exports) { + module.exports = { + getWsUrl, resolveArrIndex, getDefaultArrangements, + panelToPrefs, migratePanelPrefs, _ctlRange, + _setArrangementsForTest(next) { arrangements = next; }, + }; + } })(); diff --git a/tests/screen.test.js b/tests/screen.test.js new file mode 100644 index 0000000..38bd0af --- /dev/null +++ b/tests/screen.test.js @@ -0,0 +1,161 @@ +'use strict'; +// Coverage for pure helpers in screen.js: WS URL building, arrangement +// resolution/defaulting, panel-prefs snapshot/migration, range clamping. +// 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 makeLocalStorage() { + const store = new Map(); + return { + getItem: (k) => (store.has(k) ? store.get(k) : null), + setItem: (k, v) => store.set(k, String(v)), + removeItem: (k) => store.delete(k), + }; +} + +function freshPlugin({ search = '', protocol = 'http:' } = {}) { + const location = { search, host: 'localhost:8420', protocol }; + global.window = { location, addEventListener: () => {} }; + global.document = { + getElementById: () => null, + addEventListener: () => {}, + body: { appendChild: () => {} }, + createElement: () => ({ + style: {}, classList: { add() {}, remove() {} }, + addEventListener() {}, appendChild() {}, setAttribute() {}, + }), + readyState: 'loading', + }; + global.localStorage = makeLocalStorage(); + global.location = location; + const file = path.join(__dirname, '..', 'screen.js'); + delete require.cache[require.resolve(file)]; + return require(file); +} + +test('getWsUrl decodes percent-encoded filenames and builds a ws:// URL', () => { + const { getWsUrl } = freshPlugin(); + assert.equal( + getWsUrl('sloppak%2Fperfouts.sloppak', 0), + 'ws://localhost:8420/ws/highway/sloppak/perfouts.sloppak?arrangement=0'); +}); + +test('getWsUrl omits the arrangement param when undefined', () => { + const { getWsUrl } = freshPlugin(); + assert.equal(getWsUrl('song.sloppak'), 'ws://localhost:8420/ws/highway/song.sloppak'); +}); + +test('getWsUrl uses wss:// under https', () => { + const { getWsUrl } = freshPlugin({ protocol: 'https:' }); + assert.match(getWsUrl('a.sloppak', 1), /^wss:\/\//); +}); + +test('resolveArrIndex is case-insensitive and finds by name', () => { + const mod = freshPlugin(); + mod._setArrangementsForTest([{ name: 'Lead' }, { name: 'Rhythm' }, { name: 'Bass' }]); + assert.equal(mod.resolveArrIndex('rhythm'), 1); + assert.equal(mod.resolveArrIndex('BASS'), 2); + assert.equal(mod.resolveArrIndex('nope'), -1); +}); + +test('resolveArrIndex treats special modes as non-arrangements', () => { + const mod = freshPlugin(); + mod._setArrangementsForTest([{ name: 'Lead' }]); + assert.equal(mod.resolveArrIndex(''), -1); + assert.equal(mod.resolveArrIndex(null), -1); + assert.equal(mod.resolveArrIndex('__lyrics__'), -1); // LYRICS_VALUE sentinel used at runtime; harmless if it isn't this literal +}); + +test('getDefaultArrangements prioritizes lead/rhythm/bass then fills and wraps', () => { + const mod = freshPlugin(); + mod._setArrangementsForTest([ + { name: 'Bass Guitar' }, { name: 'Rhythm Guitar' }, { name: 'Lead Guitar' }, { name: 'Extra' }, + ]); + // indices: 0=bass, 1=rhythm, 2=lead, 3=extra + assert.deepEqual(mod.getDefaultArrangements(2), [2, 1]); // lead, rhythm + assert.deepEqual(mod.getDefaultArrangements(4), [2, 1, 0, 3]); // lead, rhythm, bass, extra + assert.deepEqual(mod.getDefaultArrangements(5), [2, 1, 0, 3, 2]); // wraps around +}); + +test('getDefaultArrangements falls back to arrangement order with no named matches', () => { + const mod = freshPlugin(); + mod._setArrangementsForTest([{ name: 'Arr A' }, { name: 'Arr B' }]); + assert.deepEqual(mod.getDefaultArrangements(2), [0, 1]); +}); + +test('_ctlRange fills defaults for missing/non-finite bounds', () => { + const { _ctlRange } = freshPlugin(); + assert.deepEqual(_ctlRange({}), { lo: 0, hi: 1, st: 0.05 }); + assert.deepEqual(_ctlRange({ min: -1, max: 2, step: 0.1 }), { lo: -1, hi: 2, st: 0.1 }); + assert.deepEqual(_ctlRange({ min: NaN, max: Infinity }), { lo: 0, hi: 1, st: 0.05 }); +}); + +test('panelToPrefs encodes a plain arrangement panel', () => { + const mod = freshPlugin(); + mod._setArrangementsForTest([{ name: 'Lead' }]); + const panel = { + arrIndex: 0, + hw: { getInverted: () => false, getLefty: () => false, getMastery: () => 0.5 }, + bar: { style: { display: '' } }, + }; + const prefs = mod.panelToPrefs(panel); + assert.equal(prefs.arrName, 'Lead'); + assert.equal(prefs.inverted, false); + assert.equal(prefs.mastery, 0.5); + assert.equal(prefs.barHidden, false); +}); + +test('panelToPrefs encodes lyrics mode', () => { + const mod = freshPlugin(); + mod._setArrangementsForTest([{ name: 'Lead' }]); + const panel = { + arrIndex: 0, lyricsMode: true, lyricsOverlayOn: true, + hw: { getInverted: () => true, getLefty: () => true, getMastery: () => 0 }, + bar: { style: { display: 'none' } }, + }; + const prefs = mod.panelToPrefs(panel); + assert.equal(prefs.arrName, '__lyrics__'); // LYRICS_VALUE sentinel captured verbatim + assert.equal(prefs.lyrics, true); + assert.equal(prefs.barHidden, true); +}); + +test('panelToPrefs encodes viz mode with the underlying arrangement name', () => { + const mod = freshPlugin(); + mod._setArrangementsForTest([{ name: 'Lead' }, { name: 'Bass' }]); + const panel = { + arrIndex: 1, vizMode: 'highway_3d', + hw: { getInverted: () => false, getLefty: () => false, getMastery: () => 0 }, + bar: { style: { display: '' } }, + }; + const prefs = mod.panelToPrefs(panel); + assert.equal(prefs.arrName, '__viz__:highway_3d:Bass'); +}); + +test('migratePanelPrefs passes through non-array input unchanged', () => { + const { migratePanelPrefs } = freshPlugin(); + assert.equal(migratePanelPrefs(null), null); + assert.equal(migratePanelPrefs('nope'), 'nope'); +}); + +test('migratePanelPrefs resets lyrics on first migration (v<2) and preserves other fields', () => { + const { migratePanelPrefs } = freshPlugin(); + const out = migratePanelPrefs([{ arrName: 'Lead', lyrics: true, inverted: true }]); + assert.equal(out[0].lyrics, false); + assert.equal(out[0].inverted, true); +}); + +test('migratePanelPrefs migrates the legacy 3D-highway sentinel prefix', () => { + const { migratePanelPrefs } = freshPlugin(); + const out = migratePanelPrefs([{ arrName: '__3d_highway__:Lead', lyrics: false }]); + assert.equal(out[0].arrName, '__viz__:highway_3d:Lead'); +}); + +test('migratePanelPrefs does not reset lyrics once already migrated to v2', () => { + global.location = { search: '', host: 'x', protocol: 'http:' }; + const mod = freshPlugin(); + global.localStorage.setItem('splitscreenPrefsMigrationV', '2'); + const out = mod.migratePanelPrefs([{ arrName: 'Lead', lyrics: true }]); + assert.equal(out[0].lyrics, true); +});