feedBack/tests/js/loop_controller.test.js
Viktor Olausson 98090557df feat: unify loop practice experience
Signed-off-by: Viktor Olausson <viktor.olausson@gmail.com>
2026-07-20 10:04:04 +02:00

387 lines
15 KiB
JavaScript

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 LOOPS_JS = path.join(__dirname, '..', '..', 'static', 'js', 'loops.js');
function normalizePreferences(value) {
const source = value && typeof value === 'object' ? value : {};
return {
activation: ['arm', 'auto'].includes(source.activation) ? source.activation : 'arm',
firstPass: ['count-in', 'immediate'].includes(source.firstPass) ? source.firstPass : 'count-in',
repeat: ['count-in', 'continuous'].includes(source.repeat) ? source.repeat : 'count-in',
};
}
function buildHarness(preferences = {}, { juce = false, isPlaying = false } = {}) {
const seekCalls = [];
const countInCalls = [];
const operationCalls = [];
const transportEvents = [];
const loopEvents = [];
const persisted = [];
const elements = new Map();
let currentTime = 33;
let seekGeneration = 0;
const sandbox = {
seekCalls,
countInCalls,
operationCalls,
transportEvents,
loopEvents,
persisted,
S: { isPlaying, lastAudioTime: currentTime },
document: {
getElementById(id) { return elements.get(id) || null; },
},
window: {
_juceMode: juce,
_ndAnyDrillActive: false,
feedBack: {
emit(name, detail) {
loopEvents.push({ name, detail });
},
playback: {
transportEvent(name, detail) {
transportEvents.push({ name, detail });
},
},
},
},
loadLoopPreferences() {
return normalizePreferences(preferences);
},
normalizeLoopPreferences: normalizePreferences,
saveLoopPreferences(value) {
const normalized = normalizePreferences(value);
persisted.push(normalized);
return normalized;
},
_audioTime() { return currentTime; },
audioSeekGen() { return seekGeneration; },
async _audioSeek(seconds, reason, options = {}) {
if (typeof options.guard === 'function' && !options.guard()) {
return { completed: false, from: NaN, to: NaN };
}
const from = currentTime;
currentTime = seconds;
operationCalls.push(`seek:${reason}`);
seekCalls.push({ seconds, reason, juce });
loopEvents.push({ name: 'song:seek', detail: { from, to: seconds, reason } });
return { completed: true, from, to: seconds };
},
async togglePlay() {
sandbox.__playCalls++;
sandbox.S.isPlaying = true;
},
_cancelCountIn() {
sandbox.__cancelCalls++;
},
isCountingIn() {
return false;
},
async pauseBackingForCountIn() {
operationCalls.push('pause-count-in-backing');
},
async startCountIn(options) {
operationCalls.push('start-count-in');
countInCalls.push(options);
loopEvents.push({
name: 'loop:restart',
detail: {
loopA: options.bounds.a,
loopB: options.bounds.b,
time: options.bounds.a,
},
});
return true;
},
formatTime(value) { return String(value); },
esc(value) { return String(value); },
async uiPrompt() { return null; },
fetch: async () => ({ json: async () => [] }),
_setSectionPracticeMode() {},
_syncSectionPracticeFromLoop() {},
_updateSectionPracticeHighlight() {},
resetSelection() {},
host: {
_updateEditRegionBtn() {},
currentFilename() { return 'song.sloppak'; },
},
__playCalls: 0,
__cancelCalls: 0,
__setCurrentTime(value) { currentTime = value; },
__bumpSeekGeneration() { seekGeneration++; },
setTimeout,
clearTimeout,
console,
};
vm.createContext(sandbox);
const raw = fs.readFileSync(LOOPS_JS, 'utf8');
const moduleStart = raw.indexOf('export let loopA');
assert.notEqual(moduleStart, -1, 'loop module state declaration not found');
const body = raw.slice(moduleStart).replace(/^export /gm, '');
vm.runInContext(`${body}
globalThis.__loop = {
setLoop,
setLoopStart,
setLoopEnd,
startLoop,
clearLoop,
cancelLoopOperations,
handleLoopBoundary,
getLoopState,
updateLoopPreference,
};
`, sandbox);
return { sandbox, api: sandbox.__loop };
}
test('legacy plugin setLoop still seeks to A and activates without forcing play', async () => {
const { sandbox, api } = buildHarness({}, { isPlaying: false });
assert.equal(await api.setLoop(4, 9), true);
assert.equal(sandbox.seekCalls.length, 1);
assert.equal(sandbox.seekCalls[0].seconds, 4);
assert.equal(sandbox.seekCalls[0].reason, 'loop-set');
assert.equal(api.getLoopState().state, 'active');
assert.equal(sandbox.__playCalls, 0);
});
test('arm-only configuration preserves the current transport and does not wrap at B', async () => {
const { sandbox, api } = buildHarness({
activation: 'arm',
firstPass: 'count-in',
repeat: 'count-in',
}, { isPlaying: true });
sandbox.__setCurrentTime(27);
assert.equal(await api.setLoop(5, 10, { activation: 'preference', source: 'saved' }), true);
assert.equal(api.getLoopState().state, 'armed');
assert.equal(sandbox.seekCalls.length, 0);
assert.equal(sandbox.__playCalls, 0);
assert.equal(await api.handleLoopBoundary(10), false);
assert.equal(sandbox.countInCalls.length, 0);
});
test('manual A/B completion uses the same arm-only controller path', async () => {
const { sandbox, api } = buildHarness({ activation: 'arm' }, { isPlaying: true });
sandbox.__setCurrentTime(6);
api.setLoopStart();
assert.equal(api.getLoopState().state, 'partial');
sandbox.__setCurrentTime(14);
assert.equal(await api.setLoopEnd(), true);
const state = api.getLoopState();
assert.equal(state.loopA, 6);
assert.equal(state.loopB, 14);
assert.equal(state.state, 'armed');
assert.equal(state.source, 'manual');
assert.equal(sandbox.seekCalls.length, 0);
});
test('all activation, first-pass, and repeat combinations follow one policy matrix', async (t) => {
const activations = ['arm', 'auto'];
const firstPasses = ['count-in', 'immediate'];
const repeats = ['count-in', 'continuous'];
for (const activation of activations) {
for (const firstPass of firstPasses) {
for (const repeat of repeats) {
await t.test(`${activation}/${firstPass}/${repeat}`, async () => {
const { sandbox, api } = buildHarness({
activation,
firstPass,
repeat,
});
const configured = api.setLoop(8, 12, {
activation: 'preference',
source: 'section',
});
assert.equal(await configured, true);
if (activation === 'arm') {
assert.equal(api.getLoopState().state, 'armed');
assert.equal(sandbox.seekCalls.length, 0);
assert.equal(await api.startLoop({ source: 'ui' }), true);
}
assert.equal(api.getLoopState().state, 'active');
assert.equal(sandbox.seekCalls.filter(call => call.reason === 'loop-start').length, 1);
if (firstPass === 'count-in') {
assert.equal(sandbox.countInCalls.length, 1);
assert.equal(sandbox.countInCalls[0].immediate, true);
assert.equal(sandbox.__playCalls, 0);
} else {
assert.equal(sandbox.countInCalls.length, 0);
assert.equal(sandbox.__playCalls, 1);
}
sandbox.S.isPlaying = true;
sandbox.countInCalls.length = 0;
sandbox.seekCalls.length = 0;
sandbox.loopEvents.length = 0;
assert.equal(await api.handleLoopBoundary(12), true);
if (repeat === 'count-in') {
assert.equal(sandbox.countInCalls.length, 1);
assert.equal(!!sandbox.countInCalls[0].immediate, false);
assert.equal(sandbox.seekCalls.length, 0);
} else {
assert.equal(sandbox.countInCalls.length, 0);
assert.equal(sandbox.seekCalls.length, 1);
assert.equal(sandbox.seekCalls[0].reason, 'loop-wrap-continuous');
assert.equal(sandbox.loopEvents[0].name, 'song:seek');
assert.equal(sandbox.loopEvents[1].name, 'loop:restart');
}
});
}
}
}
});
test('pause/resume of an active loop does not replay the initial count-in', async () => {
const { sandbox, api } = buildHarness({
activation: 'auto',
firstPass: 'count-in',
repeat: 'continuous',
});
await api.setLoop(2, 6, { activation: 'preference' });
assert.equal(sandbox.countInCalls.length, 1);
sandbox.S.isPlaying = false;
assert.equal(await api.handleLoopBoundary(6), false);
sandbox.S.isPlaying = true;
assert.equal(await api.handleLoopBoundary(5), false);
assert.equal(sandbox.countInCalls.length, 1);
});
test('initial count-in pauses backing before seeking A without pausing twice', async () => {
const { sandbox, api } = buildHarness({
activation: 'auto',
firstPass: 'count-in',
repeat: 'count-in',
}, { isPlaying: true });
assert.equal(await api.setLoop(3, 7, { activation: 'preference' }), true);
assert.deepEqual(
Array.from(sandbox.operationCalls),
['pause-count-in-backing', 'seek:loop-start', 'start-count-in'],
);
assert.equal(sandbox.countInCalls.length, 1);
assert.equal(sandbox.countInCalls[0].backingAlreadyPaused, true);
sandbox.operationCalls.length = 0;
sandbox.countInCalls.length = 0;
sandbox.S.isPlaying = true;
assert.equal(await api.handleLoopBoundary(7), true);
assert.deepEqual(Array.from(sandbox.operationCalls), ['start-count-in']);
assert.equal(!!sandbox.countInCalls[0].backingAlreadyPaused, false);
});
test('clear cancels a pending automatic start and cannot activate stale bounds', async () => {
const { sandbox, api } = buildHarness({
activation: 'auto',
firstPass: 'immediate',
repeat: 'continuous',
});
let release;
sandbox._audioSeek = (seconds, reason, options = {}) => new Promise(resolve => {
release = () => {
const allowed = typeof options.guard !== 'function' || options.guard();
resolve(allowed
? { completed: true, from: 20, to: seconds }
: { completed: false, from: NaN, to: NaN });
};
});
const pending = api.setLoop(3, 7, { activation: 'preference' });
api.clearLoop();
release();
await pending;
assert.equal(api.getLoopState().state, 'inactive');
assert.equal(api.getLoopState().loopA, null);
assert.equal(sandbox.__playCalls, 0);
});
test('arrangement cancellation leaves a pending selection armed, never stale-active', async () => {
const { sandbox, api } = buildHarness({
activation: 'auto',
firstPass: 'immediate',
});
let release;
sandbox._audioSeek = (seconds, reason, options = {}) => new Promise(resolve => {
release = () => resolve({
completed: typeof options.guard !== 'function' || options.guard(),
from: 20,
to: seconds,
});
});
const pending = api.setLoop(4, 8, { activation: 'preference', source: 'section' });
api.cancelLoopOperations();
release();
await pending;
assert.equal(api.getLoopState().state, 'armed');
assert.equal(sandbox.__playCalls, 0);
});
test('rapid loop selections only allow the newest start operation to activate', async () => {
const { sandbox, api } = buildHarness({
activation: 'auto',
firstPass: 'immediate',
});
const pendingSeeks = [];
sandbox._audioSeek = (seconds, reason, options = {}) => new Promise(resolve => {
pendingSeeks.push({
seconds,
release() {
const allowed = typeof options.guard !== 'function' || options.guard();
resolve(allowed
? { completed: true, from: 20, to: seconds }
: { completed: false, from: NaN, to: NaN });
},
});
});
const first = api.setLoop(1, 5, { activation: 'preference', source: 'section' });
const second = api.setLoop(10, 15, { activation: 'preference', source: 'saved' });
assert.equal(pendingSeeks.length, 2);
pendingSeeks[0].release();
await first;
assert.equal(api.getLoopState().loopA, 10);
assert.notEqual(api.getLoopState().state, 'active');
pendingSeeks[1].release();
await second;
assert.equal(api.getLoopState().loopA, 10);
assert.equal(api.getLoopState().loopB, 15);
assert.equal(api.getLoopState().state, 'active');
assert.equal(sandbox.__playCalls, 1);
});
test('continuous wrapping uses the canonical seek path in HTML5 and mocked JUCE modes', async (t) => {
for (const juce of [false, true]) {
await t.test(juce ? 'JUCE' : 'HTML5', async () => {
const { sandbox, api } = buildHarness({
activation: 'auto',
firstPass: 'immediate',
repeat: 'continuous',
}, { juce });
await api.setLoop(5, 9, { activation: 'preference' });
sandbox.S.isPlaying = true;
sandbox.seekCalls.length = 0;
assert.equal(await api.handleLoopBoundary(9), true);
assert.equal(sandbox.seekCalls.length, 1);
assert.equal(sandbox.seekCalls[0].seconds, 5);
assert.equal(sandbox.seekCalls[0].reason, 'loop-wrap-continuous');
assert.equal(sandbox.seekCalls[0].juce, juce);
});
}
});
test('configured and cleared transport events are emitted once with armed/active state', async () => {
const { sandbox, api } = buildHarness({ activation: 'arm' });
await api.setLoop(5, 10, { activation: 'preference' });
assert.equal(sandbox.transportEvents.length, 1);
assert.equal(sandbox.transportEvents[0].name, 'loop-set');
assert.equal(sandbox.transportEvents[0].detail.loop.state, 'armed');
assert.equal(sandbox.transportEvents[0].detail.loop.enabled, false);
api.clearLoop();
assert.equal(sandbox.transportEvents.length, 2);
assert.equal(sandbox.transportEvents[1].name, 'loop-cleared');
});