mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-23 13:21:21 +00:00
* 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>
244 lines
11 KiB
JavaScript
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()');
|
|
});
|