mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
Clean release snapshot
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
// 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');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.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: 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 slopsmith 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: {
|
||||
slopsmith: {
|
||||
emit(event, detail) { emitCalls.push({ event, detail }); },
|
||||
isPlaying: false,
|
||||
},
|
||||
_juceMode: false,
|
||||
},
|
||||
|
||||
// Capture for assertions.
|
||||
__emitCalls: emitCalls,
|
||||
queueMicrotask,
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
test('loop:restart fires once when wrap path runs', async () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
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\.slopsmith\.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 isPlaying = false;
|
||||
var 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');
|
||||
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 isPlaying = false;
|
||||
var 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');
|
||||
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');
|
||||
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\.slopsmith\.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()');
|
||||
});
|
||||
Reference in New Issue
Block a user