diff --git a/screen.js b/screen.js index ed19b64..8b2943f 100644 --- a/screen.js +++ b/screen.js @@ -168,6 +168,21 @@ function _smFmt(s) { return Math.floor(s / 60) + ':' + String(Math.floor(s % 60)).padStart(2, '0'); } +// Node-only export hook for tests; browsers fall through to the side-effect +// IIFE below (poller + playSong/showScreen wrapping). +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + _smGetColor, _smFmt, _smCreate, _smRemove, _smUpdate, _smRender, + _smOnClick, _smOnWheel, + _getState: () => ({ bar: _smBar, sections: _smSections, duration: _smDuration }), + _setState(next) { + if ('sections' in next) _smSections = next.sections; + if ('duration' in next) _smDuration = next.duration; + if ('bar' in next) _smBar = next.bar; + }, + }; +} else { + // Side effects: poller + playSong/showScreen wrappers. Consolidated under // one idempotency guard so re-evaluation (loader cache miss, hot reload, // older core builds without the load-side guard) doesn't start a second @@ -197,3 +212,5 @@ function _smFmt(s) { origShowScreen(id); }; })(); + +} diff --git a/tests/screen.test.js b/tests/screen.test.js new file mode 100644 index 0000000..5e4e4ef --- /dev/null +++ b/tests/screen.test.js @@ -0,0 +1,147 @@ +'use strict'; +// Coverage for pure/DOM-light helpers in screen.js: section color lookup, +// time formatting, render HTML shape, click/wheel seek math. +// 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.document = { getElementById: () => null }; + const file = path.join(__dirname, '..', 'screen.js'); + delete require.cache[require.resolve(file)]; + return require(file); +} + +class FakeBar { + constructor() { + this.innerHTML = ''; + this.style = {}; + this._listeners = {}; + this.left = 0; + this.width = 500; + } + addEventListener(type, fn) { this._listeners[type] = fn; } + getBoundingClientRect() { return { left: this.left, width: this.width }; } + querySelectorAll() { return []; } +} + +class FakeAudio { + constructor() { + this.currentTime = 0; + this.paused = true; + this._listeners = {}; + } + pause() { this.paused = true; } + play() { this.paused = false; } + addEventListener(type, fn) { this._listeners[type] = fn; } + removeEventListener() {} +} + +test('_smGetColor matches by substring, case-insensitively', () => { + const mod = freshPlugin(); + assert.equal(mod._smGetColor('Verse 1'), '#22c55e'); + assert.equal(mod._smGetColor('CHORUS'), '#eab308'); + assert.equal(mod._smGetColor('Guitar Solo'), '#ef4444'); +}); + +test('_smGetColor falls back to default for an unrecognized section name', () => { + const mod = freshPlugin(); + assert.equal(mod._smGetColor('Mystery Section'), '#4b5563'); +}); + +test('_smFmt formats seconds as m:ss with zero-padded seconds', () => { + const mod = freshPlugin(); + assert.equal(mod._smFmt(0), '0:00'); + assert.equal(mod._smFmt(65), '1:05'); + assert.equal(mod._smFmt(600), '10:00'); +}); + +test('_smRender builds one .sm-block-tagged div per section plus a position marker', () => { + const mod = freshPlugin(); + const bar = new FakeBar(); + mod._setState({ + bar, + sections: [{ name: 'Intro', time: 0 }, { name: 'Verse 1', time: 10 }], + duration: 20, + }); + mod._smRender(); + assert.equal((bar.innerHTML.match(/sm-block/g) || []).length, 2); + assert.ok(bar.innerHTML.includes('id="sm-marker"')); + assert.ok(bar.innerHTML.includes('left:0%')); // Intro starts at 0% + assert.ok(bar.innerHTML.includes('left:50%')); // Verse 1 starts at 10/20 +}); + +test('_smRender strips a trailing numeric suffix and capitalizes the label', () => { + const mod = freshPlugin(); + const bar = new FakeBar(); + mod._setState({ bar, sections: [{ name: 'verse2', time: 0 }], duration: 10 }); + mod._smRender(); + assert.ok(bar.innerHTML.includes('>Verse<')); +}); + +test('_smOnClick seeks to the clicked fraction of the bar', () => { + const mod = freshPlugin(); + const bar = new FakeBar(); + bar.width = 500; + const audio = new FakeAudio(); + audio.paused = true; // not playing -> seeks immediately, no resume dance + mod._setState({ bar, sections: [{ name: 'Intro', time: 0 }], duration: 100 }); + global.document = { getElementById: (id) => (id === 'audio' ? audio : null) }; + + mod._smOnClick({ clientX: 250 }); // 50% across a 500px-wide bar + assert.equal(audio.currentTime, 50); +}); + +test('_smOnClick pauses, seeks, and resumes playback while playing', () => { + const mod = freshPlugin(); + const bar = new FakeBar(); + const audio = new FakeAudio(); + audio.paused = false; + mod._setState({ bar, sections: [{ name: 'Intro', time: 0 }], duration: 100 }); + global.document = { getElementById: (id) => (id === 'audio' ? audio : null) }; + + mod._smOnClick({ clientX: 0 }); + assert.equal(audio.currentTime, 0); + assert.equal(audio.paused, true); // paused before the seek + audio._listeners.seeked(); // simulate the browser firing 'seeked' + assert.equal(audio.paused, false); // resumed +}); + +test('_smOnWheel computes a fine-grained seek delta with ctrlKey', () => { + const mod = freshPlugin(); + const audio = new FakeAudio(); + audio.currentTime = 10; + audio.paused = true; + mod._setState({ bar: new FakeBar(), sections: [{ name: 'Intro', time: 0 }], duration: 100 }); + global.document = { getElementById: (id) => (id === 'audio' ? audio : null) }; + + let prevented = false; + mod._smOnWheel({ deltaY: -1, ctrlKey: true, preventDefault: () => { prevented = true; } }); + assert.equal(prevented, true); + assert.equal(audio.currentTime, 10.1); // scroll up -> forward by 0.1s (ctrl = fine) +}); + +test('_smOnWheel without ctrlKey moves a full second and clamps to [0, duration]', () => { + const mod = freshPlugin(); + const audio = new FakeAudio(); + audio.currentTime = 0; + audio.paused = true; + mod._setState({ bar: new FakeBar(), sections: [{ name: 'Intro', time: 0 }], duration: 100 }); + global.document = { getElementById: (id) => (id === 'audio' ? audio : null) }; + + mod._smOnWheel({ deltaY: 1, ctrlKey: false, preventDefault: () => {} }); // scroll down -> backward + assert.equal(audio.currentTime, 0); // clamped at 0, can't go negative +}); + +test('_smOnWheel/_smOnClick are no-ops without a known duration', () => { + const mod = freshPlugin(); + const audio = new FakeAudio(); + mod._setState({ bar: new FakeBar(), sections: [], duration: 0 }); + global.document = { getElementById: (id) => (id === 'audio' ? audio : null) }; + + mod._smOnClick({ clientX: 100 }); + mod._smOnWheel({ deltaY: 1, preventDefault: () => {} }); + assert.equal(audio.currentTime, 0); // untouched +});