Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions screen.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -197,3 +212,5 @@ function _smFmt(s) {
origShowScreen(id);
};
})();

}
147 changes: 147 additions & 0 deletions tests/screen.test.js
Original file line number Diff line number Diff line change
@@ -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
});
Loading