feedBack/tests/js/loop_restart.test.js
Byron Gamatos d9fa6d3f55
refactor(highway): make the highway global explicit before the module flip (R3c) (#912)
73 bare `highway.x` references -> `window.highway.x`, across app.js and 10 other files.
Provably a NO-OP today. It is the precondition for flipping highway.js to a module.

━━━ WHY THIS HAS TO LAND FIRST ━━━

highway.js is a CLASSIC script. Its top-level `const highway = createHighway()` therefore
creates a GLOBAL LEXICAL BINDING — visible as a bare name to every other classic script AND
to every ES module. 73 call sites quietly rely on that.

The moment highway.js becomes a module, that binding is gone. `const` in a module is
module-scoped, not global. Every one of those 73 sites becomes a ReferenceError, and the
flip is impossible until they say what they mean.

`window.highway = highway` is already set, to the same object, on the same line. So this is
an identity rewrite — verified in the browser below.

━━━ THE REWRITE BIT ME THREE TIMES. REGEX IS NOT ENOUGH FOR THIS. ━━━

1. A SHADOWED LOCAL. capabilities/note-detection.js does `const highway = window.highway`.
   Its 9 bare uses are LOCAL and already correct; a blind rewrite would have emitted
   `const window.highway = window.highway`. Excluded.

2. HALF-CONVERTED GUARDS — the dangerous one. Six sites read
   `typeof highway !== 'undefined' && highway && typeof highway.setTime === 'function'`.
   The regex converted the CONSEQUENT and left the TEST, which is WORSE than not touching
   them: after the flip `typeof highway` is 'undefined', so each guard is PERMANENTLY FALSE
   and the code behind it silently never runs. transport.js's was the seek->setTime sync:
   the chart clock would have quietly desynced after every seek, with nothing failing.
   All six now test window.highway.

3. TWO MORE BARE REFERENCES, found by Codex [P2] and confirmed by an AST scan: app.js:3114
   and :3176 use `highway && typeof window.highway.getSections === 'function'`. My grep
   searched for `typeof highway`, not `highway &&`. After the flip these throw, the catch
   swallows it, and the editor silently falls back to a ±4s edit window and arrangement 0.

Regex missed a shadow, a half-conversion, and two bare reads. The final check is an
AST pass that resolves scopes and reports every `highway` identifier not bound locally.
It now reports ZERO.

VERIFIED. A/B against origin/main in two browsers, 15 probes, IDENTICAL, zero page errors:
window.highway is the same object as the bare global, the whole API surface resolves, a real
song plays, the chart clock advances, getPerf().drawMs > 0 — and `seek syncs chart` passes,
which is the exact guard I nearly broke in (2).

node 1045, pytest 2416, ESLint 0, Codex 0.

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

261 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);
// highway.js is loaded as a CLASSIC script today, so its top-level `const highway`
// creates a global lexical binding that app.js and the modules could reach as a bare
// name. That binding disappears the moment highway.js becomes a module (R3c), so every
// consumer now says `window.highway` — the same object, explicitly. Mirror it here.
if (sandbox.window && sandbox.highway) sandbox.window.highway = sandbox.highway;
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()');
});