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:
byrongamatos
2026-09-03 18:49:02 +02:00
co-authored by Claude Fable 5
6 changed files with 706 additions and 19 deletions
+12
View File
@@ -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; }
+74 -7
View File
@@ -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
View File
@@ -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') {
+335
View File
@@ -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');
});
});
+6 -5
View File
@@ -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()
+130
View File
@@ -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 == []