Files
feedBack/tests/js/career_gig_tuning.test.js
T
byrongamatosandClaude Sonnet 4.6 2a455702b8 feat(career): tuning preference filter + interstitial for gigs
Users can now pick a tuning preference before booking a gig:
- Any (default), Standard only, Drop only, or a specific tuning
- Backend filters the song pool (stubs + filler) by that preference
- Empty-filter case returns a 404 with a descriptive message; frontend
  reverts the pref to 'any' and shows a notification
- Interstitial pause before first song and on tuning changes (all prefs
  except 'specific') via window.feedBack.holdAutoplay(); opens the tuner
  panel in auto mode while the user retunes
- 'Specific' gigs skip interstitials (every song already shares one tuning)
- Graceful degradation: no holdAutoplay → interstitial silently skipped

New backend:
- _tuning_ok_fn helper for standard/drop/specific classification
- _fill_genre_songs accepts optional tuning_ok filter
- propose_gig batch-fetches tuning_name for played stubs, applies filter
- GET /gigs/tunings endpoint for the specific-tuning picker

Tests:
- tests/test_career_gig_tuning.py — 17 Python tests (classification, filter)
- tests/js/career_gig_tuning.test.js — 9 JS tests (interstitial logic)
- tests/plugins/career/conftest.py — songs table schema gets tuning_name

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj
2026-09-03 17:52:30 +02:00

169 lines
6.3 KiB
JavaScript

/**
* Tests for career-gig-tuning interstitial logic (feedBack career-gig-tuning).
*
* Failure inputs:
* - pref='specific' + first song → no interstitial (specific never needs a tune pause)
* - pref='any' + first song → interstitial fires
* - pref='any' + same tuning → no interstitial between songs
* - pref='any' + tuning diff → interstitial fires
* - holdAutoplay absent → interstitial gracefully skipped
*/
'use strict';
const { test, describe } = 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 ROOT = path.join(__dirname, '..', '..');
const SCREEN_JS = fs.readFileSync(
path.join(ROOT, 'plugins', 'career', 'screen.js'), 'utf8'
);
// ---------------------------------------------------------------------------
// VM harness
// ---------------------------------------------------------------------------
function makeCtx(opts = {}) {
const holdReleaseCalled = { v: false };
const holdSettleCalled = { v: false };
const feedBackBase = {
on: () => {},
emit: () => {},
holdAutoplay: opts.noHoldAutoplay ? undefined : function () {
const release = function () { holdReleaseCalled.v = true; };
release.settle = function () { holdSettleCalled.v = true; };
return release;
},
};
const ctx = vm.createContext({
window: {},
document: {
getElementById: () => null,
readyState: 'complete',
addEventListener: () => {},
},
localStorage: {
_store: {},
getItem(k) { return this._store[k] != null ? this._store[k] : null; },
setItem(k, v) { this._store[k] = String(v); },
},
clearTimeout: () => {},
setTimeout: (fn, ms) => 42,
fetch: () => Promise.resolve({ ok: false, json: async () => ({}) }),
console,
__holdReleaseCalled: holdReleaseCalled,
__holdSettleCalled: holdSettleCalled,
});
// Set window.feedBack inside the context so script-level refs pick it up
ctx.window.feedBack = feedBackBase;
vm.runInContext(SCREEN_JS, ctx);
return ctx;
}
function setRun(ctx, tuning_pref, songs) {
vm.runInContext(`
window.__careerPassportTest.setGigRun({
idx: 0,
tuning_pref: ${JSON.stringify(tuning_pref)},
songs: ${JSON.stringify(songs)},
});
`, ctx);
}
function get(ctx, expr) {
return vm.runInContext(expr, ctx);
}
function callOnLoading(ctx) {
vm.runInContext('window.__careerPassportTest.onGigSongLoading()', ctx);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('career-gig-tuning interstitial', () => {
test('no hold when gig run is null', () => {
const ctx = makeCtx();
vm.runInContext('window.__careerPassportTest.setGigRun(null)', ctx);
callOnLoading(ctx);
assert.equal(get(ctx, 'window.__careerPassportTest.getTuningHold()'), null);
});
test('first song fires interstitial for pref=any', () => {
const ctx = makeCtx();
setRun(ctx, 'any', [{ filename: 'a.sloppak', tuning_name: 'E Standard' }]);
callOnLoading(ctx);
assert.notEqual(get(ctx, 'window.__careerPassportTest.getTuningHold()'), null);
});
test('first song fires interstitial for pref=standard', () => {
const ctx = makeCtx();
setRun(ctx, 'standard', [{ filename: 'a.sloppak', tuning_name: 'E Standard' }]);
callOnLoading(ctx);
assert.notEqual(get(ctx, 'window.__careerPassportTest.getTuningHold()'), null);
});
test('first song does NOT fire interstitial for pref=specific', () => {
const ctx = makeCtx();
setRun(ctx, 'specific', [{ filename: 'a.sloppak', tuning_name: 'E Standard' }]);
callOnLoading(ctx);
assert.equal(get(ctx, 'window.__careerPassportTest.getTuningHold()'), null);
});
test('between-songs same tuning: no interstitial', () => {
const ctx = makeCtx();
const songs = [
{ filename: 'a.sloppak', tuning_name: 'E Standard' },
{ filename: 'b.sloppak', tuning_name: 'E Standard' },
];
setRun(ctx, 'any', songs);
vm.runInContext('window.__careerPassportTest.setLastTuning("E Standard")', ctx);
vm.runInContext('window.__careerPassportTest.getGigRun().idx = 1', ctx);
callOnLoading(ctx);
assert.equal(get(ctx, 'window.__careerPassportTest.getTuningHold()'), null);
});
test('between-songs tuning change: fires interstitial for pref=any', () => {
const ctx = makeCtx();
const songs = [
{ filename: 'a.sloppak', tuning_name: 'E Standard' },
{ filename: 'b.sloppak', tuning_name: 'Drop D' },
];
setRun(ctx, 'any', songs);
vm.runInContext('window.__careerPassportTest.setLastTuning("E Standard")', ctx);
vm.runInContext('window.__careerPassportTest.getGigRun().idx = 1', ctx);
callOnLoading(ctx);
assert.notEqual(get(ctx, 'window.__careerPassportTest.getTuningHold()'), null);
});
test('lastTuning is updated after onGigSongLoading', () => {
const ctx = makeCtx();
setRun(ctx, 'any', [{ filename: 'a.sloppak', tuning_name: 'Drop D' }]);
callOnLoading(ctx);
assert.equal(get(ctx, 'window.__careerPassportTest.getLastTuning()'), 'Drop D');
});
test('clearing hold via setTuningHold(null) leaves null', () => {
const ctx = makeCtx();
setRun(ctx, 'any', [{ filename: 'a.sloppak', tuning_name: 'E Standard' }]);
callOnLoading(ctx);
assert.notEqual(get(ctx, 'window.__careerPassportTest.getTuningHold()'), null);
vm.runInContext('window.__careerPassportTest.setTuningHold(null)', ctx);
assert.equal(get(ctx, 'window.__careerPassportTest.getTuningHold()'), null);
});
test('holdAutoplay unavailable: no interstitial (graceful skip)', () => {
const ctx = makeCtx({ noHoldAutoplay: true });
setRun(ctx, 'any', [{ filename: 'a.sloppak', tuning_name: 'E Standard' }]);
callOnLoading(ctx);
assert.equal(get(ctx, 'window.__careerPassportTest.getTuningHold()'), null);
});
});