mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 06:54:31 +00:00
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:
co-authored by
Claude Sonnet 4.6
parent
a57b62378c
commit
2a455702b8
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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 == []
|
||||
Reference in New Issue
Block a user