mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-22 21:01:40 +00:00
feat(career): passport backend — genre badges computed from stars
The badge-journey layer on top of career stars (Christian's career-mode
v2 design, composed with the shipped venue system). Badges are computed
on read from song_stats × the library's effective genre — never stored:
Bronze = N genre songs at min_stars (data-driven in passports.json,
default 5 songs at 2★) plus any configured virtuoso drill nodes.
New endpoints under /api/plugins/career/:
- GET /passports passport walls per instrument: badges, ticket
stubs (qualifying songs), library genres, drills
- POST /passports/commit instrument commitment (idempotent wax seal)
- POST /passports/open open a genre passport (implies commitment)
- POST /drill-state intake for the relayed virtuoso.progress
snapshot (career's frontend listens on the bus)
Instrument attribution reuses progression.instrument_for_arrangement via
the song_stats arrangement index; the genre column goes through the
host's override-aware effective-genre SQL. Non-graded instruments (bass,
drums) render shown-not-judged — repertoire, never a false badge denial.
Persisted state (commitments, opened passports, drill snapshot) lives
under CONFIG_DIR/career/ and rides the settings export bundle via
settings.server_files; a minimal settings.html documents it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
342def3851
commit
e1a534ab63
17
CHANGELOG.md
17
CHANGELOG.md
@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Career passports (backend)** — the badge-journey layer on top of career stars.
|
||||
New career-plugin endpoints: `GET /api/plugins/career/passports` (per-instrument
|
||||
passport walls: genre badges computed on read from `song_stats` × the library's
|
||||
effective genre — Bronze = N genre songs at K★, data-driven in
|
||||
`plugins/career/passports.json`, default 5 songs at 2★ — plus qualifying-song
|
||||
"ticket stubs", the library genre list, and drill status), `POST
|
||||
/passports/commit` (instrument commitment), `POST /passports/open` (open a genre
|
||||
passport), and `POST /drill-state` (intake for the relayed Virtuoso
|
||||
`virtuoso.progress` snapshot, so drill requirements can gate badges
|
||||
server-side). Badges are never stored; the only persisted state (commitments,
|
||||
opened passports, drill snapshot) lives under `CONFIG_DIR/career/` and rides the
|
||||
settings export/import bundle via `settings.server_files`. Instruments are
|
||||
attributed via the existing progression arrangement→instrument mapping;
|
||||
non-graded instruments (bass, drums) render shown-not-judged — repertoire
|
||||
without a pass bar, never a false badge denial.
|
||||
|
||||
### Removed
|
||||
- **The classic v2 UI shell is gone — v3 is the only UI (R3a).** `static/index.html`, the
|
||||
`/v2` route, and the `FEEDBACK_UI` v2/legacy opt-out are deleted; `/` and `/v3` both serve
|
||||
|
||||
17
plugins/career/passports.json
Normal file
17
plugins/career/passports.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"badge_requirement": {
|
||||
"songs": 5,
|
||||
"min_stars": 2
|
||||
},
|
||||
"genres": {},
|
||||
"graded_instruments": [
|
||||
"guitar",
|
||||
"keys"
|
||||
],
|
||||
"instruments": [
|
||||
"guitar",
|
||||
"bass",
|
||||
"keys",
|
||||
"drums"
|
||||
]
|
||||
}
|
||||
@ -1,12 +1,18 @@
|
||||
{
|
||||
"id": "career",
|
||||
"name": "Career",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.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.",
|
||||
"description": "Career mode — gig your way from a local bar to the arena, and build a passport wall of genre badges per instrument. Earn stars per song; the crowd reacts to how you play.",
|
||||
"screen": "screen.html",
|
||||
"script": "screen.js",
|
||||
"styles": "assets/career.css",
|
||||
"settings": {
|
||||
"html": "settings.html",
|
||||
"server_files": [
|
||||
"career/"
|
||||
]
|
||||
},
|
||||
"routes": "routes.py"
|
||||
}
|
||||
|
||||
@ -10,11 +10,22 @@ the plugin under ``venue-packs/<id>/`` or downloaded on demand into
|
||||
``CONFIG_DIR/plugin_uploads/career/venues/<id>/``. Downloaded packs override
|
||||
bundled packs so release assets can replace a built-in starter venue.
|
||||
|
||||
Passports (badge journey per instrument × genre — the identity layer on top
|
||||
of the same stars): badges are COMPUTED on read from ``song_stats`` × the
|
||||
library's effective genre, never stored. The only persisted career state is
|
||||
what cannot be derived — instrument commitment, opened passports, and the
|
||||
relayed virtuoso drill snapshot — as JSON under ``CONFIG_DIR/career/``
|
||||
(exported via ``settings.server_files``).
|
||||
|
||||
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)
|
||||
GET /passports passport walls: badges, stubs, genres, drill status
|
||||
POST /passports/commit commit to an instrument (the wax seal, Stage 0)
|
||||
POST /passports/open open a genre passport for an instrument
|
||||
POST /drill-state relayed virtuoso.progress snapshot (drill intake)
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
@ -26,11 +37,14 @@ import tempfile
|
||||
import threading
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi import Body, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from progression import instrument_for_arrangement
|
||||
|
||||
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|mp3|json)$")
|
||||
@ -118,6 +132,235 @@ def _stars():
|
||||
return sum(per_song.values()), per_song, detail
|
||||
|
||||
|
||||
# ── Passports ─────────────────────────────────────────────────────────────────
|
||||
|
||||
GENRE_MAX_LEN = 64
|
||||
DRILL_SNAPSHOT_MAX_BYTES = 256 * 1024
|
||||
|
||||
|
||||
def _now_iso():
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _genre_display(genre):
|
||||
return " ".join(str(genre or "").strip().split())
|
||||
|
||||
|
||||
def _genre_key(genre):
|
||||
return _genre_display(genre).lower()
|
||||
|
||||
|
||||
def _state_file() -> Path:
|
||||
return _state["state_dir"] / "passports-state.json"
|
||||
|
||||
|
||||
def _drill_file() -> Path:
|
||||
return _state["state_dir"] / "drill-state.json"
|
||||
|
||||
|
||||
def _load_json(path: Path, default):
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _save_json(path: Path, obj):
|
||||
tmp = path.with_name(path.name + ".tmp")
|
||||
tmp.write_text(json.dumps(obj, indent=2), encoding="utf-8")
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
def _career_state():
|
||||
st = _load_json(_state_file(), {})
|
||||
if not isinstance(st, dict):
|
||||
st = {}
|
||||
if not isinstance(st.get("instruments"), dict):
|
||||
st["instruments"] = {}
|
||||
if not isinstance(st.get("passports"), dict):
|
||||
st["passports"] = {}
|
||||
return st
|
||||
|
||||
|
||||
def _genre_expr(db):
|
||||
# Reuse the host's override-aware effective-genre SQL (Fix-metadata popup
|
||||
# overrides); plain `genre` on stand-ins that don't implement it.
|
||||
fn = getattr(db, "_effective_genre_expr", None)
|
||||
return fn() if callable(fn) else "genre"
|
||||
|
||||
|
||||
def _instrument_of(arrangements, arrangement):
|
||||
"""Progression's arrangement→instrument mapping, via the song_stats
|
||||
arrangement index into the song's arrangements JSON."""
|
||||
entry = None
|
||||
try:
|
||||
idx = int(arrangement)
|
||||
if isinstance(arrangements, list) and 0 <= idx < len(arrangements):
|
||||
entry = arrangements[idx]
|
||||
except (TypeError, ValueError):
|
||||
entry = None
|
||||
return instrument_for_arrangement(entry)
|
||||
|
||||
|
||||
def _played_by_instrument_genre():
|
||||
"""(instrument, genre_key) → {filename: stub dict}. Best accuracy per
|
||||
(instrument, song); the JOIN keeps the same dead-song filter as _stars()."""
|
||||
db = _state["meta_db"]
|
||||
if db is None:
|
||||
return {}
|
||||
thresholds = _state["content"]["star_accuracy_thresholds"]
|
||||
rows = db.conn.execute(
|
||||
"SELECT s.filename, s.arrangement, s.best_accuracy, s.last_played_at, "
|
||||
" songs.title, songs.artist, songs.arrangements, "
|
||||
f" {_genre_expr(db)} "
|
||||
"FROM song_stats s JOIN songs ON songs.filename = s.filename"
|
||||
).fetchall()
|
||||
arrs_cache = {}
|
||||
out = {}
|
||||
for filename, arrangement, acc, played_at, title, artist, arrs_json, genre in rows:
|
||||
gkey = _genre_key(genre)
|
||||
if not gkey:
|
||||
continue
|
||||
if filename not in arrs_cache:
|
||||
try:
|
||||
arrs_cache[filename] = json.loads(arrs_json) if arrs_json else None
|
||||
except (TypeError, ValueError):
|
||||
arrs_cache[filename] = None
|
||||
instrument = _instrument_of(arrs_cache[filename], arrangement)
|
||||
acc = acc or 0.0
|
||||
stub = out.setdefault((instrument, gkey), {}).get(filename)
|
||||
if stub is None:
|
||||
out[(instrument, gkey)][filename] = {
|
||||
"filename": filename,
|
||||
"title": title or filename,
|
||||
"artist": artist or "",
|
||||
"best_accuracy": acc,
|
||||
"last_played_at": played_at,
|
||||
}
|
||||
else:
|
||||
stub["best_accuracy"] = max(stub["best_accuracy"], acc)
|
||||
stub["last_played_at"] = max(stub["last_played_at"] or "", played_at or "") or None
|
||||
for stubs in out.values():
|
||||
for stub in stubs.values():
|
||||
acc = stub["best_accuracy"]
|
||||
stub["best_accuracy"] = round(acc, 4)
|
||||
stub["stars"] = sum(1 for t in thresholds if acc >= t)
|
||||
return out
|
||||
|
||||
|
||||
def _library_genres():
|
||||
"""Distinct effective genres across the live library (the brochure rack)."""
|
||||
db = _state["meta_db"]
|
||||
if db is None:
|
||||
return []
|
||||
rows = db.conn.execute(
|
||||
f"SELECT {_genre_expr(db)} AS g, COUNT(*) FROM songs GROUP BY g").fetchall()
|
||||
by_key = {}
|
||||
for genre, count in rows:
|
||||
display = _genre_display(genre)
|
||||
key = display.lower()
|
||||
if not key:
|
||||
continue
|
||||
cur = by_key.get(key)
|
||||
if cur: # case-variant duplicates collapse onto the first-seen casing
|
||||
cur["songs_in_library"] += count
|
||||
else:
|
||||
by_key[key] = {"genre_key": key, "genre": display,
|
||||
"songs_in_library": count}
|
||||
return sorted(by_key.values(),
|
||||
key=lambda r: (-r["songs_in_library"], r["genre_key"]))
|
||||
|
||||
|
||||
def _badge_requirement(gkey):
|
||||
cfg = _state["passports_content"]
|
||||
req = dict(cfg.get("badge_requirement") or {})
|
||||
req.setdefault("songs", 5)
|
||||
req.setdefault("min_stars", 2)
|
||||
override = (cfg.get("genres") or {}).get(gkey)
|
||||
if isinstance(override, dict):
|
||||
req.update(override)
|
||||
req["virtuoso_nodes"] = [n for n in (req.get("virtuoso_nodes") or [])
|
||||
if isinstance(n, str)]
|
||||
return req
|
||||
|
||||
|
||||
def _drill_by_node():
|
||||
doc = _load_json(_drill_file(), {})
|
||||
if not isinstance(doc, dict):
|
||||
return None, {}
|
||||
snapshot = doc.get("snapshot") if isinstance(doc.get("snapshot"), dict) else {}
|
||||
by_node = snapshot.get("byNode") if isinstance(snapshot.get("byNode"), dict) else {}
|
||||
return doc.get("received_at"), by_node
|
||||
|
||||
|
||||
def _node_cleared(by_node, node_id):
|
||||
"""A drill counts as cleared on real completion evidence: mastered, or any
|
||||
depth rung flipped true (virtuoso's gained-only false→true artifacts)."""
|
||||
entry = by_node.get(node_id)
|
||||
if not isinstance(entry, dict):
|
||||
return False
|
||||
depth = entry.get("depth") if isinstance(entry.get("depth"), dict) else {}
|
||||
return bool(entry.get("masteredAt")) or any(bool(v) for v in depth.values())
|
||||
|
||||
|
||||
def _passports_view():
|
||||
cfg = _state["passports_content"]
|
||||
graded = set(cfg.get("graded_instruments") or [])
|
||||
st = _career_state()
|
||||
played = _played_by_instrument_genre()
|
||||
received_at, by_node = _drill_by_node()
|
||||
instruments = {}
|
||||
for inst in cfg.get("instruments") or []:
|
||||
committed_at = (st["instruments"].get(inst) or {}).get("committed_at")
|
||||
opened = st["passports"].get(inst)
|
||||
opened = opened if isinstance(opened, dict) else {}
|
||||
passports = []
|
||||
for gkey, meta in sorted(opened.items(),
|
||||
key=lambda kv: ((kv[1] or {}).get("opened_at") or "", kv[0])):
|
||||
meta = meta if isinstance(meta, dict) else {}
|
||||
req = _badge_requirement(gkey)
|
||||
songs = list(played.get((inst, gkey), {}).values())
|
||||
for s in songs:
|
||||
s["qualifies"] = s["stars"] >= req["min_stars"]
|
||||
songs.sort(key=lambda s: (not s["qualifies"], -s["stars"],
|
||||
s["title"].lower()))
|
||||
qualifying = sum(1 for s in songs if s["qualifies"])
|
||||
required = req["virtuoso_nodes"]
|
||||
cleared = [n for n in required if _node_cleared(by_node, n)]
|
||||
is_graded = inst in graded
|
||||
if not is_graded:
|
||||
# Where the engine can't fairly grade the instrument's job
|
||||
# (bass pocket, feel) the passport shows repertoire, never a
|
||||
# false badge denial — the doc's shown-not-judged rule.
|
||||
badge = "shown_not_judged"
|
||||
elif qualifying >= req["songs"] and len(cleared) == len(required):
|
||||
badge = "earned"
|
||||
else:
|
||||
badge = "in_progress"
|
||||
passports.append({
|
||||
"genre_key": gkey,
|
||||
"genre": meta.get("genre") or gkey,
|
||||
"opened_at": meta.get("opened_at"),
|
||||
"requirement": req,
|
||||
"graded": is_graded,
|
||||
"songs": songs,
|
||||
"qualifying_count": qualifying,
|
||||
"drills": {"required": required, "cleared": cleared},
|
||||
"badge": badge,
|
||||
})
|
||||
instruments[inst] = {"committed_at": committed_at, "passports": passports}
|
||||
return {
|
||||
"config": {
|
||||
"badge_requirement": cfg.get("badge_requirement") or {},
|
||||
"graded_instruments": sorted(graded),
|
||||
"instruments": list(cfg.get("instruments") or []),
|
||||
},
|
||||
"instruments": instruments,
|
||||
"genres": _library_genres(),
|
||||
"drill_state": {"received_at": received_at},
|
||||
}
|
||||
|
||||
|
||||
def _validate_pack_dir(pack_dir: Path):
|
||||
"""Raise ValueError unless pack_dir holds a complete venue pack."""
|
||||
manifest_path = pack_dir / "manifest.json"
|
||||
@ -197,6 +440,13 @@ def setup(app, context):
|
||||
_state["venues_dir"] = (
|
||||
Path(context["config_dir"]) / "plugin_uploads" / PLUGIN_ID / "venues")
|
||||
_state["venues_dir"].mkdir(parents=True, exist_ok=True)
|
||||
_state["passports_content"] = json.loads(
|
||||
(plugin_dir / "passports.json").read_text(encoding="utf-8"))
|
||||
# Persisted career state (commitment / opened passports / drill snapshot)
|
||||
# lives under CONFIG_DIR/career/ — declared in settings.server_files so it
|
||||
# rides the settings export/import bundle. Packs stay out (they're media).
|
||||
_state["state_dir"] = Path(context["config_dir"]) / PLUGIN_ID
|
||||
_state["state_dir"].mkdir(parents=True, exist_ok=True)
|
||||
_state["meta_db"] = context.get("meta_db")
|
||||
_state["log"] = context.get("log") or _state["log"]
|
||||
for v in _state["content"]["venues"]:
|
||||
@ -229,6 +479,64 @@ def setup(app, context):
|
||||
"venues": venues,
|
||||
}
|
||||
|
||||
@app.get(f"/api/plugins/{PLUGIN_ID}/passports")
|
||||
def get_passports():
|
||||
with _lock:
|
||||
return _passports_view()
|
||||
|
||||
@app.post(f"/api/plugins/{PLUGIN_ID}/passports/commit")
|
||||
def commit_instrument(body: dict = Body(...)):
|
||||
inst = str((body or {}).get("instrument") or "")
|
||||
if inst not in (_state["passports_content"].get("instruments") or []):
|
||||
raise HTTPException(400, "Unknown instrument.")
|
||||
with _lock:
|
||||
st = _career_state()
|
||||
entry = st["instruments"].setdefault(inst, {})
|
||||
# Idempotent: the wax seal is pressed once; re-commits keep the
|
||||
# original date (only-gained-never-lost).
|
||||
if not entry.get("committed_at"):
|
||||
entry["committed_at"] = _now_iso()
|
||||
_save_json(_state_file(), st)
|
||||
return {"ok": True, "instrument": inst,
|
||||
"committed_at": entry["committed_at"]}
|
||||
|
||||
@app.post(f"/api/plugins/{PLUGIN_ID}/passports/open")
|
||||
def open_passport(body: dict = Body(...)):
|
||||
inst = str((body or {}).get("instrument") or "")
|
||||
genre = _genre_display((body or {}).get("genre"))
|
||||
gkey = genre.lower()
|
||||
if inst not in (_state["passports_content"].get("instruments") or []):
|
||||
raise HTTPException(400, "Unknown instrument.")
|
||||
if not gkey or len(genre) > GENRE_MAX_LEN:
|
||||
raise HTTPException(400, "Provide a genre.")
|
||||
with _lock:
|
||||
st = _career_state()
|
||||
# Opening a passport implies the instrument commitment (permissive
|
||||
# server, ceremony ordering is the UI's job).
|
||||
st["instruments"].setdefault(inst, {}).setdefault(
|
||||
"committed_at", _now_iso())
|
||||
genres = st["passports"].setdefault(inst, {})
|
||||
if gkey not in genres:
|
||||
genres[gkey] = {"genre": genre, "opened_at": _now_iso()}
|
||||
_save_json(_state_file(), st)
|
||||
return {"ok": True, "instrument": inst, "passport": genres[gkey]}
|
||||
|
||||
@app.post(f"/api/plugins/{PLUGIN_ID}/drill-state")
|
||||
def post_drill_state(body: dict = Body(...)):
|
||||
# The relayed virtuoso.progress snapshot (career's screen.js listens to
|
||||
# the virtuoso:progress bus event and forwards the localStorage doc).
|
||||
# Only the fields the badge check reads are kept.
|
||||
if not isinstance(body, dict) or not isinstance(body.get("byNode"), dict):
|
||||
raise HTTPException(400, "Expected a progress snapshot with byNode.")
|
||||
snapshot = {"mode": body.get("mode"), "xp": body.get("xp"),
|
||||
"byNode": body["byNode"]}
|
||||
if len(json.dumps(snapshot)) > DRILL_SNAPSHOT_MAX_BYTES:
|
||||
raise HTTPException(413, "Snapshot too large.")
|
||||
with _lock:
|
||||
_save_json(_drill_file(), {"received_at": _now_iso(),
|
||||
"snapshot": snapshot})
|
||||
return {"ok": True}
|
||||
|
||||
@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
|
||||
|
||||
10
plugins/career/settings.html
Normal file
10
plugins/career/settings.html
Normal file
@ -0,0 +1,10 @@
|
||||
<!-- Career plugin — data panel. Exists so the passport/drill state declared in
|
||||
settings.server_files has a visible home in Settings; nothing to configure. -->
|
||||
<div class="text-sm text-gray-300 space-y-2">
|
||||
<p><strong>Career</strong> computes stars and genre badges from your play
|
||||
stats — they are never stored, so there is nothing to back up or reset.</p>
|
||||
<p class="text-gray-400">What <em>is</em> saved server-side: your instrument
|
||||
commitments, opened genre passports, and the practice-drill snapshot the
|
||||
Virtuoso plugin reports. These ride along in
|
||||
<em>Settings → Export</em> automatically.</p>
|
||||
</div>
|
||||
@ -1,3 +1,4 @@
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@ -14,25 +15,45 @@ import routes as career_routes
|
||||
|
||||
|
||||
class FakeMetaDb:
|
||||
"""song_stats-only stand-in for MetadataDB (the plugin reads nothing else)."""
|
||||
"""song_stats/songs stand-in for MetadataDB (the plugin reads nothing else).
|
||||
|
||||
The real song_stats.arrangement is an INTEGER index into the song's
|
||||
arrangements JSON; the legacy star tests pass strings ("guitar"), which
|
||||
the passport code treats as index-less → instrument defaults to guitar."""
|
||||
|
||||
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
|
||||
filename TEXT, arrangement TEXT, best_accuracy REAL,
|
||||
last_played_at TEXT
|
||||
)"""
|
||||
)
|
||||
self.conn.execute(
|
||||
"""CREATE TABLE songs (
|
||||
filename TEXT, title TEXT, artist TEXT,
|
||||
genre TEXT DEFAULT '', arrangements TEXT
|
||||
)"""
|
||||
)
|
||||
self.conn.execute("CREATE TABLE songs (filename TEXT, title TEXT, artist TEXT)")
|
||||
|
||||
def add(self, filename, arrangement, best_accuracy, in_library=True):
|
||||
self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?)",
|
||||
(filename, arrangement, best_accuracy))
|
||||
def add(self, filename, arrangement, best_accuracy, in_library=True,
|
||||
genre="", arrangements=None, last_played_at=None):
|
||||
self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?, ?)",
|
||||
(filename, arrangement, best_accuracy, last_played_at))
|
||||
if in_library:
|
||||
self.conn.execute(
|
||||
"INSERT INTO songs SELECT ?, ?, ? WHERE NOT EXISTS "
|
||||
"INSERT INTO songs SELECT ?, ?, ?, ?, ? WHERE NOT EXISTS "
|
||||
"(SELECT 1 FROM songs WHERE filename = ?)",
|
||||
(filename, filename.replace(".feedpak", "").title(), "Test Artist", filename))
|
||||
(filename, filename.replace(".feedpak", "").title(), "Test Artist",
|
||||
genre,
|
||||
json.dumps(arrangements) if arrangements is not None else None,
|
||||
filename))
|
||||
self.conn.commit()
|
||||
|
||||
def add_song_only(self, filename, genre=""):
|
||||
"""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.commit()
|
||||
|
||||
|
||||
|
||||
145
tests/plugins/career/test_passports.py
Normal file
145
tests/plugins/career/test_passports.py
Normal file
@ -0,0 +1,145 @@
|
||||
"""HTTP-level tests for the passport layer: badges, stubs, genres, drill intake.
|
||||
|
||||
Badges are computed on read (never stored): N genre songs at min_stars — with
|
||||
stars ≥2 meaning best_accuracy ≥ 0.75 under the default 0.6/0.75/0.85
|
||||
thresholds — plus any configured virtuoso drills.
|
||||
"""
|
||||
|
||||
import routes as career_routes
|
||||
|
||||
LEAD = [{"type": "lead", "name": "Lead"}]
|
||||
BASS = [{"type": "bass", "name": "Bass"}]
|
||||
|
||||
|
||||
def _open(client, instrument="guitar", genre="Blues"):
|
||||
res = client.post("/api/plugins/career/passports/open",
|
||||
json={"instrument": instrument, "genre": genre})
|
||||
assert res.status_code == 200
|
||||
return res.json()
|
||||
|
||||
|
||||
def _passport(client, instrument="guitar", genre_key="blues"):
|
||||
view = client.get("/api/plugins/career/passports").json()
|
||||
for p in view["instruments"][instrument]["passports"]:
|
||||
if p["genre_key"] == genre_key:
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def test_badge_earned_at_five_genre_songs_two_stars(client, meta_db):
|
||||
for i in range(5):
|
||||
meta_db.add(f"blues{i}.feedpak", 0, 0.8, genre="Blues", arrangements=LEAD)
|
||||
_open(client)
|
||||
p = _passport(client)
|
||||
assert p["badge"] == "earned"
|
||||
assert p["qualifying_count"] == 5
|
||||
assert all(s["qualifies"] and s["stars"] == 2 for s in p["songs"])
|
||||
|
||||
|
||||
def test_badge_in_progress_below_the_bar(client, meta_db):
|
||||
for i in range(4):
|
||||
meta_db.add(f"blues{i}.feedpak", 0, 0.8, genre="Blues", arrangements=LEAD)
|
||||
meta_db.add("weak.feedpak", 0, 0.65, genre="Blues", arrangements=LEAD) # 1★
|
||||
_open(client)
|
||||
p = _passport(client)
|
||||
assert p["badge"] == "in_progress"
|
||||
assert p["qualifying_count"] == 4
|
||||
# Qualifying stubs sort ahead of the near-misses.
|
||||
assert [s["qualifies"] for s in p["songs"]] == [True] * 4 + [False]
|
||||
|
||||
|
||||
def test_instruments_split_and_bass_is_shown_not_judged(client, meta_db):
|
||||
# Same 5 songs but played on the BASS arrangement: no guitar badge credit.
|
||||
for i in range(5):
|
||||
meta_db.add(f"blues{i}.feedpak", 0, 0.9, genre="Blues", arrangements=BASS)
|
||||
_open(client, "guitar")
|
||||
_open(client, "bass")
|
||||
guitar = _passport(client, "guitar")
|
||||
bass = _passport(client, "bass")
|
||||
assert guitar["qualifying_count"] == 0 and guitar["badge"] == "in_progress"
|
||||
assert bass["qualifying_count"] == 5
|
||||
# Bass isn't a graded instrument: repertoire shows, no pass/fail bar.
|
||||
assert bass["badge"] == "shown_not_judged" and bass["graded"] is False
|
||||
|
||||
|
||||
def test_best_accuracy_per_instrument_across_arrangements(client, meta_db):
|
||||
both = [{"type": "lead", "name": "Lead"}, {"type": "lead", "name": "Alt. Lead"}]
|
||||
meta_db.add("song.feedpak", 0, 0.7, genre="Blues", arrangements=both)
|
||||
meta_db.add("song.feedpak", 1, 0.9, genre="Blues", arrangements=both)
|
||||
_open(client)
|
||||
p = _passport(client)
|
||||
assert len(p["songs"]) == 1
|
||||
assert p["songs"][0]["best_accuracy"] == 0.9
|
||||
assert p["songs"][0]["stars"] == 3
|
||||
|
||||
|
||||
def test_orphaned_songs_do_not_feed_stubs(client, meta_db):
|
||||
meta_db.add("gone.feedpak", 0, 0.9, genre="Blues", arrangements=LEAD,
|
||||
in_library=False)
|
||||
_open(client)
|
||||
assert _passport(client)["songs"] == []
|
||||
|
||||
|
||||
def test_genre_rack_collapses_case_and_skips_blank(client, meta_db):
|
||||
meta_db.add_song_only("a.feedpak", genre="Blues")
|
||||
meta_db.add_song_only("b.feedpak", genre="blues")
|
||||
meta_db.add_song_only("c.feedpak", genre="Funk")
|
||||
meta_db.add_song_only("d.feedpak", genre="")
|
||||
genres = client.get("/api/plugins/career/passports").json()["genres"]
|
||||
assert genres == [
|
||||
{"genre_key": "blues", "genre": "Blues", "songs_in_library": 2},
|
||||
{"genre_key": "funk", "genre": "Funk", "songs_in_library": 1},
|
||||
]
|
||||
|
||||
|
||||
def test_commit_is_idempotent_and_open_implies_commit(client):
|
||||
first = client.post("/api/plugins/career/passports/commit",
|
||||
json={"instrument": "guitar"}).json()
|
||||
again = client.post("/api/plugins/career/passports/commit",
|
||||
json={"instrument": "guitar"}).json()
|
||||
assert first["committed_at"] == again["committed_at"]
|
||||
_open(client, "bass", "Funk")
|
||||
view = client.get("/api/plugins/career/passports").json()
|
||||
assert view["instruments"]["bass"]["committed_at"]
|
||||
# Re-opening the same passport keeps the original opened_at.
|
||||
opened = view["instruments"]["bass"]["passports"][0]["opened_at"]
|
||||
_open(client, "bass", " funk ") # normalizes to the same key
|
||||
view = client.get("/api/plugins/career/passports").json()
|
||||
assert [p["opened_at"] for p in view["instruments"]["bass"]["passports"]] == [opened]
|
||||
|
||||
|
||||
def test_open_and_commit_validation(client):
|
||||
assert client.post("/api/plugins/career/passports/commit",
|
||||
json={"instrument": "theremin"}).status_code == 400
|
||||
assert client.post("/api/plugins/career/passports/open",
|
||||
json={"instrument": "guitar", "genre": " "}).status_code == 400
|
||||
assert client.post("/api/plugins/career/passports/open",
|
||||
json={"instrument": "guitar", "genre": "x" * 65}).status_code == 400
|
||||
|
||||
|
||||
def test_drill_requirement_gates_badge_until_snapshot_clears_it(client, meta_db):
|
||||
for i in range(5):
|
||||
meta_db.add(f"blues{i}.feedpak", 0, 0.8, genre="Blues", arrangements=LEAD)
|
||||
career_routes._state["passports_content"]["genres"]["blues"] = {
|
||||
"virtuoso_nodes": ["node.shuffle"]}
|
||||
_open(client)
|
||||
p = _passport(client)
|
||||
assert p["badge"] == "in_progress"
|
||||
assert p["drills"] == {"required": ["node.shuffle"], "cleared": []}
|
||||
|
||||
res = client.post("/api/plugins/career/drill-state", json={
|
||||
"mode": "casual", "xp": 120,
|
||||
"byNode": {"node.shuffle": {"masteredAt": 1720000000,
|
||||
"depth": {"travel": None}}}})
|
||||
assert res.status_code == 200
|
||||
p = _passport(client)
|
||||
assert p["drills"]["cleared"] == ["node.shuffle"]
|
||||
assert p["badge"] == "earned"
|
||||
|
||||
|
||||
def test_drill_state_validation(client):
|
||||
assert client.post("/api/plugins/career/drill-state",
|
||||
json={"mode": "casual"}).status_code == 400
|
||||
huge = {"byNode": {"pad": "x" * (300 * 1024)}}
|
||||
assert client.post("/api/plugins/career/drill-state",
|
||||
json=huge).status_code == 413
|
||||
Loading…
Reference in New Issue
Block a user