feedBack/tests/js/loop_restart.test.js
Bret Mogilefsky af2949677a
rename: slopsmith → feedBack, byron → got-feedBack (#537)
* Update GitHub repo references from feedback* to feedBack*

* rename: slopsmith -> feedBack, byron -> got-feedBack

Renames across the entire codebase:
- slopsmith/Slopsmith/SLOPSMITH/SlopSmith -> feedBack/FeedBack/FEEDBACK/FeedBack
- byron/Byron/Byrongamatos -> got-feedBack/got-feedBack/got-feedBack
- /home/byron/ -> /opt/got-feedBack/
- byron@ougsoft.com -> hi@got-feedBack.org
- github.com/byrongamatos/ -> github.com/got-feedback/
- com.byron. -> com.got-feedback.
- SLOPSMITH_ env vars -> FEEDBACK_ with backward-compat fallback
- Protocol/storage strings migrated with read-old/write-new pattern
- window.slopsmith JS API -> window.feedBack (canonical) + backward-compat alias

Refs: #rename-slopsmith

* rename: complete regen against current main + fix backward-compat alias

Regenerated the slopsmith->feedBack / byron->got-feedBack rename on top of
current main (3 commits had landed since the branch: #572/#554/#574),
resolving the four content conflicts in favour of main's newer content
(autoplay/auto-exit, accuracy-badge, Virtuoso re-home, feedpak badge).

Completion fixes on top of the mechanical rename:
- Re-apply rename to post-branch content the original rename never saw:
  window.slopsmith(.Tour) consumers in lessons.js / notifications.js /
  onboarding-tour.js, and the matching JS + python tests (autoplay_exit,
  progression_*, test_feedpak_extension FEEDBACK_* env vars). The test env
  vars now match server.py (which reads FEEDBACK_SYNC_STARTUP /
  FEEDBACK_SKIP_STARTUP_TASKS), so the sync-startup test exercises the real
  path again.
- Restore the window.slopsmith backward-compat alias dropped during conflict
  resolution, and move the bus aliases to AFTER the _feedBackExisting merge
  block so they reference the fully-assembled object (also fixes the
  loop_api.test.js API-surface regex, which the original PR latently broke).
- Drop the stray empty data/web_library.db (runtime DB lives in CONFIG_DIR)
  and gitignore it.
- Fix stale tone-source test: feed[dB]ack -> fee[dB]ack to match shipped
  source labels.

Verified locally (org CI billing-blocked): JS 819/819 pass; pytest 1669
passed / 1683 collected with 0 import errors; zero residual slopsmith/byron
except the two intentional window.slopsmith aliases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* rename: implement advertised backward-compat + prune dead community plugins

Address gaps where PR #537's "Backward compatibility" section was advertised
but not implemented, and clean up the community plugin list.

Env vars (FEEDBACK_* canonical, legacy SLOPSMITH_* honoured):
- New lib/env_compat.py (getenv_compat / env_flag_compat) + tests. server.py
  (_env_flag + all FEEDBACK_* reads), diagnostics_hardware, gp2midi and
  tailwind_rebuild now resolve the legacy alias, so existing SLOPSMITH_UI /
  SLOPSMITH_PLUGINS_DIR / etc. deployments keep working.
- Fix the rename collapsing plugins/__init__.py and minigames/routes.py from
  `FEEDBACK_PLUGINS_DIR or SLOPSMITH_PLUGINS_DIR` into a redundant
  `FEEDBACK_ or FEEDBACK_` (the fallback was silently lost).

Storage (app.js update-channel):
- Read feedBack-update-channel, fall back to legacy slopsmith-update-channel,
  and clear the legacy key on write — so a user's update-channel preference
  survives the rename instead of resetting to "stable".

Community plugin list (README): the rename rewrote third-party repo URLs we
don't own. Probed every one; their owners never renamed, so:
- Restore the 13 live community plugins to their real slopsmith-* names.
- Prune 6 that are 404 to the public (topkoa splitscreen/stems, OmikronApex
  tuner, Jafz2001 nam-rig-builder, DeathlySin song-preview, Erikcb91 shuffle).
- Fix a pre-existing Guitar Theory clone-command typo (nam-tone -> guitar-theory).

Verified: env_compat 7/7, JS 819/819, pytest 1690 collected / 0 import errors,
rename-sensitive + startup suites green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:03:01 +02:00

244 lines
11 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');
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 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,
};
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\.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 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\.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()');
});