Files
feedBack/tests/js/song_credits_overlay.test.js
T
11f8c36b61 refactor(app): carve count-in (and the song-credits overlay) out of app.js (R3a) (#890)
static/js/count-in.js (389) — bodies VERBATIM. app.js 8,223 → 7,913.
The third slice out of the strongly-connected core, and the first that WRITES
shared state rather than only reading it. #889's container is what makes it possible.

  imports: loops (setLoop/loopA/loopB — a count-in inside an A-B loop must begin at
           A), audio-el, player-state, host
  hooks  : _audioSeek, setPlayButtonState, _songEventPayload, togglePlay + a
           jucePlayer getter
  Nothing imports count-in back — app.js and section-practice both reach it through
  the seam — so the graph stays acyclic.

app.js's autoplay path used to reach IN and set this module's credits timers itself
(_creditsTimer, _creditsHideOnPlay) and read _countingIn. It cannot now, and should
not have to, so the module exports the OPERATIONS instead — armCreditsHideOnPlay(),
scheduleCreditsHide(), holdCreditsThen(start), isCountingIn() — and owns its own
timer invariants. Third time this has happened (section-practice's resetSelection,
loops' state) and each time the constraint produced better code than was there
before: the module keeps its own promises instead of trusting a caller 6,000 lines
away to zero the right fields.

THE no-undef GATE FOUND FIVE MISSED MEMBERS, one at a time: showSongCreditsOverlay
and startSongCountIn (my name regex matched startCountIn, not startSongCountIn),
then _creditLineLabel, _CREDITS_MAX_MS, and _CREDIT_ROLE_VERBS. A call-graph closure
does not see a const table; only the undefined-symbol pass does.

AND A REAL TRAP: I computed _CREDIT_ROLE_VERBS's span against the ALREADY-MODIFIED
app.js and applied it to the clean one — the line numbers had drifted, so the slice
would have cut somewhere else entirely. Recomputed every span from the clean file
with acorn. Never carry line numbers across an edit.

VERIFIED. A/B against origin/main in two browsers with a real song: playback state,
the public feedBack.isPlaying mirror, audio position, cancel-count-in — IDENTICAL,
zero page errors. Unit coverage moved with the code: loop_restart's count-in
cancellation-token test and the 5 song_credits_overlay tests now read count-in.js;
loop_restart's sandbox gains a `host` object routed at its EXISTING stubs, so every
assertion is unchanged.

HONEST LIMIT: I could not make the count-in OVERLAY actually render headlessly —
its autoplay path needs a fresh-load _pendingAutostart that a scripted playSong()
never arms. Behaviour is identical to main on every probe and the unit tests cover
the logic, but the on-screen 1-2-3-4 and the credits card want a human look.

pytest 2396, node 1040/1040, ESLint 0 (no-cycle clean), tailwind clean, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 22:05:22 +02:00

135 lines
5.4 KiB
JavaScript

// Verify the feedpak credits overlay helpers in app.js:
// - _creditLineLabel() role → friendly "<verb> by" label
// - showSongCreditsOverlay() builds an XSS-safe card; no-op on empty list
// - hideSongCreditsOverlay() removes the overlay element
//
// Same isolation strategy as autoplay_exit.test.js — extract the functions
// from app.js by brace-matching and run them in a vm sandbox with a fake DOM.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { extractFunction } = require('./test_utils');
// the song-credits overlay was carved out of app.js into its own module (R3a).
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'count-in.js');
const SRC = fs.readFileSync(APP_JS, 'utf8').replace(/^export /gm, '');
// Minimal fake DOM element: records className, children, and textContent.
// Setting textContent clears children (matching real DOM) so we can assert
// names were set via textContent (not innerHTML) — the XSS-safety contract.
function makeEl() {
return {
className: '',
children: [],
_text: '',
set textContent(v) { this._text = String(v); this.children = []; },
get textContent() { return this._text; },
appendChild(c) { this.children.push(c); return c; },
replaceChildren() { this.children = []; },
remove() { this.removed = true; },
};
}
function allText(node) {
let s = node._text || '';
for (const c of node.children) s += allText(c);
return s;
}
function buildSandbox(currentSong) {
const body = makeEl();
const sandbox = {
document: { body, createElement: () => makeEl() },
window: { feedBack: { currentSong, off() {} } },
setTimeout: () => 1,
clearTimeout: () => {},
};
vm.createContext(sandbox);
const preamble = `
let _creditsOverlay = null;
let _creditsTimer = null;
let _creditsHideOnPlay = null;
let _creditsMaxTimer = null;
const _CREDITS_MAX_MS = 12000;
const _CREDIT_ROLE_VERBS = ${JSON.stringify({
charter: 'Charted by', transcriber: 'Transcribed by',
arranger: 'Arranged by', editor: 'Edited by', mixer: 'Mixed by',
engineer: 'Engineered by', proofreader: 'Proofread by',
})};
`;
vm.runInContext(
preamble
+ extractFunction(SRC, 'function _creditLineLabel(') + '\n'
+ extractFunction(SRC, 'function showSongCreditsOverlay(') + '\n'
+ extractFunction(SRC, 'function hideSongCreditsOverlay(') + '\n'
+ 'globalThis._creditLineLabel = _creditLineLabel;'
+ 'globalThis.showSongCreditsOverlay = showSongCreditsOverlay;'
+ 'globalThis.hideSongCreditsOverlay = hideSongCreditsOverlay;'
+ 'globalThis._getOverlay = () => _creditsOverlay;',
sandbox,
);
return sandbox;
}
test('_creditLineLabel maps known roles, title-cases unknown, blanks empty', () => {
const s = buildSandbox({});
assert.equal(s._creditLineLabel('charter'), 'Charted by');
assert.equal(s._creditLineLabel('Editor'), 'Edited by'); // case-insensitive
assert.equal(s._creditLineLabel('mixer'), 'Mixed by');
assert.equal(s._creditLineLabel('luthier'), 'Luthier by'); // unknown → title-cased
assert.equal(s._creditLineLabel(null), ''); // no role → bare name
assert.equal(s._creditLineLabel(''), '');
});
test('showSongCreditsOverlay builds a card with heading + credit lines', () => {
const s = buildSandbox({ title: 'My Song' });
s.showSongCreditsOverlay([
{ name: 'Azure', role: 'charter' },
{ name: 'Bob Lee', role: 'editor' },
{ name: 'Solo', role: null },
]);
const overlay = s._getOverlay();
assert.ok(overlay, 'overlay created');
assert.equal(overlay.className, 'song-credits-overlay');
assert.equal(s.document.body.children.length, 1);
const text = allText(overlay);
assert.match(text, /My Song/); // heading is the song title
assert.match(text, /Charted by/);
assert.match(text, /Azure/);
assert.match(text, /Edited by/);
assert.match(text, /Bob Lee/);
assert.match(text, /Solo/); // role-less entry still shows the name
});
test('showSongCreditsOverlay sets names via textContent (XSS-safe)', () => {
const s = buildSandbox({ title: 'T' });
s.showSongCreditsOverlay([{ name: '<img src=x onerror=alert(1)>', role: 'charter' }]);
const overlay = s._getOverlay();
// The raw string survives verbatim as text — proving it was never parsed
// as HTML (no innerHTML interpolation anywhere on the path).
assert.match(allText(overlay), /<img src=x onerror=alert\(1\)>/);
});
test('showSongCreditsOverlay is a no-op for empty / non-array input', () => {
const s = buildSandbox({ title: 'T' });
s.showSongCreditsOverlay([]);
assert.equal(s._getOverlay(), null);
s.showSongCreditsOverlay(undefined);
assert.equal(s._getOverlay(), null);
assert.equal(s.document.body.children.length, 0);
});
test('hideSongCreditsOverlay removes the overlay', () => {
const s = buildSandbox({ title: 'T' });
s.showSongCreditsOverlay([{ name: 'Azure', role: 'charter' }]);
const overlay = s._getOverlay();
assert.ok(overlay);
s.hideSongCreditsOverlay();
assert.equal(overlay.removed, true);
assert.equal(s._getOverlay(), null);
});