Files
feedBack/tests/test_career_gig_tuning.py
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

131 lines
4.7 KiB
Python

"""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 == []