From 05a9bee38f935771a9561288834f7e232725e4b4 Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Sun, 12 Jul 2026 01:48:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(career):=20career=20plugin=20=E2=80=94=20s?= =?UTF-8?q?tars=20from=20song=5Fstats,=20venue=20tiers,=20pack=20downloads?= =?UTF-8?q?=20(career=20mode=20PR2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundled plugin: per-song stars from best_accuracy (60/75/85% → 1/2/3★), cumulative stars unlock bar → club → arena (data-driven venues.json). Venue packs (UE-rendered crowd loops) download on demand to CONFIG_DIR/plugin_uploads/career/ on a background thread with sha256 + zip-slip validation, served via FileResponse. Career screen (promoted sidebar entry) shows progress and pushes the active venue's manifest into the crowd video layer (v3VenueCrowd, PR1) — degrades cleanly when either side is absent. Pack URLs land in venues.json in PR3. Co-Authored-By: Claude Fable 5 --- .gitignore | 3 + plugins/career/assets/career.css | 36 ++++ plugins/career/plugin.json | 12 ++ plugins/career/routes.py | 244 ++++++++++++++++++++++++++++ plugins/career/screen.html | 14 ++ plugins/career/screen.js | 200 +++++++++++++++++++++++ plugins/career/venues.json | 26 +++ static/v3/shell.js | 2 + tests/js/career_plugin.test.js | 50 ++++++ tests/plugins/career/__init__.py | 0 tests/plugins/career/conftest.py | 64 ++++++++ tests/plugins/career/test_routes.py | 130 +++++++++++++++ 12 files changed, 781 insertions(+) create mode 100644 plugins/career/assets/career.css create mode 100644 plugins/career/plugin.json create mode 100644 plugins/career/routes.py create mode 100644 plugins/career/screen.html create mode 100644 plugins/career/screen.js create mode 100644 plugins/career/venues.json create mode 100644 tests/js/career_plugin.test.js create mode 100644 tests/plugins/career/__init__.py create mode 100644 tests/plugins/career/conftest.py create mode 100644 tests/plugins/career/test_routes.py diff --git a/.gitignore b/.gitignore index 5aff061..4fe3198 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,9 @@ plugins/*/ !plugins/achievements/ !plugins/achievements/** plugins/achievements/__pycache__/ +!plugins/career/ +!plugins/career/** +plugins/career/__pycache__/ !plugins/highway_3d/ !plugins/highway_3d/** plugins/highway_3d/__pycache__/ diff --git a/plugins/career/assets/career.css b/plugins/career/assets/career.css new file mode 100644 index 0000000..83cd1b0 --- /dev/null +++ b/plugins/career/assets/career.css @@ -0,0 +1,36 @@ +/* Career plugin — only what the prebuilt core Tailwind doesn't ship + (plugin files are outside the core content glob, so responsive grid + variants and cyan button shades live here under plugin-prefixed names). */ + +.career-venues { + display: grid; + gap: 1rem; + grid-template-columns: 1fr; +} +@media (min-width: 768px) { + .career-venues { grid-template-columns: repeat(3, minmax(0, 1fr)); } +} + +.career-btn { + font-size: 0.75rem; + line-height: 1rem; + padding: 0.25rem 0.5rem; + border-radius: 0.375rem; + transition: background-color 0.15s ease; +} +.career-btn-primary { background-color: #0891b2; color: #fff; } +.career-btn-primary:hover { background-color: #06b6d4; } +.career-btn-ghost { background-color: rgba(31, 41, 55, 0.7); color: #d1d5db; } +.career-btn-ghost:hover { background-color: rgba(55, 65, 81, 0.9); } + +.career-bar-track { + height: 0.5rem; + border-radius: 0.25rem; + background-color: rgba(31, 41, 55, 0.9); + overflow: hidden; +} +.career-bar-fill { + height: 100%; + background-color: #06b6d4; + transition: width 0.3s ease; +} diff --git a/plugins/career/plugin.json b/plugins/career/plugin.json new file mode 100644 index 0000000..9f0550a --- /dev/null +++ b/plugins/career/plugin.json @@ -0,0 +1,12 @@ +{ + "id": "career", + "name": "Career", + "version": "0.1.0", + "bundled": true, + "private": false, + "description": "Career mode — gig your way from a local bar to the arena. Earn stars per song; the crowd reacts to how you play.", + "screen": "screen.html", + "script": "screen.js", + "styles": "assets/career.css", + "routes": "routes.py" +} diff --git a/plugins/career/routes.py b/plugins/career/routes.py new file mode 100644 index 0000000..791cba9 --- /dev/null +++ b/plugins/career/routes.py @@ -0,0 +1,244 @@ +"""Career mode — venue progression driven by per-song stars. + +Stars come straight from ``song_stats`` (meta.db): per song, the best +accuracy across arrangements crosses 0/1/2/3 of the thresholds in +``venues.json`` (data-driven so tuning never touches code). Cumulative +stars unlock venue tiers (bar → club → arena). + +Venue packs (crowd-loop videos rendered offline in UE) are heavyweight and +never ship with the app: ``venues.json`` points at a release asset per +venue, downloaded on demand into ``CONFIG_DIR/plugin_uploads/career/venues/ +/`` on a background thread (constitution: nothing heavy inline on the +request path), sha256-verified, then served back with the same +FileResponse/no-cache recipe as highway_3d's custom-video route. + +Endpoints (all under /api/plugins/career/): + GET /state stars + per-venue unlock/install/download status + POST /packs/{venue_id}/download start background pack download (409 if running) + DELETE /packs/{venue_id} remove an installed pack + GET /venues/{venue_id}/{filename} serve pack files (manifest.json, loops, stingers) +""" + +import hashlib +import json +import logging +import re +import shutil +import tempfile +import threading +import urllib.request +import zipfile +from pathlib import Path + +from fastapi import HTTPException +from fastapi.responses import FileResponse + +PLUGIN_ID = "career" +VENUE_ID_RE = re.compile(r"^[a-z0-9_-]{1,40}$") +PACK_FILENAME_RE = re.compile(r"^[a-z0-9_-]{1,64}\.(mp4|webm|json)$") +REQUIRED_LOOPS = ("bored", "neutral", "engaged", "ecstatic") +DOWNLOAD_CHUNK = 1024 * 256 + +_lock = threading.Lock() +_state = { + "content": None, # parsed venues.json + "venues_dir": None, # CONFIG_DIR/plugin_uploads/career/venues + "meta_db": None, # MetadataDB (song_stats reads are lock-free / WAL) + "log": logging.getLogger("feedBack.plugin.career"), + "downloads": {}, # venue_id -> {status, bytes_done, bytes_total, error} +} + + +def _venue(venue_id): + for v in _state["content"]["venues"]: + if v["id"] == venue_id: + return v + return None + + +def _venue_dir(venue_id) -> Path: + return _state["venues_dir"] / venue_id + + +def _installed(venue_id): + return (_venue_dir(venue_id) / "manifest.json").is_file() + + +def _stars(): + """(total, per-song dict). Accuracy in song_stats is a 0..1 fraction.""" + db = _state["meta_db"] + if db is None: + return 0, {} + thresholds = _state["content"]["star_accuracy_thresholds"] + rows = db.conn.execute( + "SELECT filename, MAX(best_accuracy) FROM song_stats GROUP BY filename" + ).fetchall() + per_song = {} + for filename, acc in rows: + stars = sum(1 for t in thresholds if (acc or 0.0) >= t) + if stars: + per_song[filename] = stars + return sum(per_song.values()), per_song + + +def _validate_pack_dir(pack_dir: Path): + """Raise ValueError unless pack_dir holds a complete venue pack.""" + manifest_path = pack_dir / "manifest.json" + if not manifest_path.is_file(): + raise ValueError("pack has no manifest.json") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + loops = manifest.get("loops") or {} + for state in REQUIRED_LOOPS: + name = loops.get(state) + if not name or not PACK_FILENAME_RE.match(name): + raise ValueError(f"manifest is missing the '{state}' loop") + if not (pack_dir / name).is_file(): + raise ValueError(f"loop file '{name}' missing from pack") + for name in (manifest.get("stingers") or {}).values(): + if name and (not PACK_FILENAME_RE.match(name) or not (pack_dir / name).is_file()): + raise ValueError(f"stinger file '{name}' invalid or missing") + + +def _download_pack(venue_id, pack, progress): + """Worker thread: stream → sha256 verify → extract → validate → swap in.""" + log = _state["log"] + final_dir = _venue_dir(venue_id) + staging = Path(tempfile.mkdtemp(prefix=f"career-{venue_id}-", + dir=str(_state["venues_dir"]))) + zip_path = staging / "pack.zip" + try: + digest = hashlib.sha256() + req = urllib.request.Request(pack["url"], headers={"User-Agent": "feedBack-career"}) + with urllib.request.urlopen(req, timeout=60) as resp, open(zip_path, "wb") as out: + total = int(resp.headers.get("Content-Length") or pack.get("bytes") or 0) + progress["bytes_total"] = total + while True: + chunk = resp.read(DOWNLOAD_CHUNK) + if not chunk: + break + digest.update(chunk) + out.write(chunk) + progress["bytes_done"] += len(chunk) + if digest.hexdigest() != pack["sha256"]: + raise ValueError("sha256 mismatch — corrupt or tampered download") + + extract_dir = staging / "pack" + extract_dir.mkdir() + with zipfile.ZipFile(zip_path) as zf: + for info in zf.infolist(): + # Zip-slip guard: only flat, whitelisted names get extracted. + if info.is_dir(): + continue + name = Path(info.filename).name + if name != info.filename or not PACK_FILENAME_RE.match(name): + raise ValueError(f"unexpected file in pack: {info.filename!r}") + with zf.open(info) as src, open(extract_dir / name, "wb") as dst: + shutil.copyfileobj(src, dst) + zip_path.unlink() + _validate_pack_dir(extract_dir) + + if final_dir.exists(): + shutil.rmtree(final_dir) + extract_dir.rename(final_dir) + progress["status"] = "done" + log.info("career: venue pack '%s' installed", venue_id) + except Exception as exc: # noqa: BLE001 — surface any failure to the UI + progress["status"] = "error" + progress["error"] = str(exc) + log.warning("career: venue pack '%s' download failed: %s", venue_id, exc) + finally: + shutil.rmtree(staging, ignore_errors=True) + + +def setup(app, context): + plugin_dir = Path(__file__).resolve().parent + _state["content"] = json.loads((plugin_dir / "venues.json").read_text(encoding="utf-8")) + _state["venues_dir"] = ( + Path(context["config_dir"]) / "plugin_uploads" / PLUGIN_ID / "venues") + _state["venues_dir"].mkdir(parents=True, exist_ok=True) + _state["meta_db"] = context.get("meta_db") + _state["log"] = context.get("log") or _state["log"] + + @app.get(f"/api/plugins/{PLUGIN_ID}/state") + def get_state(): + stars_total, per_song = _stars() + venues = [] + for v in _state["content"]["venues"]: + with _lock: + dl = dict(_state["downloads"].get(v["id"]) or {"status": "idle"}) + venues.append({ + "id": v["id"], + "name": v["name"], + "description": v.get("description", ""), + "star_threshold": v["star_threshold"], + "unlocked": stars_total >= v["star_threshold"], + "installed": _installed(v["id"]), + "has_pack": bool(v.get("pack")), + "download": dl, + }) + return { + "stars_total": stars_total, + "stars_per_song": per_song, + "star_accuracy_thresholds": _state["content"]["star_accuracy_thresholds"], + "venues": venues, + } + + @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.match(venue_id) else None + if venue is None: + raise HTTPException(404, "Unknown venue.") + pack = venue.get("pack") + if not pack: + raise HTTPException(404, "No pack published for this venue yet.") + stars_total, _ = _stars() + if stars_total < venue["star_threshold"]: + raise HTTPException(403, "Venue not unlocked yet.") + with _lock: + running = _state["downloads"].get(venue_id) + if running and running["status"] == "running": + raise HTTPException(409, "Download already running.") + progress = {"status": "running", "bytes_done": 0, + "bytes_total": pack.get("bytes") or 0, "error": None} + _state["downloads"][venue_id] = progress + threading.Thread(target=_download_pack, args=(venue_id, pack, progress), + name=f"career-pack-{venue_id}", daemon=True).start() + return {"ok": True} + + @app.delete(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}") + def delete_pack(venue_id: str): + if not VENUE_ID_RE.match(venue_id) or _venue(venue_id) is None: + raise HTTPException(404, "Unknown venue.") + with _lock: + running = _state["downloads"].get(venue_id) + if running and running["status"] == "running": + raise HTTPException(409, "Download in progress.") + _state["downloads"].pop(venue_id, None) + shutil.rmtree(_venue_dir(venue_id), ignore_errors=True) + return {"ok": True} + + @app.get(f"/api/plugins/{PLUGIN_ID}/venues/{{venue_id}}/{{filename}}") + async def get_pack_file(venue_id: str, filename: str): + if not VENUE_ID_RE.match(venue_id) or not PACK_FILENAME_RE.match(filename): + raise HTTPException(404, "Not found.") + path = _venue_dir(venue_id) / filename + # Defense-in-depth beyond the regexes (same recipe as highway_3d): + # the resolved path must stay inside the venues dir. + try: + resolved = path.resolve() + resolved.relative_to(_state["venues_dir"].resolve()) + except (OSError, ValueError): + raise HTTPException(404, "Not found.") + if not resolved.is_file(): + raise HTTPException(404, "Not found.") + media = {"mp4": "video/mp4", "webm": "video/webm", + "json": "application/json"}[resolved.suffix.lstrip(".").lower()] + return FileResponse( + resolved, + media_type=media, + # Pack files are immutable per version, but a re-download after a + # pack update overwrites in place — no-cache + ETag revalidation + # keeps browsers honest for the price of a 304. + headers={"Cache-Control": "no-cache", + "X-Content-Type-Options": "nosniff"}, + ) diff --git a/plugins/career/screen.html b/plugins/career/screen.html new file mode 100644 index 0000000..9305a8c --- /dev/null +++ b/plugins/career/screen.html @@ -0,0 +1,14 @@ +
+
+

Career

+
+
+

Earn stars by playing songs well — 60% accuracy is a star, 75% two, 85% three. Stars unlock bigger stages, and the crowd plays along with you.

+
+
+
+
+
+
+
+
diff --git a/plugins/career/screen.js b/plugins/career/screen.js new file mode 100644 index 0000000..5ea40b9 --- /dev/null +++ b/plugins/career/screen.js @@ -0,0 +1,200 @@ +/* + * Career plugin — venue progression UI + crowd-manifest push. + * + * Reads /api/plugins/career/state (stars from song_stats, per-venue + * unlock/install/download status), renders the career screen, and pushes the + * active venue's pack manifest into the crowd video layer + * (window.v3VenueCrowd, shipped with the venue crowd PR) whenever it changes. + * Everything degrades: no crowd layer → screen still works; no packs → the + * venue scene keeps its static plate. + */ +(function () { + 'use strict'; + + const API = '/api/plugins/career'; + const VENUE_OVERRIDE_KEY = 'feedBack-career-venue'; + const POLL_MS = 2000; + + let _state = null; + let _pollTimer = 0; + let _appliedManifestVenue = null; + let _prevUnlockedIds = null; + + function $(id) { return document.getElementById(id); } + + function esc(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, + (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + } + + async function fetchState() { + const res = await fetch(API + '/state'); + if (!res.ok) throw new Error('career state ' + res.status); + return res.json(); + } + + function lastOf(arr) { return arr.length ? arr[arr.length - 1] : null; } + + // Active pack = localStorage override when unlocked+installed, else the + // highest unlocked+installed tier; none → clear the crowd manifest. + async function pushCrowdManifest(state) { + const crowd = window.v3VenueCrowd; + if (!crowd || typeof crowd.setManifest !== 'function') return; + const unlocked = state.venues.filter((v) => v.unlocked); + let venue = null; + try { + const override = localStorage.getItem(VENUE_OVERRIDE_KEY); + venue = unlocked.find((v) => v.id === override && v.installed) || null; + } catch (_) { /* ok */ } + if (!venue) venue = lastOf(unlocked.filter((v) => v.installed)); + if (!venue) { + if (_appliedManifestVenue !== null) { + _appliedManifestVenue = null; + crowd.setManifest(null); + } + return; + } + if (venue.id === _appliedManifestVenue) return; + try { + const res = await fetch(`${API}/venues/${venue.id}/manifest.json`); + if (!res.ok) return; + const manifest = await res.json(); + manifest.base = `${API}/venues/${venue.id}/`; + _appliedManifestVenue = venue.id; + crowd.setManifest(manifest); + } catch (_) { /* pack half-installed; next refresh retries */ } + } + + function venueCardHTML(v, state) { + const locked = !v.unlocked; + const dl = v.download || { status: 'idle' }; + const pct = dl.bytes_total > 0 + ? Math.round((dl.bytes_done / dl.bytes_total) * 100) : 0; + let action = ''; + if (locked) { + action = `
Unlocks at ${v.star_threshold} ★ — ${Math.max(0, v.star_threshold - state.stars_total)} to go
`; + } else if (dl.status === 'running') { + action = `
+
Downloading… ${pct}%
`; + } else if (v.installed) { + action = `
+ + +
`; + } else if (v.has_pack) { + const err = dl.status === 'error' + ? `
${esc(dl.error || 'Download failed')} — try again
` : ''; + action = `${err}`; + } else { + action = '
Venue pack coming soon — plays with the standard stage for now
'; + } + const isActive = !locked && localStorage.getItem(VENUE_OVERRIDE_KEY) === v.id; + return `
+
+
${esc(v.name)}${isActive ? ' ● playing here' : ''}
+
${v.star_threshold} ★
+
+
${esc(v.description)}
+ ${action} +
`; + } + + function render(state) { + const host = $('career-venues'); + if (!host) return; + $('career-stars-summary').textContent = `★ ${state.stars_total} total`; + const next = state.venues.find((v) => !v.unlocked); + const bar = $('career-progress-bar'); + const label = $('career-progress-label'); + if (next) { + const prevThreshold = state.venues + .filter((v) => v.unlocked) + .reduce((m, v) => Math.max(m, v.star_threshold), 0); + const span = Math.max(1, next.star_threshold - prevThreshold); + const into = Math.max(0, state.stars_total - prevThreshold); + bar.style.width = Math.min(100, Math.round((into / span) * 100)) + '%'; + label.textContent = `${state.stars_total} / ${next.star_threshold} ★ to unlock ${next.name}`; + } else { + bar.style.width = '100%'; + label.textContent = 'All venues unlocked — enjoy the arena.'; + } + host.innerHTML = state.venues.map((v) => venueCardHTML(v, state)).join(''); + } + + function schedulePoll(state) { + clearTimeout(_pollTimer); + if (state.venues.some((v) => (v.download || {}).status === 'running')) { + _pollTimer = setTimeout(refresh, POLL_MS); + } + } + + function announceUnlocks(state) { + const unlocked = state.venues.filter((v) => v.unlocked).map((v) => v.id); + if (_prevUnlockedIds) { + for (const v of state.venues) { + if (v.unlocked && !_prevUnlockedIds.includes(v.id)) { + const sm = window.feedBack; + if (sm && typeof sm.emit === 'function') { + sm.emit('career:venue-unlocked', { id: v.id, name: v.name }); + } + if (window.fbNotify && typeof window.fbNotify.show === 'function') { + window.fbNotify.show({ + big: true, icon: '🎤', accent: '#06B6D4', + title: 'New venue unlocked!', + message: `${v.name} — your crowd just got bigger.`, + }); + } + } + } + } + _prevUnlockedIds = unlocked; + } + + async function refresh() { + let state; + try { + state = await fetchState(); + } catch (_) { + return; // server restarting; next trigger retries + } + _state = state; + announceUnlocks(state); + render(state); + schedulePoll(state); + pushCrowdManifest(state); + } + + function onClick(e) { + const dlBtn = e.target.closest('[data-career-download]'); + const delBtn = e.target.closest('[data-career-delete]'); + const playBtn = e.target.closest('[data-career-play]'); + if (dlBtn) { + fetch(`${API}/packs/${dlBtn.dataset.careerDownload}/download`, { method: 'POST' }) + .then(refresh); + } else if (delBtn) { + fetch(`${API}/packs/${delBtn.dataset.careerDelete}`, { method: 'DELETE' }) + .then(() => { _appliedManifestVenue = null; refresh(); }); + } else if (playBtn) { + try { localStorage.setItem(VENUE_OVERRIDE_KEY, playBtn.dataset.careerPlay); } catch (_) { /* ok */ } + _appliedManifestVenue = null; // force manifest re-push + refresh(); + } + } + + function boot() { + const screen = document.getElementById('plugin-career'); + if (screen) screen.addEventListener('click', onClick); + const sm = window.feedBack; + if (sm && typeof sm.on === 'function') { + // New song stats can add stars → thresholds may cross mid-session. + sm.on('stats:recorded', () => refresh()); + } + refresh(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot); + } else { + boot(); + } +}()); diff --git a/plugins/career/venues.json b/plugins/career/venues.json new file mode 100644 index 0000000..d4684ba --- /dev/null +++ b/plugins/career/venues.json @@ -0,0 +1,26 @@ +{ + "star_accuracy_thresholds": [0.6, 0.75, 0.85], + "venues": [ + { + "id": "bar", + "name": "The Dive Bar", + "description": "Sticky floors, a dozen regulars, and a PA that has seen better decades.", + "star_threshold": 0, + "pack": null + }, + { + "id": "club", + "name": "Velvet Room", + "description": "A proper club stage. People actually came to hear you.", + "star_threshold": 15, + "pack": null + }, + { + "id": "arena", + "name": "Feedback Arena", + "description": "Ten thousand seats. Try not to think about it.", + "star_threshold": 40, + "pack": null + } + ] +} diff --git a/static/v3/shell.js b/static/v3/shell.js index 0a935c1..743182f 100644 --- a/static/v3/shell.js +++ b/static/v3/shell.js @@ -48,6 +48,7 @@ // above. Screens are injected async by the plugin loader, so go()'s // plugin- guard applies. { key: 'virtuoso', screen: 'plugin-virtuoso', label: 'Virtuoso - Practice', group: null, icon: 'target' }, + { key: 'career', screen: 'plugin-career', label: 'Career', group: null, icon: 'trophy' }, { key: 'rig_builder', screen: 'plugin-rig_builder', label: 'Rig Builder', group: null, icon: 'amp' }, { key: 'editor', screen: 'plugin-editor', label: 'Song Editor', group: null, icon: 'edit' }, { key: 'audio_engine', screen: 'plugin-audio_engine', label: 'Audio', group: null, icon: 'amp' }, @@ -60,6 +61,7 @@ // that group. Each is gated on the plugin actually being installed. const PROMOTED_PLUGINS = [ { navKey: 'virtuoso', pluginId: 'virtuoso', slotId: 'v3-nav-virtuoso', anchorAfter: 'feedbarcade' }, + { navKey: 'career', pluginId: 'career', slotId: 'v3-nav-career', anchorAfter: 'feedbarcade' }, { navKey: 'rig_builder', pluginId: 'rig_builder', slotId: 'v3-nav-rig-builder', anchorAfter: 'saved' }, { navKey: 'editor', pluginId: 'editor', slotId: 'v3-nav-editor', anchorAfter: 'songs' }, { navKey: 'audio_engine', pluginId: 'audio_engine', slotId: 'v3-nav-audio-engine', anchorAfter: 'settings' }, diff --git a/tests/js/career_plugin.test.js b/tests/js/career_plugin.test.js new file mode 100644 index 0000000..98da433 --- /dev/null +++ b/tests/js/career_plugin.test.js @@ -0,0 +1,50 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..', '..'); +const PLUGIN_DIR = path.join(ROOT, 'plugins', 'career'); +const SHELL_JS = path.join(ROOT, 'static', 'v3', 'shell.js'); + +test('career plugin manifest is complete and bundled', () => { + const manifest = JSON.parse(fs.readFileSync(path.join(PLUGIN_DIR, 'plugin.json'), 'utf8')); + assert.equal(manifest.id, 'career'); + assert.equal(manifest.bundled, true); + assert.equal(manifest.screen, 'screen.html'); + assert.equal(manifest.script, 'screen.js'); + assert.equal(manifest.routes, 'routes.py'); + for (const f of ['screen.html', 'screen.js', 'routes.py', 'venues.json', manifest.styles]) { + assert.ok(fs.existsSync(path.join(PLUGIN_DIR, f)), `${f} missing`); + } +}); + +test('venues.json defines the 3 ascending tiers with star thresholds', () => { + const content = JSON.parse(fs.readFileSync(path.join(PLUGIN_DIR, 'venues.json'), 'utf8')); + assert.deepEqual(content.star_accuracy_thresholds, [0.6, 0.75, 0.85]); + const venues = content.venues; + assert.deepEqual(venues.map((v) => v.id), ['bar', 'club', 'arena']); + assert.equal(venues[0].star_threshold, 0, 'bar must always be unlocked'); + for (let i = 1; i < venues.length; i++) { + assert.ok(venues[i].star_threshold > venues[i - 1].star_threshold, + 'thresholds must ascend'); + } +}); + +test('shell promotes the career plugin into the sidebar', () => { + const src = fs.readFileSync(SHELL_JS, 'utf8'); + assert.match(src, /key: 'career',\s*screen: 'plugin-career'/); + assert.match(src, /navKey: 'career',\s*pluginId: 'career',\s*slotId: 'v3-nav-career'/); +}); + +test('career screen pushes the crowd manifest with a base URL', () => { + const src = fs.readFileSync(path.join(PLUGIN_DIR, 'screen.js'), 'utf8'); + assert.match(src, /v3VenueCrowd/); + assert.match(src, /setManifest\(manifest\)/); + assert.match(src, /manifest\.base = /); + assert.match(src, /feedBack-career-venue/); + // Degrades without the crowd layer (PR1 not merged / older desktop). + assert.match(src, /typeof crowd\.setManifest !== 'function'\) return/); +}); diff --git a/tests/plugins/career/__init__.py b/tests/plugins/career/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/plugins/career/conftest.py b/tests/plugins/career/conftest.py new file mode 100644 index 0000000..8242234 --- /dev/null +++ b/tests/plugins/career/conftest.py @@ -0,0 +1,64 @@ +import sqlite3 +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / 'plugins' / 'career')) + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +# Drop a sibling 'routes' cached by another plugin's tests (bare-name collision). +sys.modules.pop('routes', None) +import routes as career_routes + + +class FakeMetaDb: + """song_stats-only stand-in for MetadataDB (the plugin reads nothing else).""" + + def __init__(self): + self.conn = sqlite3.connect(":memory:", check_same_thread=False) + self.conn.execute( + """CREATE TABLE song_stats ( + filename TEXT, arrangement TEXT, best_accuracy REAL + )""" + ) + + def add(self, filename, arrangement, best_accuracy): + self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?)", + (filename, arrangement, best_accuracy)) + self.conn.commit() + + +@pytest.fixture(autouse=True) +def _bind_career_routes(): + """Keep sys.modules['routes'] pointing at THIS plugin's routes for these tests.""" + prev = sys.modules.get('routes') + sys.modules['routes'] = career_routes + try: + yield + finally: + if prev is not None: + sys.modules['routes'] = prev + else: + sys.modules.pop('routes', None) + + +@pytest.fixture(autouse=True) +def _reset_state(): + # Module state outlives tests when the module stays imported — reset the + # mutable bits so ordering can't leak downloads/content between tests. + career_routes._state["downloads"] = {} + yield + + +@pytest.fixture +def meta_db(): + return FakeMetaDb() + + +@pytest.fixture +def client(tmp_path, meta_db): + app = FastAPI() + career_routes.setup(app, {"config_dir": str(tmp_path), "meta_db": meta_db}) + return TestClient(app) diff --git a/tests/plugins/career/test_routes.py b/tests/plugins/career/test_routes.py new file mode 100644 index 0000000..972fb48 --- /dev/null +++ b/tests/plugins/career/test_routes.py @@ -0,0 +1,130 @@ +"""HTTP-level tests for the career plugin: stars, unlocks, packs.""" + +import hashlib +import json +import zipfile + +import routes as career_routes + + +def _install_fake_pack(venue_id, files=None): + """Drop a valid installed pack into the plugin's venues dir.""" + pack_dir = career_routes._venue_dir(venue_id) + pack_dir.mkdir(parents=True, exist_ok=True) + loops = {s: f"{s}.mp4" for s in career_routes.REQUIRED_LOOPS} + (pack_dir / "manifest.json").write_text(json.dumps( + {"venue": venue_id, "version": 1, "loops": loops, + "stingers": {"clap": "clap.mp4", "cheer": "cheer.mp4"}})) + for name in list(loops.values()) + ["clap.mp4", "cheer.mp4"]: + (pack_dir / name).write_bytes((files or {}).get(name, b"\x00video")) + + +def test_stars_from_best_accuracy_across_arrangements(client, meta_db): + # Thresholds 0.6/0.75/0.85 → 1/2/3 stars; best arrangement wins. + meta_db.add("a.feedpak", "guitar", 0.5) # 0 stars + meta_db.add("b.feedpak", "guitar", 0.62) # 1 star + meta_db.add("c.feedpak", "guitar", 0.70) + meta_db.add("c.feedpak", "bass", 0.80) # 2 stars (max across arrangements) + meta_db.add("d.feedpak", "guitar", 0.99) # 3 stars + state = client.get("/api/plugins/career/state").json() + assert state["stars_total"] == 6 + assert state["stars_per_song"] == {"b.feedpak": 1, "c.feedpak": 2, "d.feedpak": 3} + + +def test_unlock_flags_follow_thresholds(client, meta_db): + # 6 stars: bar (0) unlocked, club (15) and arena (40) locked. + for i in range(2): + meta_db.add(f"s{i}.feedpak", "guitar", 0.9) # 3 stars each + state = client.get("/api/plugins/career/state").json() + by_id = {v["id"]: v for v in state["venues"]} + assert by_id["bar"]["unlocked"] is True + assert by_id["club"]["unlocked"] is False + assert by_id["arena"]["unlocked"] is False + + +def test_no_stats_still_serves_state(client): + state = client.get("/api/plugins/career/state").json() + assert state["stars_total"] == 0 + assert state["venues"][0]["unlocked"] is True # bar is always open + + +def test_download_unknown_venue_404s(client): + assert client.post("/api/plugins/career/packs/nope/download").status_code == 404 + assert client.post("/api/plugins/career/packs/../etc/download").status_code == 404 + + +def test_download_without_published_pack_404s(client): + # venues.json ships pack: null until packs are released. + assert client.post("/api/plugins/career/packs/bar/download").status_code == 404 + + +def test_download_locked_venue_403s(client, monkeypatch): + club = career_routes._venue("club") + monkeypatch.setitem(club, "pack", {"url": "http://x/pack.zip", "sha256": "0" * 64}) + assert client.post("/api/plugins/career/packs/club/download").status_code == 403 + + +def test_pack_file_serving_and_traversal_guard(client): + _install_fake_pack("bar") + ok = client.get("/api/plugins/career/venues/bar/manifest.json") + assert ok.status_code == 200 + assert ok.json()["loops"]["ecstatic"] == "ecstatic.mp4" + video = client.get("/api/plugins/career/venues/bar/bored.mp4") + assert video.status_code == 200 + assert video.headers["content-type"].startswith("video/mp4") + assert video.headers["x-content-type-options"] == "nosniff" + # Traversal / junk shapes never resolve. + for bad in ("../manifest.json", "..%2Fmanifest.json", "x.sh", "MANIFEST.JSON"): + assert client.get(f"/api/plugins/career/venues/bar/{bad}").status_code == 404 + assert client.get("/api/plugins/career/venues/../bar/manifest.json").status_code == 404 + + +def test_state_reports_installed_and_delete_removes(client): + _install_fake_pack("bar") + state = client.get("/api/plugins/career/state").json() + assert {v["id"]: v["installed"] for v in state["venues"]}["bar"] is True + assert client.delete("/api/plugins/career/packs/bar").status_code == 200 + state = client.get("/api/plugins/career/state").json() + assert {v["id"]: v["installed"] for v in state["venues"]}["bar"] is False + + +def test_download_worker_end_to_end(client, tmp_path): + # Build a real pack zip, serve it via file://, verify the full worker path: + # stream → sha256 → extract (flat names only) → validate → swap in. + src = tmp_path / "src" + src.mkdir() + names = [f"{s}.mp4" for s in career_routes.REQUIRED_LOOPS] + ["cheer.mp4"] + for name in names: + (src / name).write_bytes(b"fake-video-" + name.encode()) + (src / "manifest.json").write_text(json.dumps({ + "venue": "bar", "version": 1, + "loops": {s: f"{s}.mp4" for s in career_routes.REQUIRED_LOOPS}, + "stingers": {"cheer": "cheer.mp4"}, + })) + zip_path = tmp_path / "bar-pack.zip" + with zipfile.ZipFile(zip_path, "w") as zf: + for p in src.iterdir(): + zf.write(p, p.name) + sha = hashlib.sha256(zip_path.read_bytes()).hexdigest() + + progress = {"status": "running", "bytes_done": 0, "bytes_total": 0, "error": None} + career_routes._download_pack( + "bar", {"url": zip_path.as_uri(), "sha256": sha}, progress) + assert progress["status"] == "done", progress["error"] + assert career_routes._installed("bar") + assert progress["bytes_done"] == zip_path.stat().st_size + + # Corrupt hash → error status, nothing installed over the good pack. + bad = {"status": "running", "bytes_done": 0, "bytes_total": 0, "error": None} + career_routes._download_pack("bar", {"url": zip_path.as_uri(), "sha256": "0" * 64}, bad) + assert bad["status"] == "error" + assert "sha256" in bad["error"] + + +def test_double_download_409s(client, monkeypatch): + bar = career_routes._venue("bar") + monkeypatch.setitem(bar, "pack", {"url": "http://x/pack.zip", "sha256": "0" * 64}) + # Pretend one is already running. + career_routes._state["downloads"]["bar"] = {"status": "running"} + assert client.post("/api/plugins/career/packs/bar/download").status_code == 409 + assert client.delete("/api/plugins/career/packs/bar").status_code == 409