feedBack/tests/js/loop_restart.test.js
Byron Gamatos 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

256 lines
12 KiB
JavaScript

// Verify static/app.js emits `loop:restart` exactly once when the A-B
// loop wraps, with the documented payload shape. Plugins (notedetect's
// drill-mode score capture) consume this contract.
//
// The test does not load the full app.js into a DOM — it extracts just
// the `startCountIn` function source via brace-matching and evaluates it
// in a vm sandbox with stubbed dependencies. This trades coverage of the
// surrounding script for isolation: a failure here points at the wrap
// path, not at unrelated DOM coupling.
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');
// startCountIn was carved out of app.js into its own module (R3a).
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'count-in.js');
// Pull a function body by declaration prefix (e.g. `async function startCountIn`)
// and brace-matching to the closing brace. Skips an optional `( ... )` param
// list so `startCountIn(opts = {})` and `startCountIn()` both match the same
// prefix. Brittle by design: rename/restructure fails loudly, not silently.
function extractFunction(src, signature) {
const start = src.indexOf(signature);
if (start === -1) throw new Error(`extractFunction: '${signature}' not found in app.js`);
let scan = start + signature.length;
if (src[scan] === '(') {
let parenDepth = 1;
scan++;
while (scan < src.length && parenDepth > 0) {
const ch = src[scan];
if (ch === '(') parenDepth++;
else if (ch === ')') parenDepth--;
scan++;
}
}
const openBrace = src.indexOf('{', scan);
let depth = 1;
let i = openBrace + 1;
while (i < src.length && depth > 0) {
const ch = src[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
if (depth !== 0) throw new Error(`extractFunction: unbalanced braces after '${signature}'`);
return src.slice(start, i);
}
function buildSandbox() {
const emitCalls = [];
const sandbox = {
// Globals the function reads/writes via closure. Declared as `var`
// in the eval prelude so they attach to the sandbox.
loopA: 10,
loopB: 20,
_countingIn: false,
// isPlaying / lastAudioTime moved onto the shared player-state container
// (static/js/player-state.js) so a carved module can WRITE them — an imported
// binding is read-only. Same values, same assertions, one indirection.
S: { isPlaying: false, lastAudioTime: 0 },
// Browser-ish globals.
performance: { now: () => Date.now() },
// requestAnimationFrame: skip to t >= 1 in one tick so the rewind
// animation completes synchronously and we reach the `_audioSeek`
// continuation immediately.
requestAnimationFrame(fn) {
// Fire with `now` far enough in the future that
// (now - rewindStart) / rewindDuration >= 1.
queueMicrotask(() => fn(Date.now() + 10_000));
},
// setTimeout: swallow. beginCount schedules ticks via setTimeout;
// we don't need them to fire — the emit happens before beginCount.
setTimeout: () => 0,
// Stubbed feedBack DOM dependencies.
audio: { pause() {} },
jucePlayer: { pause: () => Promise.resolve(), play: () => Promise.resolve(true) },
highway: { setTime() {}, getBPM: () => 120 },
// Stubbed app.js helpers.
// Resolve with the real shape `{ completed, from, to }` so
// startCountIn's loop-wrap callback sees completed=true and uses
// r.to for highway.setTime / lastAudioTime.
_audioSeek: (s) => Promise.resolve({ completed: true, from: 20, to: s }),
playClick: () => {},
showCountOverlay: () => {},
hideCountOverlay: () => {},
// Stubbed DOM access. Anything querying for a button just gets a
// permissive object that ignores writes.
document: {
getElementById: () => ({
textContent: '',
className: '',
classList: { add() {}, remove() {}, toggle() {} },
}),
},
// Spy: records every emit call so the test can assert.
window: {
feedBack: {
emit(event, detail) { emitCalls.push({ event, detail }); },
isPlaying: false,
},
_juceMode: false,
},
// Capture for assertions.
__emitCalls: emitCalls,
queueMicrotask,
};
// startCountIn was carved into static/js/count-in.js and now reaches back into
// app.js through the host seam (static/js/host.js). Point the seam at the SAME
// stubs the sandbox already had: the assertions below are unchanged, they just
// travel through the indirection the real code now uses.
sandbox.host = {
_audioSeek: (...a) => sandbox._audioSeek(...a),
setPlayButtonState: () => {},
_songEventPayload: () => ({}),
togglePlay: () => {},
jucePlayer: () => sandbox.jucePlayer,
};
vm.createContext(sandbox);
return sandbox;
}
test('loop:restart fires once when wrap path runs', async () => {
const src = fs.readFileSync(APP_JS, 'utf8').replace(/^export /gm, '');
const startCountInSrc = extractFunction(src, 'async function startCountIn');
// Sanity check: the change under test is present at all. Catches
// accidental revert before we even run the behavior assertion.
assert.match(
startCountInSrc,
/window\.feedBack\.emit\(\s*['"]loop:restart['"]/,
'startCountIn is missing the loop:restart emit',
);
const sandbox = buildSandbox();
// Re-declare the closure-scoped lets as vars so the function can read
// them from the sandbox global, then define the function in-context.
const prelude = `
var loopA = ${sandbox.loopA};
var loopB = ${sandbox.loopB};
var _countingIn = false;
var _countInGen = 0;
var _countInTimer = null;
var _countInRaf = 0;
var S = { isPlaying: false, lastAudioTime: 0 };
${startCountInSrc}
globalThis.__startCountIn = startCountIn;
`;
vm.runInContext(prelude, sandbox);
await sandbox.__startCountIn();
// Allow the queued requestAnimationFrame microtask + the _audioSeek
// promise chain to settle. Two awaits is enough: rAF microtask -> rewind
// completion -> _audioSeek().then() -> emit.
await new Promise((r) => setImmediate(r));
await new Promise((r) => setImmediate(r));
const restarts = sandbox.__emitCalls.filter((c) => c.event === 'loop:restart');
assert.equal(restarts.length, 1, `expected 1 loop:restart emit, got ${restarts.length}`);
// Field-wise assertion: deepStrictEqual fails across vm-context object
// realms because Object.prototype identities differ even when contents
// match. Compare values, not prototype graphs.
const detail = restarts[0].detail;
assert.equal(detail.loopA, 10);
assert.equal(detail.loopB, 20);
assert.equal(detail.time, 10);
assert.equal(Object.keys(detail).length, 3, `unexpected extra keys in detail: ${Object.keys(detail)}`);
});
test('loop:restart aborts when seek lands far from loopA (JUCE rollback)', async () => {
// Regression: if jucePlayer.seek rolls back (currentTime stays put),
// _audioSeek resolves with completed:true but r.to !== loopA. The
// wrap handler must abort instead of running beginCount on the wrong
// position and emitting a misleading loop:restart.
const src = fs.readFileSync(APP_JS, 'utf8').replace(/^export /gm, '');
const startCountInSrc = extractFunction(src, 'async function startCountIn');
const sandbox = buildSandbox();
// Override _audioSeek to mimic JUCE rollback: completed but to=from,
// far from the requested loopA (10).
sandbox._audioSeek = (s) => Promise.resolve({ completed: true, from: 20, to: 20 });
const prelude = `
var loopA = ${sandbox.loopA};
var loopB = ${sandbox.loopB};
var _countingIn = false;
var _countInGen = 0;
var _countInTimer = null;
var _countInRaf = 0;
var S = { isPlaying: false, lastAudioTime: 0 };
${startCountInSrc}
globalThis.__startCountIn = startCountIn;
globalThis.__getCountingIn = () => _countingIn;
`;
vm.runInContext(prelude, sandbox);
await sandbox.__startCountIn();
await new Promise((r) => setImmediate(r));
await new Promise((r) => setImmediate(r));
const restarts = sandbox.__emitCalls.filter((c) => c.event === 'loop:restart');
assert.equal(restarts.length, 0, 'rollback must not emit loop:restart');
assert.equal(sandbox.__getCountingIn(), false, '_countingIn must be cleared on abort');
});
test('count-in cancellation token bails delayed callbacks (rewindStep + tick)', () => {
// Source-level assertion: the gen-capture pattern is in place so
// teardown can interrupt an in-flight count-in. Behavioral simulation
// of timer cancellation is out of scope for the static extractor; this
// verifies the contract is wired into the source.
const src = fs.readFileSync(APP_JS, 'utf8').replace(/^export /gm, '');
const fn = extractFunction(src, 'async function startCountIn');
// Captures gen at entry
assert.match(fn, /const gen = _countInGen/, 'startCountIn must capture _countInGen at entry');
// Each delayed callback bails on mismatch
const guards = [...fn.matchAll(/if \(gen !== _countInGen\) return/g)];
assert.ok(guards.length >= 4, `expected ≥4 gen-mismatch bails, found ${guards.length}`);
// RAF and timer handles tracked so _cancelCountIn can cancel them
assert.match(fn, /_countInRaf = requestAnimationFrame/, 'rewindStep must store its RAF handle in _countInRaf');
assert.match(fn, /_countInTimer = setTimeout/, 'tick scheduling must store its timer in _countInTimer');
});
test('loop:restart fires after highway.setTime, before beginCount', () => {
// Source-order assertion on the A-B wrap path only. Section-practice
// `opts.immediate` also emits loop:restart but is a separate entry path;
// the wrap handler lives inside the `_audioSeek(loopA, 'loop-wrap')` then.
const src = fs.readFileSync(APP_JS, 'utf8').replace(/^export /gm, '');
const fn = extractFunction(src, 'async function startCountIn');
const wrapMarker = "_audioSeek(loopA, 'loop-wrap')";
const wrapStart = fn.indexOf(wrapMarker);
assert.ok(wrapStart !== -1, 'loop-wrap _audioSeek call not found in startCountIn');
const wrapSlice = fn.slice(wrapStart);
const setTimeMatches = [...wrapSlice.matchAll(/highway\.setTime\(\s*[^)]+\)/g)];
const setTimeIdx = setTimeMatches.length
? wrapStart + setTimeMatches[setTimeMatches.length - 1].index
: -1;
const emitRel = wrapSlice.search(/window\.feedBack\.emit\(\s*['"]loop:restart['"]/);
const emitIdx = emitRel === -1 ? -1 : wrapStart + emitRel;
const afterEmit = emitIdx === -1 ? '' : fn.slice(emitIdx);
const beginCallMatch = afterEmit.match(/(?<!function\s)\bbeginCount\s*\(/);
const beginCallIdx = beginCallMatch ? emitIdx + beginCallMatch.index : -1;
assert.ok(setTimeIdx !== -1, 'post-seek highway.setTime not found on wrap path');
assert.ok(emitIdx !== -1, 'loop:restart emit not found on wrap path');
assert.ok(beginCallIdx !== -1, 'beginCount() call not found after wrap emit');
assert.ok(setTimeIdx < emitIdx, 'wrap emit must come after highway.setTime');
assert.ok(emitIdx < beginCallIdx, 'wrap emit must come before beginCount()');
});