mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 04:44: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
@@ -789,3 +789,15 @@
|
|||||||
}
|
}
|
||||||
.pp-giglog-row { font-size: 0.7rem; color: #6d5d40; padding: 0.1rem 0; }
|
.pp-giglog-row { font-size: 0.7rem; color: #6d5d40; padding: 0.1rem 0; }
|
||||||
.pp-giglog-row b { color: #9a5b16; letter-spacing: 0.06em; }
|
.pp-giglog-row b { color: #9a5b16; letter-spacing: 0.06em; }
|
||||||
|
|
||||||
|
/* Tuning preference pills on gig poster */
|
||||||
|
.pp-tuning-row { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin: 0.5rem 0 0.3rem; }
|
||||||
|
.pp-tuning-pill { font-size: 0.65rem; padding: 0.2rem 0.6rem; border-radius: 999px; opacity: 0.7; }
|
||||||
|
.pp-tuning-pill-on { opacity: 1; border-color: #d9a253; color: #d9a253; }
|
||||||
|
.pp-tuning-select { font-size: 0.7rem; background: #1a1510; color: #c8b48a; border: 1px solid rgba(138,122,94,0.5); border-radius: 4px; padding: 0.15rem 0.4rem; }
|
||||||
|
.pp-poster-tuning-chip { font-size: 0.6rem; color: #8a7a5e; margin-left: 0.35rem; }
|
||||||
|
|
||||||
|
/* Gig interstitial — tune up banner */
|
||||||
|
.pp-gig-strip.pp-interstitial { pointer-events: auto; display: flex; align-items: center; gap: 0.75rem; border-radius: 8px; border-color: rgba(64,128,224,0.5); }
|
||||||
|
.pp-gig-tune-label { color: #4080e0; letter-spacing: 0.08em; font-size: 0.78rem; }
|
||||||
|
.pp-gig-start-btn { font-size: 0.7rem; padding: 0.25rem 0.7rem; }
|
||||||
|
|||||||
@@ -529,7 +529,7 @@ def _current_venue():
|
|||||||
return best
|
return best
|
||||||
|
|
||||||
|
|
||||||
def _fill_genre_songs(gkey, exclude, limit):
|
def _fill_genre_songs(gkey, exclude, limit, tuning_ok=None):
|
||||||
"""Library songs of a genre to round out a gig — ANY song of the genre the
|
"""Library songs of a genre to round out a gig — ANY song of the genre the
|
||||||
set hasn't already picked.
|
set hasn't already picked.
|
||||||
|
|
||||||
@@ -553,17 +553,41 @@ def _fill_genre_songs(gkey, exclude, limit):
|
|||||||
if db is None:
|
if db is None:
|
||||||
return []
|
return []
|
||||||
rows = db.conn.execute(
|
rows = db.conn.execute(
|
||||||
f"SELECT filename, title, artist, {_genre_expr(db)} AS g FROM songs"
|
f"SELECT filename, title, artist, {_genre_expr(db)} AS g, tuning_name FROM songs"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
pool = [
|
pool = [
|
||||||
{"filename": filename, "title": title or filename, "artist": artist or ""}
|
{"filename": fn, "title": title or fn, "artist": artist or "", "tuning_name": tn or ""}
|
||||||
for filename, title, artist, genre in rows
|
for fn, title, artist, genre, tn in rows
|
||||||
if _genre_key(genre) == gkey and filename not in exclude
|
if _genre_key(genre) == gkey and fn not in exclude
|
||||||
|
and (tuning_ok is None or tuning_ok(tn or ""))
|
||||||
]
|
]
|
||||||
random.shuffle(pool) # re-roll must vary; free per call
|
random.shuffle(pool) # re-roll must vary; free per call
|
||||||
return pool[:limit]
|
return pool[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def _tuning_ok_fn(tuning_pref):
|
||||||
|
"""Return a (tuning_name: str) -> bool callable for the given preference.
|
||||||
|
|
||||||
|
Returns None for 'any' (no filter). 'standard' matches names ending in
|
||||||
|
' Standard'; 'drop' matches names containing 'Drop' (covers Drop D, Drop C,
|
||||||
|
Double Drop D, etc.); 'specific:<name>' matches exact names. Unknown or
|
||||||
|
malformed prefs treat as 'any' rather than hard-failing — a stale client
|
||||||
|
request must not break gig booking.
|
||||||
|
"""
|
||||||
|
if not tuning_pref or tuning_pref == "any":
|
||||||
|
return None
|
||||||
|
if tuning_pref == "standard":
|
||||||
|
return lambda n: bool(n) and n.endswith(" Standard")
|
||||||
|
if tuning_pref == "drop":
|
||||||
|
return lambda n: bool(n) and "Drop" in n
|
||||||
|
if tuning_pref.startswith("specific:"):
|
||||||
|
spec = tuning_pref[len("specific:"):]
|
||||||
|
if not spec or len(spec) > 64:
|
||||||
|
return None
|
||||||
|
return lambda n, _s=spec: n == _s
|
||||||
|
return None # unknown pref → no filter
|
||||||
|
|
||||||
|
|
||||||
def _validate_pack_dir(pack_dir: Path):
|
def _validate_pack_dir(pack_dir: Path):
|
||||||
"""Raise ValueError unless pack_dir holds a complete venue pack."""
|
"""Raise ValueError unless pack_dir holds a complete venue pack."""
|
||||||
manifest_path = pack_dir / "manifest.json"
|
manifest_path = pack_dir / "manifest.json"
|
||||||
@@ -823,6 +847,8 @@ def setup(app, context):
|
|||||||
raise HTTPException(400, "Unknown instrument.")
|
raise HTTPException(400, "Unknown instrument.")
|
||||||
if not gkey or len(genre) > GENRE_MAX_LEN:
|
if not gkey or len(genre) > GENRE_MAX_LEN:
|
||||||
raise HTTPException(400, "Provide a genre.")
|
raise HTTPException(400, "Provide a genre.")
|
||||||
|
tuning_pref = str((body or {}).get("tuning_pref") or "any")
|
||||||
|
tuning_ok = _tuning_ok_fn(tuning_pref)
|
||||||
cfg = _gig_config()
|
cfg = _gig_config()
|
||||||
try:
|
try:
|
||||||
size = int((body or {}).get("size") or 4)
|
size = int((body or {}).get("size") or 4)
|
||||||
@@ -831,6 +857,18 @@ def setup(app, context):
|
|||||||
size = max(cfg["min_songs"], min(cfg["max_songs"], size))
|
size = max(cfg["min_songs"], min(cfg["max_songs"], size))
|
||||||
played, _seconds = _played_by_instrument_genre()
|
played, _seconds = _played_by_instrument_genre()
|
||||||
stubs = list(played.get((inst, gkey), {}).values())
|
stubs = list(played.get((inst, gkey), {}).values())
|
||||||
|
# Annotate stubs with tuning_name (batch lookup to avoid N+1).
|
||||||
|
if stubs and _state["meta_db"] is not None:
|
||||||
|
fns = [s["filename"] for s in stubs]
|
||||||
|
ph = ",".join("?" * len(fns))
|
||||||
|
tn_rows = _state["meta_db"].conn.execute(
|
||||||
|
f"SELECT filename, tuning_name FROM songs WHERE filename IN ({ph})", fns
|
||||||
|
).fetchall()
|
||||||
|
tn_by_file = {fn: (tn or "") for fn, tn in tn_rows}
|
||||||
|
for s in stubs:
|
||||||
|
s.setdefault("tuning_name", tn_by_file.get(s["filename"], ""))
|
||||||
|
if tuning_ok is not None:
|
||||||
|
stubs = [s for s in stubs if tuning_ok(s.get("tuning_name", ""))]
|
||||||
req = _badge_requirement(gkey, inst)
|
req = _badge_requirement(gkey, inst)
|
||||||
qualifying = [s for s in stubs if s["stars"] >= req["min_stars"]]
|
qualifying = [s for s in stubs if s["stars"] >= req["min_stars"]]
|
||||||
rest = [s for s in stubs if s["stars"] < req["min_stars"]]
|
rest = [s for s in stubs if s["stars"] < req["min_stars"]]
|
||||||
@@ -855,18 +893,26 @@ def setup(app, context):
|
|||||||
picks.append(s)
|
picks.append(s)
|
||||||
if len(picks) < size:
|
if len(picks) < size:
|
||||||
exclude = {s["filename"] for s in picks}
|
exclude = {s["filename"] for s in picks}
|
||||||
picks.extend(_fill_genre_songs(gkey, exclude, size - len(picks)))
|
picks.extend(_fill_genre_songs(gkey, exclude, size - len(picks), tuning_ok))
|
||||||
if not picks:
|
if not picks:
|
||||||
|
if tuning_pref and tuning_pref != "any":
|
||||||
|
label = ("standard-tuning " if tuning_pref == "standard"
|
||||||
|
else "drop-tuning " if tuning_pref == "drop"
|
||||||
|
else f"“{tuning_pref[len('specific:'):]}”-tuning "
|
||||||
|
if tuning_pref.startswith("specific:") else "")
|
||||||
|
raise HTTPException(404, f"No {label}songs of this genre in the library.")
|
||||||
raise HTTPException(404, "No songs of this genre in the library.")
|
raise HTTPException(404, "No songs of this genre in the library.")
|
||||||
venue = _current_venue()
|
venue = _current_venue()
|
||||||
return {
|
return {
|
||||||
"instrument": inst,
|
"instrument": inst,
|
||||||
"genre": genre,
|
"genre": genre,
|
||||||
"genre_key": gkey,
|
"genre_key": gkey,
|
||||||
|
"tuning_pref": tuning_pref,
|
||||||
"venue_id": venue["id"] if venue else None,
|
"venue_id": venue["id"] if venue else None,
|
||||||
"venue_name": venue["name"] if venue else "",
|
"venue_name": venue["name"] if venue else "",
|
||||||
"songs": [{"filename": s["filename"], "title": s.get("title") or s["filename"],
|
"songs": [{"filename": s["filename"], "title": s.get("title") or s["filename"],
|
||||||
"artist": s.get("artist") or ""} for s in picks[:size]],
|
"artist": s.get("artist") or "", "tuning_name": s.get("tuning_name") or ""}
|
||||||
|
for s in picks[:size]],
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs")
|
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs")
|
||||||
@@ -935,6 +981,27 @@ def setup(app, context):
|
|||||||
_save_json(_state_file(), st)
|
_save_json(_state_file(), st)
|
||||||
return {"ok": True, "gig": gig}
|
return {"ok": True, "gig": gig}
|
||||||
|
|
||||||
|
@app.get(f"/api/plugins/{PLUGIN_ID}/gigs/tunings")
|
||||||
|
def gig_tunings(genre: str = ""):
|
||||||
|
"""Distinct tuning names present in a genre's song pool, for the
|
||||||
|
specific-tuning picker in the gig poster UI. Sorted by the library's
|
||||||
|
own tuning_sort_key so the list matches the main library tuning filter."""
|
||||||
|
gkey = _genre_key(_genre_display(genre)) if genre else ""
|
||||||
|
if not gkey:
|
||||||
|
return {"tunings": []}
|
||||||
|
db = _state["meta_db"]
|
||||||
|
if db is None:
|
||||||
|
return {"tunings": []}
|
||||||
|
rows = db.conn.execute(
|
||||||
|
f"SELECT tuning_name, tuning_sort_key, {_genre_expr(db)} AS g "
|
||||||
|
"FROM songs WHERE tuning_name != ''"
|
||||||
|
).fetchall()
|
||||||
|
seen: dict[str, int] = {}
|
||||||
|
for tn, sk, genre_raw in rows:
|
||||||
|
if _genre_key(genre_raw) == gkey and tn and tn not in seen:
|
||||||
|
seen[tn] = sk or 0
|
||||||
|
return {"tunings": sorted(seen.keys(), key=lambda n: (seen[n], n))}
|
||||||
|
|
||||||
@app.post(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}/download")
|
@app.post(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}/download")
|
||||||
def start_download(venue_id: str):
|
def start_download(venue_id: str):
|
||||||
venue = _venue(venue_id) if VENUE_ID_RE.fullmatch(venue_id) else None
|
venue = _venue(venue_id) if VENUE_ID_RE.fullmatch(venue_id) else None
|
||||||
|
|||||||
+129
-7
@@ -26,6 +26,7 @@
|
|||||||
const PP_SEEN_KEY = 'feedBack-career-badges-seen';
|
const PP_SEEN_KEY = 'feedBack-career-badges-seen';
|
||||||
const PP_INST_KEY = 'feedBack-career-instrument';
|
const PP_INST_KEY = 'feedBack-career-instrument';
|
||||||
const PP_TAB_KEY = 'feedBack-career-tab';
|
const PP_TAB_KEY = 'feedBack-career-tab';
|
||||||
|
const PP_TUNING_PREF_KEY = 'feedBack-career-tuning-pref';
|
||||||
const PP_LABELS = { guitar: 'Guitar', bass: 'Bass', keys: 'Keys', drums: 'Drums' };
|
const PP_LABELS = { guitar: 'Guitar', bass: 'Bass', keys: 'Keys', drums: 'Drums' };
|
||||||
const PP_BROCHURE_ART = ['🎸', '🎷', '🎹', '🥁', '🎺', '🎻', '🎤', '🪕'];
|
const PP_BROCHURE_ART = ['🎸', '🎷', '🎹', '🥁', '🎺', '🎻', '🎤', '🪕'];
|
||||||
|
|
||||||
@@ -43,7 +44,11 @@
|
|||||||
let _ppBootstrapped = false;
|
let _ppBootstrapped = false;
|
||||||
let _ppNotified = {}; // badges chimed this session (slam still pending)
|
let _ppNotified = {}; // badges chimed this session (slam still pending)
|
||||||
let _ppGigProposal = null; // the booking poster's proposal, while open
|
let _ppGigProposal = null; // the booking poster's proposal, while open
|
||||||
let _ppGigRun = null; // {songs, venue_id, genre, genre_key, instrument, idx} mid-set
|
let _ppGigRun = null; // {songs, venue_id, genre, genre_key, instrument, tuning_pref, idx} mid-set
|
||||||
|
let _ppGigTuningPref = 'any'; // loaded from localStorage in boot()
|
||||||
|
let _ppGigTuningNames = []; // cached distinct tuning names for the specific picker
|
||||||
|
let _ppGigTuningHold = null; // holdAutoplay release fn — non-null = interstitial active
|
||||||
|
let _ppGigLastTuning = null; // tuning_name of the current gig song (for change detection)
|
||||||
|
|
||||||
function $(id) { return document.getElementById(id); }
|
function $(id) { return document.getElementById(id); }
|
||||||
|
|
||||||
@@ -1085,13 +1090,25 @@
|
|||||||
|
|
||||||
function gigPosterHTML(prop) {
|
function gigPosterHTML(prop) {
|
||||||
const bill = prop.songs.map((s, i) =>
|
const bill = prop.songs.map((s, i) =>
|
||||||
`<div class="pp-poster-line"><span>${i + 1}.</span> ${esc(s.title)}${s.artist ? ` <em>${esc(s.artist)}</em>` : ''}</div>`).join('');
|
`<div class="pp-poster-line"><span>${i + 1}.</span> ${esc(s.title)}${s.artist ? ` <em>${esc(s.artist)}</em>` : ''}${s.tuning_name ? ` <small class="pp-poster-tuning-chip">${esc(s.tuning_name)}</small>` : ''}</div>`).join('');
|
||||||
|
const PREF_LABELS = { any: 'Any', standard: 'Standard', drop: 'Drop', specific: 'Specific…' };
|
||||||
|
const isSpecific = _ppGigTuningPref.startsWith('specific:');
|
||||||
|
const curPill = isSpecific ? 'specific' : _ppGigTuningPref;
|
||||||
|
const pills = Object.keys(PREF_LABELS).map((p) =>
|
||||||
|
`<button data-pp-tuning="${esc(p)}" class="career-btn career-btn-ghost pp-tuning-pill${curPill === p ? ' pp-tuning-pill-on' : ''}">${PREF_LABELS[p]}</button>`
|
||||||
|
).join('');
|
||||||
|
const selVal = isSpecific ? _ppGigTuningPref.slice('specific:'.length) : '';
|
||||||
|
const selOpts = _ppGigTuningNames.length
|
||||||
|
? `<option value="">Choose tuning…</option>${_ppGigTuningNames.map((n) => `<option value="${esc(n)}"${n === selVal ? ' selected' : ''}>${esc(n)}</option>`).join('')}`
|
||||||
|
: `<option value="">Loading…</option>`;
|
||||||
|
const selHidden = isSpecific ? '' : ' hidden';
|
||||||
return `<div class="pp-book-wrap" data-pp-close-bg="1" role="dialog" aria-modal="true" aria-label="Gig poster">
|
return `<div class="pp-book-wrap" data-pp-close-bg="1" role="dialog" aria-modal="true" aria-label="Gig poster">
|
||||||
<div class="pp-poster">
|
<div class="pp-poster">
|
||||||
<div class="pp-poster-venue">${esc(prop.venue_name || 'The stage')}</div>
|
<div class="pp-poster-venue">${esc(prop.venue_name || 'The stage')}</div>
|
||||||
<div class="pp-poster-presents">presents</div>
|
<div class="pp-poster-presents">presents</div>
|
||||||
<div class="pp-poster-title">${esc(prop.genre.toUpperCase())} NIGHT</div>
|
<div class="pp-poster-title">${esc(prop.genre.toUpperCase())} NIGHT</div>
|
||||||
<div class="pp-poster-inst">${esc(ppLabel(prop.instrument))} · tonight</div>
|
<div class="pp-poster-inst">${esc(ppLabel(prop.instrument))} · tonight</div>
|
||||||
|
<div class="pp-tuning-row">${pills}<select data-pp-tuning-select class="pp-tuning-select${selHidden}">${selOpts}</select></div>
|
||||||
<div class="pp-poster-bill">${bill}</div>
|
<div class="pp-poster-bill">${bill}</div>
|
||||||
<div class="pp-poster-actions">
|
<div class="pp-poster-actions">
|
||||||
<button class="career-btn career-btn-primary" data-pp-gig-play="1">Play the gig</button>
|
<button class="career-btn career-btn-primary" data-pp-gig-play="1">Play the gig</button>
|
||||||
@@ -1114,9 +1131,18 @@
|
|||||||
const res = await fetch(`${API}/gigs/propose`, {
|
const res = await fetch(`${API}/gigs/propose`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ instrument: inst, genre: p.genre }),
|
body: JSON.stringify({ instrument: inst, genre: p.genre, tuning_pref: _ppGigTuningPref }),
|
||||||
});
|
});
|
||||||
if (!res.ok) return;
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
// Tuning filter yielded no songs → revert to 'any' and notify
|
||||||
|
_ppGigTuningPref = 'any';
|
||||||
|
lsSet(PP_TUNING_PREF_KEY, 'any');
|
||||||
|
if (window.fbNotify && typeof window.fbNotify.show === 'function') {
|
||||||
|
try { window.fbNotify.show({ title: 'Tuning filter', message: (err && err.detail) || 'No songs match that tuning filter.', icon: '🎸' }); } catch (_) { /* */ }
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
_ppGigProposal = await res.json();
|
_ppGigProposal = await res.json();
|
||||||
} catch (_) { return; }
|
} catch (_) { return; }
|
||||||
const overlay = $('pp-overlay');
|
const overlay = $('pp-overlay');
|
||||||
@@ -1127,6 +1153,26 @@
|
|||||||
sfx('page');
|
sfx('page');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function _openSpecificTuningPicker() {
|
||||||
|
const overlay = $('pp-overlay');
|
||||||
|
if (!overlay || !_ppGigProposal) return;
|
||||||
|
if (_ppGigTuningNames.length) {
|
||||||
|
const sel = overlay.querySelector('[data-pp-tuning-select]');
|
||||||
|
if (sel) sel.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/gigs/tunings?genre=${encodeURIComponent(_ppGigProposal.genre)}`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
_ppGigTuningNames = Array.isArray(data.tunings) ? data.tunings : [];
|
||||||
|
} catch (_) { return; }
|
||||||
|
// Re-render with populated options
|
||||||
|
overlay.innerHTML = gigPosterHTML(_ppGigProposal);
|
||||||
|
const sel = overlay.querySelector('[data-pp-tuning-select]');
|
||||||
|
if (sel) sel.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
// Unpack the whole set before the first note.
|
// Unpack the whole set before the first note.
|
||||||
//
|
//
|
||||||
// A feedpak is a zip, and the first play of one pays for its extraction. In
|
// A feedpak is a zip, and the first play of one pays for its extraction. In
|
||||||
@@ -1213,9 +1259,11 @@
|
|||||||
genre: prop.genre,
|
genre: prop.genre,
|
||||||
genre_key: prop.genre_key,
|
genre_key: prop.genre_key,
|
||||||
instrument: prop.instrument,
|
instrument: prop.instrument,
|
||||||
|
tuning_pref: prop.tuning_pref || 'any',
|
||||||
idx: 0,
|
idx: 0,
|
||||||
restore,
|
restore,
|
||||||
};
|
};
|
||||||
|
_ppGigLastTuning = null; // reset for fresh interstitial tracking
|
||||||
closeBook();
|
closeBook();
|
||||||
_ppGigProposal = null;
|
_ppGigProposal = null;
|
||||||
// RAW filenames: the queue itself encodes for playSong — pre-encoding
|
// RAW filenames: the queue itself encodes for playSong — pre-encoding
|
||||||
@@ -1251,8 +1299,14 @@
|
|||||||
document.body.appendChild(strip);
|
document.body.appendChild(strip);
|
||||||
}
|
}
|
||||||
const run = _ppGigRun;
|
const run = _ppGigRun;
|
||||||
const next = run.songs[run.idx + 1];
|
if (_ppGigTuningHold) {
|
||||||
strip.innerHTML = `<b>GIG</b> · ${esc(run.genre)} at ${esc(_venueName(run.venue_id))} · set ${Math.min(run.idx + 1, run.songs.length)}/${run.songs.length}${next ? ` — next: <em>${esc(next.title)}</em>` : ' — closer!'}`;
|
const song = run.songs[run.idx];
|
||||||
|
const tuning = (song && song.tuning_name) ? esc(song.tuning_name) : 'check tuning';
|
||||||
|
strip.innerHTML = `<b class="pp-gig-tune-label">Tune to: ${tuning}</b><button data-pp-gig-start-song="1" class="career-btn career-btn-primary pp-gig-start-btn">Start song</button>`;
|
||||||
|
} else {
|
||||||
|
const next = run.songs[run.idx + 1];
|
||||||
|
strip.innerHTML = `<b>GIG</b> · ${esc(run.genre)} at ${esc(_venueName(run.venue_id))} · set ${Math.min(run.idx + 1, run.songs.length)}/${run.songs.length}${next ? ` — next: <em>${esc(next.title)}</em>` : ' — closer!'}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeGigStrip() {
|
function removeGigStrip() {
|
||||||
@@ -1264,6 +1318,8 @@
|
|||||||
// No fail state: an abandoned set logs nothing and says nothing.
|
// No fail state: an abandoned set logs nothing and says nothing.
|
||||||
const run = _ppGigRun;
|
const run = _ppGigRun;
|
||||||
_ppGigRun = null;
|
_ppGigRun = null;
|
||||||
|
_ppGigLastTuning = null;
|
||||||
|
if (_ppGigTuningHold) { const h = _ppGigTuningHold; _ppGigTuningHold = null; h(); }
|
||||||
removeGigStrip();
|
removeGigStrip();
|
||||||
restoreGigStage(run);
|
restoreGigStage(run);
|
||||||
}
|
}
|
||||||
@@ -1403,6 +1459,32 @@
|
|||||||
// Queue lifecycle: advance the strip per song; complete or abandon.
|
// Queue lifecycle: advance the strip per song; complete or abandon.
|
||||||
function onGigSongLoading() {
|
function onGigSongLoading() {
|
||||||
if (!_ppGigRun) return;
|
if (!_ppGigRun) return;
|
||||||
|
const run = _ppGigRun;
|
||||||
|
const song = run.songs[run.idx];
|
||||||
|
const tuningName = (song && song.tuning_name) || '';
|
||||||
|
const pref = run.tuning_pref || 'any';
|
||||||
|
// Interstitial: pause before first song (or when tuning changes) for all
|
||||||
|
// non-specific prefs, so the player has time to retune. "specific" is
|
||||||
|
// excluded because every song already matches one fixed tuning.
|
||||||
|
const needsInterstitial = pref !== 'specific' && (
|
||||||
|
run.idx === 0 || tuningName !== _ppGigLastTuning
|
||||||
|
);
|
||||||
|
_ppGigLastTuning = tuningName;
|
||||||
|
if (needsInterstitial) {
|
||||||
|
const fb = window.feedBack;
|
||||||
|
const holdFn = fb && typeof fb.holdAutoplay === 'function' ? fb.holdAutoplay : null;
|
||||||
|
_ppGigTuningHold = holdFn ? holdFn() : null;
|
||||||
|
if (_ppGigTuningHold) {
|
||||||
|
// Cancel the fail-open backstop — we manage the dismiss ourselves
|
||||||
|
// (user clicks "Start song"). A song navigation clears the hold anyway.
|
||||||
|
_ppGigTuningHold.settle();
|
||||||
|
}
|
||||||
|
if (_ppGigTuningHold && window.tuner && typeof window.tuner.enable === 'function') {
|
||||||
|
window.tuner.enable({ auto: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_ppGigTuningHold = null;
|
||||||
|
}
|
||||||
renderGigStrip();
|
renderGigStrip();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1482,6 +1564,28 @@
|
|||||||
closeBook();
|
closeBook();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Tuning pref pill on the gig poster
|
||||||
|
const tuningPill = e.target.closest('[data-pp-tuning]');
|
||||||
|
if (tuningPill) {
|
||||||
|
const pref = tuningPill.dataset.ppTuning;
|
||||||
|
if (pref === 'specific') {
|
||||||
|
_openSpecificTuningPicker();
|
||||||
|
} else {
|
||||||
|
_ppGigTuningPref = pref;
|
||||||
|
lsSet(PP_TUNING_PREF_KEY, pref);
|
||||||
|
_ppGigTuningNames = []; // reset specific cache on pref change
|
||||||
|
if (_ppGigProposal) bookGig(_ppGigProposal.genre_key);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// "Start song" interstitial button (mid-gig tuning pause)
|
||||||
|
if (e.target.closest('[data-pp-gig-start-song]') && _ppGigTuningHold) {
|
||||||
|
const release = _ppGigTuningHold;
|
||||||
|
_ppGigTuningHold = null;
|
||||||
|
renderGigStrip();
|
||||||
|
release();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const gigBtn = e.target.closest('[data-pp-gig]');
|
const gigBtn = e.target.closest('[data-pp-gig]');
|
||||||
if (gigBtn) { bookGig(gigBtn.dataset.ppGig); return; }
|
if (gigBtn) { bookGig(gigBtn.dataset.ppGig); return; }
|
||||||
if (e.target.closest('[data-pp-gig-play]')) { startGig(e.target.closest('[data-pp-gig-play]')); return; }
|
if (e.target.closest('[data-pp-gig-play]')) { startGig(e.target.closest('[data-pp-gig-play]')); return; }
|
||||||
@@ -1539,11 +1643,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function boot() {
|
function boot() {
|
||||||
|
// Restore persisted tuning preference
|
||||||
|
_ppGigTuningPref = lsGet(PP_TUNING_PREF_KEY) || 'any';
|
||||||
const screen = document.getElementById('plugin-career');
|
const screen = document.getElementById('plugin-career');
|
||||||
if (screen) {
|
if (screen) {
|
||||||
screen.addEventListener('click', onClick);
|
screen.addEventListener('click', onClick);
|
||||||
screen.addEventListener('pointermove', onTiltMove);
|
screen.addEventListener('pointermove', onTiltMove);
|
||||||
screen.addEventListener('pointerleave', onTiltLeave);
|
screen.addEventListener('pointerleave', onTiltLeave);
|
||||||
|
// Specific-tuning select change: rebook with the chosen tuning
|
||||||
|
screen.addEventListener('change', (e) => {
|
||||||
|
const sel = e.target.closest('[data-pp-tuning-select]');
|
||||||
|
if (!sel || !_ppGigProposal) return;
|
||||||
|
const val = sel.value;
|
||||||
|
if (!val) return;
|
||||||
|
_ppGigTuningPref = 'specific:' + val;
|
||||||
|
lsSet(PP_TUNING_PREF_KEY, _ppGigTuningPref);
|
||||||
|
bookGig(_ppGigProposal.genre_key);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const sm = window.feedBack;
|
const sm = window.feedBack;
|
||||||
if (sm && typeof sm.on === 'function') {
|
if (sm && typeof sm.on === 'function') {
|
||||||
@@ -1576,10 +1692,16 @@
|
|||||||
window.__careerPassportTest = {
|
window.__careerPassportTest = {
|
||||||
ppKey, ppJitter, ppLabel, detectNewBadges, seenBadges, markBadgeSeen,
|
ppKey, ppJitter, ppLabel, detectNewBadges, seenBadges, markBadgeSeen,
|
||||||
fmtHours, ppFillFraction, careerTotals, closestAskHTML,
|
fmtHours, ppFillFraction, careerTotals, closestAskHTML,
|
||||||
onGigSongEnded, onGigSongStop,
|
onGigSongEnded, onGigSongStop, onGigSongLoading,
|
||||||
setGigRun(r) { _ppGigRun = r; },
|
setGigRun(r) { _ppGigRun = r; },
|
||||||
getGigRun() { return _ppGigRun; },
|
getGigRun() { return _ppGigRun; },
|
||||||
setView(v) { _pp = v; },
|
setView(v) { _pp = v; },
|
||||||
|
getTuningHold() { return _ppGigTuningHold; },
|
||||||
|
setTuningHold(h) { _ppGigTuningHold = h; },
|
||||||
|
getTuningPref() { return _ppGigTuningPref; },
|
||||||
|
setTuningPref(p) { _ppGigTuningPref = p; },
|
||||||
|
getLastTuning() { return _ppGigLastTuning; },
|
||||||
|
setLastTuning(t) { _ppGigLastTuning = t; },
|
||||||
};
|
};
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
|
|||||||
@@ -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(
|
self.conn.execute(
|
||||||
"""CREATE TABLE songs (
|
"""CREATE TABLE songs (
|
||||||
filename TEXT, title TEXT, artist TEXT,
|
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))
|
last_played_at, seconds_total))
|
||||||
if in_library:
|
if in_library:
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"INSERT INTO songs SELECT ?, ?, ?, ?, ? WHERE NOT EXISTS "
|
"INSERT INTO songs SELECT ?, ?, ?, ?, ?, '', 0 WHERE NOT EXISTS "
|
||||||
"(SELECT 1 FROM songs WHERE filename = ?)",
|
"(SELECT 1 FROM songs WHERE filename = ?)",
|
||||||
(filename, filename.replace(".feedpak", "").title(), "Test Artist",
|
(filename, filename.replace(".feedpak", "").title(), "Test Artist",
|
||||||
genre,
|
genre,
|
||||||
@@ -54,10 +55,10 @@ class FakeMetaDb:
|
|||||||
filename))
|
filename))
|
||||||
self.conn.commit()
|
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."""
|
"""A library song with no plays — feeds the genre (brochure) list."""
|
||||||
self.conn.execute("INSERT INTO songs VALUES (?, ?, ?, ?, ?)",
|
self.conn.execute("INSERT INTO songs VALUES (?, ?, ?, ?, ?, ?, 0)",
|
||||||
(filename, filename, "Test Artist", genre, None))
|
(filename, filename, "Test Artist", genre, None, tuning_name))
|
||||||
self.conn.commit()
|
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