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
This commit is contained in:
byrongamatos
2026-09-03 17:52:30 +02:00
co-authored by Claude Sonnet 4.6
parent a57b62378c
commit 2a455702b8
6 changed files with 519 additions and 19 deletions
+168
View File
@@ -0,0 +1,168 @@
/**
* 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);
});
});
+6 -5
View File
@@ -33,7 +33,8 @@ class FakeMetaDb:
self.conn.execute(
"""CREATE TABLE songs (
filename TEXT, title TEXT, artist TEXT,
genre TEXT DEFAULT '', arrangements TEXT
genre TEXT DEFAULT '', arrangements TEXT,
tuning_name TEXT DEFAULT '', tuning_sort_key INTEGER DEFAULT 0
)"""
)
@@ -46,7 +47,7 @@ class FakeMetaDb:
last_played_at, seconds_total))
if in_library:
self.conn.execute(
"INSERT INTO songs SELECT ?, ?, ?, ?, ? WHERE NOT EXISTS "
"INSERT INTO songs SELECT ?, ?, ?, ?, ?, '', 0 WHERE NOT EXISTS "
"(SELECT 1 FROM songs WHERE filename = ?)",
(filename, filename.replace(".feedpak", "").title(), "Test Artist",
genre,
@@ -54,10 +55,10 @@ class FakeMetaDb:
filename))
self.conn.commit()
def add_song_only(self, filename, genre=""):
def add_song_only(self, filename, genre="", tuning_name=""):
"""A library song with no plays — feeds the genre (brochure) list."""
self.conn.execute("INSERT INTO songs VALUES (?, ?, ?, ?, ?)",
(filename, filename, "Test Artist", genre, None))
self.conn.execute("INSERT INTO songs VALUES (?, ?, ?, ?, ?, ?, 0)",
(filename, filename, "Test Artist", genre, None, tuning_name))
self.conn.commit()
+130
View File
@@ -0,0 +1,130 @@
"""Tests for career gig tuning preference filtering (feedBack career-gig-tuning)."""
import importlib
import sys
import types
import pytest
# ---------------------------------------------------------------------------
# Helpers to import the career routes module in isolation
# ---------------------------------------------------------------------------
def _load_routes():
"""Import plugins/career/routes.py with minimal stubs for non-fastapi deps."""
import importlib.util, pathlib
path = pathlib.Path(__file__).parent.parent / "plugins" / "career" / "routes.py"
spec = importlib.util.spec_from_file_location("career_routes_test", path)
mod = importlib.util.module_from_spec(spec)
# Stub out lib.* deps only — fastapi IS installed and must not be stubbed
lib_stubs = ["lib.song", "lib.audio", "lib.sloppak"]
for s in lib_stubs:
if s not in sys.modules:
sys.modules[s] = types.ModuleType(s)
spec.loader.exec_module(mod)
return mod
@pytest.fixture(scope="module")
def career():
return _load_routes()
# ---------------------------------------------------------------------------
# _tuning_ok_fn — classification logic
# ---------------------------------------------------------------------------
class TestTuningOkFn:
def test_any_returns_none(self, career):
assert career._tuning_ok_fn("any") is None
def test_empty_returns_none(self, career):
assert career._tuning_ok_fn("") is None
def test_unknown_returns_none(self, career):
assert career._tuning_ok_fn("bogus") is None
def test_standard_matches_e_standard(self, career):
fn = career._tuning_ok_fn("standard")
assert fn("E Standard") is True
def test_standard_matches_eb_standard(self, career):
fn = career._tuning_ok_fn("standard")
assert fn("Eb Standard") is True
def test_standard_rejects_drop_d(self, career):
fn = career._tuning_ok_fn("standard")
assert not fn("Drop D")
def test_standard_rejects_empty(self, career):
fn = career._tuning_ok_fn("standard")
assert not fn("")
def test_drop_matches_drop_d(self, career):
fn = career._tuning_ok_fn("drop")
assert fn("Drop D") is True
def test_drop_matches_double_drop_d(self, career):
fn = career._tuning_ok_fn("drop")
assert fn("Double Drop D") is True
def test_drop_rejects_e_standard(self, career):
fn = career._tuning_ok_fn("drop")
assert not fn("E Standard")
def test_drop_rejects_empty(self, career):
fn = career._tuning_ok_fn("drop")
assert not fn("")
def test_specific_exact_match(self, career):
fn = career._tuning_ok_fn("specific:Open G")
assert fn("Open G") is True
assert not fn("Open A")
def test_specific_empty_value_returns_none(self, career):
# "specific:" with no value is degenerate — treated as any (None)
assert career._tuning_ok_fn("specific:") is None
def test_specific_too_long_returns_none(self, career):
assert career._tuning_ok_fn("specific:" + "x" * 65) is None
# ---------------------------------------------------------------------------
# _fill_genre_songs — tuning filter forwarded correctly
# ---------------------------------------------------------------------------
class TestFillGenreSongs:
"""Smoke-test that _fill_genre_songs respects tuning_ok."""
def _patch_db(self, career, rows):
fake_db = types.SimpleNamespace(
conn=types.SimpleNamespace(execute=lambda q: types.SimpleNamespace(fetchall=lambda: rows))
)
career._state["meta_db"] = fake_db
def test_no_filter_returns_all(self, career):
rows = [
("a.sloppak", "Song A", "Artist", "rock", "E Standard"),
("b.sloppak", "Song B", "Artist", "rock", "Drop D"),
]
self._patch_db(career, rows)
result = career._fill_genre_songs("rock", set(), 10, tuning_ok=None)
assert len(result) == 2
def test_standard_filter_excludes_drop(self, career):
rows = [
("a.sloppak", "Song A", "Artist", "rock", "E Standard"),
("b.sloppak", "Song B", "Artist", "rock", "Drop D"),
]
self._patch_db(career, rows)
fn = career._tuning_ok_fn("standard")
result = career._fill_genre_songs("rock", set(), 10, tuning_ok=fn)
assert len(result) == 1
assert result[0]["filename"] == "a.sloppak"
def test_empty_result_when_no_match(self, career):
rows = [("a.sloppak", "Song A", "Artist", "rock", "Drop D")]
self._patch_db(career, rows)
fn = career._tuning_ok_fn("standard")
result = career._fill_genre_songs("rock", set(), 10, tuning_ok=fn)
assert result == []