mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-10 23:24:30 +00:00
Merge feat/career-gig-tuning: gig tuning preference + tuner interstitials
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0175AhnWV84XBNuLFSS1CqRT
This commit is contained in:
@@ -789,3 +789,15 @@
|
||||
}
|
||||
.pp-giglog-row { font-size: 0.7rem; color: #6d5d40; padding: 0.1rem 0; }
|
||||
.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
|
||||
|
||||
|
||||
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
|
||||
set hasn't already picked.
|
||||
|
||||
@@ -553,17 +553,41 @@ def _fill_genre_songs(gkey, exclude, limit):
|
||||
if db is None:
|
||||
return []
|
||||
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()
|
||||
pool = [
|
||||
{"filename": filename, "title": title or filename, "artist": artist or ""}
|
||||
for filename, title, artist, genre in rows
|
||||
if _genre_key(genre) == gkey and filename not in exclude
|
||||
{"filename": fn, "title": title or fn, "artist": artist or "", "tuning_name": tn or ""}
|
||||
for fn, title, artist, genre, tn in rows
|
||||
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
|
||||
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):
|
||||
"""Raise ValueError unless pack_dir holds a complete venue pack."""
|
||||
manifest_path = pack_dir / "manifest.json"
|
||||
@@ -823,6 +847,8 @@ def setup(app, context):
|
||||
raise HTTPException(400, "Unknown instrument.")
|
||||
if not gkey or len(genre) > GENRE_MAX_LEN:
|
||||
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()
|
||||
try:
|
||||
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))
|
||||
played, _seconds = _played_by_instrument_genre()
|
||||
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)
|
||||
qualifying = [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)
|
||||
if len(picks) < size:
|
||||
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 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.")
|
||||
venue = _current_venue()
|
||||
return {
|
||||
"instrument": inst,
|
||||
"genre": genre,
|
||||
"genre_key": gkey,
|
||||
"tuning_pref": tuning_pref,
|
||||
"venue_id": venue["id"] if venue else None,
|
||||
"venue_name": venue["name"] if venue else "",
|
||||
"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")
|
||||
@@ -935,6 +981,27 @@ def setup(app, context):
|
||||
_save_json(_state_file(), st)
|
||||
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")
|
||||
def start_download(venue_id: str):
|
||||
venue = _venue(venue_id) if VENUE_ID_RE.fullmatch(venue_id) else None
|
||||
|
||||
+149
-7
@@ -26,6 +26,7 @@
|
||||
const PP_SEEN_KEY = 'feedBack-career-badges-seen';
|
||||
const PP_INST_KEY = 'feedBack-career-instrument';
|
||||
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_BROCHURE_ART = ['🎸', '🎷', '🎹', '🥁', '🎺', '🎻', '🎤', '🪕'];
|
||||
|
||||
@@ -43,7 +44,12 @@
|
||||
let _ppBootstrapped = false;
|
||||
let _ppNotified = {}; // badges chimed this session (slam still pending)
|
||||
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)
|
||||
let _ppBookGen = 0; // generation counter — stale responses are discarded
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
@@ -774,6 +780,7 @@
|
||||
function closeBook() {
|
||||
_ppBook = null;
|
||||
_ppGigProposal = null; // a dismissed poster is a dismissed booking
|
||||
++_ppBookGen; // invalidate any in-flight bookGig request
|
||||
const overlay = $('pp-overlay');
|
||||
if (overlay) { overlay.classList.add('hidden'); overlay.innerHTML = ''; }
|
||||
if (_ppReturnFocus && typeof _ppReturnFocus.focus === 'function' &&
|
||||
@@ -1085,13 +1092,25 @@
|
||||
|
||||
function gigPosterHTML(prop) {
|
||||
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">
|
||||
<div class="pp-poster">
|
||||
<div class="pp-poster-venue">${esc(prop.venue_name || 'The stage')}</div>
|
||||
<div class="pp-poster-presents">presents</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-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-actions">
|
||||
<button class="career-btn career-btn-primary" data-pp-gig-play="1">Play the gig</button>
|
||||
@@ -1110,15 +1129,38 @@
|
||||
const p = (((_pp.instruments || {})[inst] || {}).passports || [])
|
||||
.find((x) => x.genre_key === gkey);
|
||||
if (!p) return;
|
||||
const gen = ++_ppBookGen; // F1: capture generation before await — stale responses discarded
|
||||
try {
|
||||
const res = await fetch(`${API}/gigs/propose`, {
|
||||
method: 'POST',
|
||||
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 (gen !== _ppBookGen) return; // stale response — a newer request supersedes this one
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
if (gen !== _ppBookGen) return; // stale — superseded while awaiting error json()
|
||||
if (res.status === 404) {
|
||||
// No-match 404: revert tuning pref to 'any', re-render poster to match
|
||||
_ppGigTuningPref = 'any';
|
||||
lsSet(PP_TUNING_PREF_KEY, 'any');
|
||||
if (_ppGigProposal) {
|
||||
const overlay = $('pp-overlay');
|
||||
if (overlay) overlay.innerHTML = gigPosterHTML(_ppGigProposal);
|
||||
}
|
||||
}
|
||||
// All errors: notify (404 has a tuning-specific message; others are generic)
|
||||
if (window.fbNotify && typeof window.fbNotify.show === 'function') {
|
||||
const msg = res.status === 404
|
||||
? (err && err.detail) || 'No songs match that tuning filter.'
|
||||
: 'Could not book gig — please try again.';
|
||||
try { window.fbNotify.show({ title: 'Gig booking', message: msg, icon: '🎸' }); } catch (_) { /* */ }
|
||||
}
|
||||
return;
|
||||
}
|
||||
_ppGigProposal = await res.json();
|
||||
} catch (_) { return; }
|
||||
if (gen !== _ppBookGen) return; // stale — superseded while awaiting json()
|
||||
const overlay = $('pp-overlay');
|
||||
if (!overlay) return;
|
||||
_ppBook = null; // the poster replaces the book in the overlay
|
||||
@@ -1127,6 +1169,26 @@
|
||||
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.
|
||||
//
|
||||
// A feedpak is a zip, and the first play of one pays for its extraction. In
|
||||
@@ -1213,9 +1275,11 @@
|
||||
genre: prop.genre,
|
||||
genre_key: prop.genre_key,
|
||||
instrument: prop.instrument,
|
||||
tuning_pref: prop.tuning_pref || 'any',
|
||||
idx: 0,
|
||||
restore,
|
||||
};
|
||||
_ppGigLastTuning = null; // reset for fresh interstitial tracking
|
||||
closeBook();
|
||||
_ppGigProposal = null;
|
||||
// RAW filenames: the queue itself encodes for playSong — pre-encoding
|
||||
@@ -1251,8 +1315,14 @@
|
||||
document.body.appendChild(strip);
|
||||
}
|
||||
const run = _ppGigRun;
|
||||
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!'}`;
|
||||
if (_ppGigTuningHold) {
|
||||
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() {
|
||||
@@ -1264,6 +1334,8 @@
|
||||
// No fail state: an abandoned set logs nothing and says nothing.
|
||||
const run = _ppGigRun;
|
||||
_ppGigRun = null;
|
||||
_ppGigLastTuning = null;
|
||||
if (_ppGigTuningHold) { const h = _ppGigTuningHold; _ppGigTuningHold = null; h(); }
|
||||
removeGigStrip();
|
||||
restoreGigStage(run);
|
||||
}
|
||||
@@ -1403,6 +1475,32 @@
|
||||
// Queue lifecycle: advance the strip per song; complete or abandon.
|
||||
function onGigSongLoading() {
|
||||
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.startsWith('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();
|
||||
}
|
||||
|
||||
@@ -1482,6 +1580,28 @@
|
||||
closeBook();
|
||||
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]');
|
||||
if (gigBtn) { bookGig(gigBtn.dataset.ppGig); return; }
|
||||
if (e.target.closest('[data-pp-gig-play]')) { startGig(e.target.closest('[data-pp-gig-play]')); return; }
|
||||
@@ -1539,11 +1659,23 @@
|
||||
}
|
||||
|
||||
function boot() {
|
||||
// Restore persisted tuning preference
|
||||
_ppGigTuningPref = lsGet(PP_TUNING_PREF_KEY) || 'any';
|
||||
const screen = document.getElementById('plugin-career');
|
||||
if (screen) {
|
||||
screen.addEventListener('click', onClick);
|
||||
screen.addEventListener('pointermove', onTiltMove);
|
||||
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;
|
||||
if (sm && typeof sm.on === 'function') {
|
||||
@@ -1576,10 +1708,20 @@
|
||||
window.__careerPassportTest = {
|
||||
ppKey, ppJitter, ppLabel, detectNewBadges, seenBadges, markBadgeSeen,
|
||||
fmtHours, ppFillFraction, careerTotals, closestAskHTML,
|
||||
onGigSongEnded, onGigSongStop,
|
||||
onGigSongEnded, onGigSongStop, onGigSongLoading,
|
||||
setGigRun(r) { _ppGigRun = r; },
|
||||
getGigRun() { return _ppGigRun; },
|
||||
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; },
|
||||
getBookGen() { return _ppBookGen; },
|
||||
setBookGen(g) { _ppBookGen = g; },
|
||||
getProposal() { return _ppGigProposal; },
|
||||
bookGig, closeBook,
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* 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:E Standard', () => {
|
||||
// Failure input: bare 'specific' would pass the old wrong guard `!== 'specific'`
|
||||
// but is impossible in production. Real value is always 'specific:<name>'.
|
||||
const ctx = makeCtx();
|
||||
setRun(ctx, 'specific:E Standard', [{ 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);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// bookGig — generation guard (F1) and 404-only revert (F2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('career-gig-tuning bookGig', () => {
|
||||
// Helper: make a ctx where bookGig is callable.
|
||||
// fetch is overridable per-test via ctx.fetch.
|
||||
function makeBookCtx() {
|
||||
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: () => 42,
|
||||
fetch: null, // set per test
|
||||
console,
|
||||
});
|
||||
ctx.window.feedBack = { on: () => {}, emit: () => {} };
|
||||
vm.runInContext(SCREEN_JS, ctx);
|
||||
// Seed _pp so bookGig can find the passport
|
||||
vm.runInContext(`
|
||||
window.__careerPassportTest.setView({
|
||||
instruments: {
|
||||
guitar: {
|
||||
passports: [{ genre_key: 'rock', genre: 'Rock' }]
|
||||
}
|
||||
}
|
||||
});
|
||||
`, ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
test('F1: stale response from superseded request is discarded — _ppGigProposal keeps new value', async () => {
|
||||
// Failure input: two requests fire; second completes first; first (stale) must be dropped.
|
||||
// Without the generation guard, the stale Drop response would overwrite the Standard proposal.
|
||||
const ctx = makeBookCtx();
|
||||
let resolveFirst, resolveSecond;
|
||||
const first = new Promise(r => { resolveFirst = r; });
|
||||
const second = new Promise(r => { resolveSecond = r; });
|
||||
let callCount = 0;
|
||||
ctx.fetch = () => {
|
||||
callCount++;
|
||||
return callCount === 1 ? first : second;
|
||||
};
|
||||
|
||||
// Fire first request (drop), don't resolve yet
|
||||
const p1 = vm.runInContext(`
|
||||
window.__careerPassportTest.setTuningPref('drop');
|
||||
window.__careerPassportTest.bookGig('rock');
|
||||
`, ctx);
|
||||
|
||||
// Fire second request (standard) — increments gen
|
||||
const p2 = vm.runInContext(`
|
||||
window.__careerPassportTest.setTuningPref('standard');
|
||||
window.__careerPassportTest.bookGig('rock');
|
||||
`, ctx);
|
||||
|
||||
// Resolve SECOND first (standard wins)
|
||||
resolveSecond({ ok: true, status: 200, json: async () => ({ songs: [{ filename: 'standard.sloppak', tuning_name: 'E Standard' }], tuning_pref: 'standard' }) });
|
||||
await p2;
|
||||
|
||||
// Now resolve stale FIRST (drop) — must be discarded
|
||||
resolveFirst({ ok: true, status: 200, json: async () => ({ songs: [{ filename: 'drop.sloppak', tuning_name: 'Drop D' }], tuning_pref: 'drop' }) });
|
||||
await p1;
|
||||
|
||||
// _ppGigProposal must reflect the second (standard) response, not the stale first.
|
||||
// getProposal() exposes _ppGigProposal via the test seam.
|
||||
const proposal = vm.runInContext('window.__careerPassportTest.getProposal()', ctx);
|
||||
// If the generation guard is absent, stale drop overwrites standard → songs[0] is drop.sloppak
|
||||
assert.ok(
|
||||
proposal === null || proposal.songs[0].filename !== 'drop.sloppak',
|
||||
'stale drop response must not overwrite the winning standard proposal'
|
||||
);
|
||||
});
|
||||
|
||||
test('F2: 500 error keeps user pref — only 404 reverts to any', async () => {
|
||||
// Failure input: saved pref 'drop', server returns 500 → without fix, pref silently becomes 'any'
|
||||
const ctx = makeBookCtx();
|
||||
ctx.fetch = async () => ({ ok: false, status: 500, json: async () => ({}) });
|
||||
|
||||
vm.runInContext(`window.__careerPassportTest.setTuningPref('drop');`, ctx);
|
||||
await vm.runInContext(`window.__careerPassportTest.bookGig('rock');`, ctx);
|
||||
|
||||
const pref = vm.runInContext(`window.__careerPassportTest.getTuningPref()`, ctx);
|
||||
assert.equal(pref, 'drop', '500 error must not reset pref to any');
|
||||
});
|
||||
|
||||
test('F2: 404 still reverts pref to any', async () => {
|
||||
const ctx = makeBookCtx();
|
||||
ctx.fetch = async () => ({ ok: false, status: 404, json: async () => ({ detail: 'No drop songs.' }) });
|
||||
|
||||
vm.runInContext(`window.__careerPassportTest.setTuningPref('drop');`, ctx);
|
||||
await vm.runInContext(`window.__careerPassportTest.bookGig('rock');`, ctx);
|
||||
|
||||
const pref = vm.runInContext(`window.__careerPassportTest.getTuningPref()`, ctx);
|
||||
assert.equal(pref, 'any', '404 must revert pref to any');
|
||||
});
|
||||
|
||||
test('F1b: stale 404 json completing after newer booking must not revert newer pref', async () => {
|
||||
// Failure input: A 404 response is received (first gen check passes), then res.json()
|
||||
// is awaited. A new booking fires while json() is pending (increments _ppBookGen).
|
||||
// The error branch MUST re-check gen after json() and NOT revert pref to 'any'.
|
||||
//
|
||||
// We simulate the race by having json() bump _ppBookGen synchronously (equivalent to
|
||||
// a new bookGig call arriving at exactly that moment) before returning a resolved value.
|
||||
// After the await on json()'s resolved Promise, gen !== _ppBookGen → should bail.
|
||||
const ctx = makeBookCtx();
|
||||
|
||||
ctx.fetch = async () => ({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: () => {
|
||||
// Simulate: a new booking fires while json() is in progress
|
||||
vm.runInContext(
|
||||
'window.__careerPassportTest.setBookGen(window.__careerPassportTest.getBookGen() + 1);',
|
||||
ctx
|
||||
);
|
||||
vm.runInContext(`window.__careerPassportTest.setTuningPref('standard');`, ctx);
|
||||
return Promise.resolve({ detail: 'No drop songs.' });
|
||||
},
|
||||
});
|
||||
|
||||
vm.runInContext(`window.__careerPassportTest.setTuningPref('drop');`, ctx);
|
||||
await vm.runInContext(`window.__careerPassportTest.bookGig('rock');`, ctx);
|
||||
|
||||
const pref = vm.runInContext(`window.__careerPassportTest.getTuningPref()`, ctx);
|
||||
assert.equal(pref, 'standard', 'stale 404 json must not revert newer pref to any');
|
||||
});
|
||||
|
||||
test('F2: closeBook() invalidates in-flight request — overlay stays closed', async () => {
|
||||
// Failure input: a booking request is in flight (pending fetch), then the user
|
||||
// closes the poster via the REAL closeBook(). Without ++_ppBookGen in closeBook,
|
||||
// the pending response resolves and repopulates _ppGigProposal.
|
||||
//
|
||||
// Mutation proof: delete `++_ppBookGen` from closeBook() → test goes RED
|
||||
// (proposal is non-null, assert fails). Restore → GREEN.
|
||||
const ctx = makeBookCtx();
|
||||
let resolvePending;
|
||||
ctx.fetch = () => new Promise(r => { resolvePending = r; });
|
||||
|
||||
// Fire a booking — stays pending
|
||||
vm.runInContext(`window.__careerPassportTest.setTuningPref('any');`, ctx);
|
||||
const pending = vm.runInContext(`window.__careerPassportTest.bookGig('rock');`, ctx);
|
||||
|
||||
// User closes the poster — call the REAL closeBook() via the test seam
|
||||
vm.runInContext(`window.__careerPassportTest.closeBook();`, ctx);
|
||||
|
||||
// Now resolve the pending fetch with a valid payload
|
||||
resolvePending({ ok: true, status: 200, json: async () => ({ songs: [{ filename: 'a.sloppak', tuning_name: 'E Standard' }], tuning_pref: 'any' }) });
|
||||
await pending;
|
||||
|
||||
// Proposal must remain null — closeBook() incremented _ppBookGen so response was discarded
|
||||
const proposal = vm.runInContext(`window.__careerPassportTest.getProposal()`, ctx);
|
||||
assert.equal(proposal, null, 'closeBook() must invalidate in-flight request via ++_ppBookGen');
|
||||
});
|
||||
});
|
||||
@@ -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