mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-10 23:24:30 +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 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
|
||||
|
||||
+129
-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,11 @@
|
||||
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)
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
@@ -1085,13 +1090,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>
|
||||
@@ -1114,9 +1131,18 @@
|
||||
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 (!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();
|
||||
} catch (_) { return; }
|
||||
const overlay = $('pp-overlay');
|
||||
@@ -1127,6 +1153,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 +1259,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 +1299,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 +1318,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 +1459,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 !== '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 +1564,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 +1643,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 +1692,16 @@
|
||||
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; },
|
||||
};
|
||||
|
||||
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(
|
||||
"""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