"""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): # Soul has no curated drill requirement — songs alone mint the badge. for i in range(5): meta_db.add(f"soul{i}.feedpak", 0, 0.8, genre="Soul", arrangements=LEAD) _open(client, "guitar", "Soul") p = _passport(client, "guitar", "soul") assert p["badge"] == "earned" assert p["qualifying_count"] == 5 assert all(s["qualifies"] and s["stars"] == 2 for s in p["songs"]) def test_shipped_blues_drill_gates_and_keys_cleared_clears_it(client, meta_db): # Blues ships a guitar drill (blues_shuffle): songs alone are not enough. 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"] == "in_progress" assert p["drills"]["required"] == ["blues_shuffle"] # One key cleared (a top-tier clean pass) counts as cleared — the depth # rungs are a higher bar than Bronze needs. res = client.post("/api/plugins/career/drill-state", json={ "mode": "casual", "xp": 10, "byNode": {"blues_shuffle": {"reps": 12, "keysCleared": ["E"], "depth": {"travel": None, "clean": None}, "masteredAt": None}}}) assert res.status_code == 200 p = _passport(client) assert p["drills"]["cleared"] == ["blues_shuffle"] assert p["badge"] == "earned" def test_drill_lists_are_per_instrument(client, meta_db): # Keys is graded but Blues curates only a GUITAR drill — a keys passport # earns on songs alone. keys_arr = [{"type": "lead", "name": "Keys"}] for i in range(5): meta_db.add(f"kb{i}.feedpak", 0, 0.9, genre="Blues", arrangements=keys_arr) _open(client, "keys") p = _passport(client, "keys") assert p["drills"]["required"] == [] assert p["badge"] == "earned" 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 def test_hours_odometer_sums_seconds_per_instrument_and_genre(client, meta_db): both = [{"type": "lead", "name": "Lead"}, {"type": "bass", "name": "Bass"}] # Two lead arrangements' time sums; the bass row stays on the bass passport. meta_db.add("a.feedpak", 0, 0.8, genre="Blues", arrangements=both, seconds_total=600) meta_db.add("b.feedpak", 0, 0.8, genre="Blues", arrangements=both, seconds_total=300) meta_db.add("b.feedpak", 1, 0.9, genre="Blues", arrangements=both, seconds_total=1200) _open(client, "guitar") _open(client, "bass") assert _passport(client, "guitar")["seconds_total"] == 900 assert _passport(client, "bass")["seconds_total"] == 1200 def test_drill_state_merge_is_gained_only(client, meta_db): # A cleared drill survives a later STALE snapshot that lacks it # (multi-browser race / settings import / the boot relay). for i in range(5): meta_db.add(f"blues{i}.feedpak", 0, 0.8, genre="Blues", arrangements=LEAD) _open(client) client.post("/api/plugins/career/drill-state", json={ "byNode": {"blues_shuffle": {"keysCleared": ["E"]}}}) assert _passport(client)["badge"] == "earned" # Stale relay: empty byNode, then one with the node but nothing earned. client.post("/api/plugins/career/drill-state", json={"byNode": {}}) client.post("/api/plugins/career/drill-state", json={ "byNode": {"blues_shuffle": {"reps": 2, "keysCleared": [], "depth": {"travel": None}, "masteredAt": None}}}) p = _passport(client) assert p["drills"]["cleared"] == ["blues_shuffle"] assert p["badge"] == "earned" def test_genre_families_inherit_drills(client, meta_db): # 'death metal' has no exact entry — it inherits the metal family's drill. for i in range(5): meta_db.add(f"dm{i}.feedpak", 0, 0.9, genre="Death Metal", arrangements=LEAD) _open(client, "guitar", "Death Metal") p = _passport(client, "guitar", "death metal") assert p["drills"]["required"] == ["melodic_metal_gallop"] assert p["badge"] == "in_progress" # 'metalcore' (single word) matches by substring, no alias needed. _open(client, "guitar", "Metalcore") assert _passport(client, "guitar", "metalcore")["drills"]["required"] == \ ["melodic_metal_gallop"] # 'blues rock' resolves by family LIST ORDER: blues comes before rock. _open(client, "guitar", "Blues Rock") assert _passport(client, "guitar", "blues rock")["drills"]["required"] == \ ["blues_shuffle"] # A genre outside every family stays songs-only. _open(client, "guitar", "Reggae") assert _passport(client, "guitar", "reggae")["drills"]["required"] == [] # Exact per-genre entries still beat the family (the shipped 'metal' entry # IS the exact entry for genre key 'metal'). _open(client, "guitar", "Metal") assert _passport(client, "guitar", "metal")["drills"]["required"] == \ ["melodic_metal_gallop"] def test_family_drills_stay_per_instrument(client, meta_db): # Family inheritance must not leak guitar drills onto other instruments. keys_arr = [{"type": "lead", "name": "Keys"}] for i in range(5): meta_db.add(f"kdm{i}.feedpak", 0, 0.9, genre="Death Metal", arrangements=keys_arr) _open(client, "keys", "Death Metal") p = _passport(client, "keys", "death metal") assert p["drills"]["required"] == [] assert p["badge"] == "earned" def test_nearest_invitations_order_and_exclusions(client, meta_db): # Non-qualifying songs sorted by distance to the QUALIFYING bar; # qualifying songs never appear; capped at 3. meta_db.add("q.feedpak", 0, 0.80, genre="Soul", arrangements=LEAD) # qualifies meta_db.add("close.feedpak", 0, 0.74, genre="Soul", arrangements=LEAD) # 1% to 2★ meta_db.add("mid.feedpak", 0, 0.70, genre="Soul", arrangements=LEAD) # 5% to 2★ meta_db.add("far.feedpak", 0, 0.30, genre="Soul", arrangements=LEAD) # 30% to 1★ meta_db.add("far2.feedpak", 0, 0.25, genre="Soul", arrangements=LEAD) _open(client, "guitar", "Soul") p = _passport(client, "guitar", "soul") names = [s["filename"] for s in p["nearest"]] assert names == ["close.feedpak", "mid.feedpak", "far.feedpak"] assert all(s["next_star_at"] is not None for s in p["nearest"]) assert "q.feedpak" not in names def test_nearest_targets_the_qualifying_bar_not_next_star(client, meta_db): # A 0★ song 1% from its NEXT star is farther from the ★★ badge bar than # a 1★ song 5% from it — nearest must rank by the badge bar. meta_db.add("one_star.feedpak", 0, 0.70, genre="Soul", arrangements=LEAD) # 5% to bar meta_db.add("zero_star.feedpak", 0, 0.59, genre="Soul", arrangements=LEAD) # 1% to next ★, 16% to bar _open(client, "guitar", "Soul") p = _passport(client, "guitar", "soul") assert [s["filename"] for s in p["nearest"]] == ["one_star.feedpak", "zero_star.feedpak"] assert all(s["bar_at"] == 0.75 for s in p["nearest"]) # ── Gigs ────────────────────────────────────────────────────────────────────── def test_gig_propose_mixes_owned_and_stakes(client, meta_db): for i in range(4): meta_db.add(f"own{i}.feedpak", 0, 0.85, genre="Soul", arrangements=LEAD) meta_db.add("stake.feedpak", 0, 0.70, genre="Soul", arrangements=LEAD) meta_db.add_song_only("fresh.feedpak", genre="Soul") res = client.post("/api/plugins/career/gigs/propose", json={"instrument": "guitar", "genre": "Soul", "size": 4}) assert res.status_code == 200 gig = res.json() files = [s["filename"] for s in gig["songs"]] assert len(files) == 4 assert "stake.feedpak" in files # a near-bar song gives the set stakes assert gig["venue_id"] == "bar" # 9 stars < 50: the dive bar # A young passport (nothing played) still gets a playable set from the # library's unplayed genre songs. res2 = client.post("/api/plugins/career/gigs/propose", json={"instrument": "guitar", "genre": "Ska"}) assert res2.status_code == 404 # no ska in the library at all meta_db.add_song_only("ska1.feedpak", genre="Ska") res3 = client.post("/api/plugins/career/gigs/propose", json={"instrument": "guitar", "genre": "Ska"}) assert [s["filename"] for s in res3.json()["songs"]] == ["ska1.feedpak"] def test_gig_log_computes_encore_and_surfaces_in_passports(client, meta_db): for i in range(2): meta_db.add(f"s{i}.feedpak", 0, 0.9, genre="Soul", arrangements=LEAD, last_accuracy=0.9) _open(client, "guitar", "Soul") res = client.post("/api/plugins/career/gigs", json={ "instrument": "guitar", "genre": "Soul", "venue_id": "bar", "songs": ["s0.feedpak", "s1.feedpak"]}) assert res.status_code == 200 gig = res.json()["gig"] assert gig["encore"] is True # avg 0.9 ≥ 0.75 assert gig["songs"][0]["accuracy"] == 0.9 view = client.get("/api/plugins/career/passports").json() assert view["instruments"]["guitar"]["gig_count"] == 1 p = _passport(client, "guitar", "soul") assert len(p["gigs"]) == 1 and p["gigs"][0]["encore"] is True def test_gig_log_validation_and_no_fail_state(client): # Unknown venue / bad songs shapes are rejected; nothing is ever logged # as a failed gig — the endpoint only appends completed sets. assert client.post("/api/plugins/career/gigs", json={ "instrument": "guitar", "genre": "Soul", "venue_id": "nope", "songs": ["x"]}).status_code == 400 assert client.post("/api/plugins/career/gigs", json={ "instrument": "guitar", "genre": "Soul", "songs": []}).status_code == 400 assert client.post("/api/plugins/career/gigs", json={ "instrument": "guitar", "genre": "Soul", "songs": ["f"] * 9}).status_code == 400 def test_gig_accuracy_reads_newest_row_and_encore_needs_full_set(client, meta_db): # Newest row wins: a stale higher accuracy on another arrangement must # not inflate the gig log. meta_db.add("dual.feedpak", 1, 0.95, genre="Soul", arrangements=BASS, last_accuracy=0.95, last_played_at="2026-06-01T00:00:00") meta_db.add("dual.feedpak", 0, 0.60, genre="Soul", arrangements=LEAD, last_accuracy=0.60, last_played_at="2026-07-14T00:00:00") res = client.post("/api/plugins/career/gigs", json={ "instrument": "guitar", "genre": "Soul", "songs": ["dual.feedpak"]}) assert res.json()["gig"]["songs"][0]["accuracy"] == 0.6 # A set with an unscored song never earns the encore off one good song. meta_db.add("scored.feedpak", 0, 0.9, genre="Soul", arrangements=LEAD, last_accuracy=0.9, last_played_at="2026-07-14T00:01:00") res2 = client.post("/api/plugins/career/gigs", json={ "instrument": "guitar", "genre": "Soul", "songs": ["scored.feedpak", "ghost.feedpak"]}) assert res2.json()["gig"]["encore"] is False def test_gig_propose_backfills_from_surplus_qualifying(client, meta_db): # Mature passport: plenty of qualifying songs, nothing near the bar, # nothing unplayed — the set still fills to size. for i in range(8): meta_db.add(f"own{i}.feedpak", 0, 0.9, genre="Ska", arrangements=LEAD) res = client.post("/api/plugins/career/gigs/propose", json={"instrument": "guitar", "genre": "Ska", "size": 5}) assert len(res.json()["songs"]) == 5 def test_gig_propose_backfill_offset_survives_stakes(client, meta_db): # 4 qualifying + 1 near-bar stake, size 5: the stake must not shift the # qualifying backfill window past eligible songs. for i in range(4): meta_db.add(f"q{i}.feedpak", 0, 0.9, genre="Reggae", arrangements=LEAD) meta_db.add("near.feedpak", 0, 0.7, genre="Reggae", arrangements=LEAD) res = client.post("/api/plugins/career/gigs/propose", json={"instrument": "guitar", "genre": "Reggae", "size": 5}) files = [s["filename"] for s in res.json()["songs"]] assert len(files) == 5 and len(set(files)) == 5 assert "near.feedpak" in files # ── Gold rung ───────────────────────────────────────────────────────────────── def test_gold_upgrades_bronze_via_family_style_artifact(client, meta_db): # Bronze earned on 'death metal' (family: metal); a metal gold artifact # from the jam verifier upgrades it — bronze-only stays 'earned' elsewhere. for i in range(5): meta_db.add(f"dm{i}.feedpak", 0, 0.9, genre="Death Metal", arrangements=LEAD) career_routes._state["passports_content"]["genres"]["metal"] = {} # no drill gate for this test _open(client, "guitar", "Death Metal") client.post("/api/plugins/career/drill-state", json={"byNode": {}}) assert _passport(client, "guitar", "death metal")["badge"] == "earned" client.post("/api/plugins/career/drill-state", json={ "byNode": {}, "goldImprov": {"metal": {"at": 1, "verifier": "comb", "inKeyPct": 0.9}}}) assert _passport(client, "guitar", "death metal")["badge"] == "gold" def test_gold_without_bronze_stays_in_progress(client, meta_db): meta_db.add("one.feedpak", 0, 0.9, genre="Soul", arrangements=LEAD) _open(client, "guitar", "Soul") client.post("/api/plugins/career/drill-state", json={ "byNode": {}, "goldImprov": {"soul": {"at": 1, "verifier": "comb"}}}) assert _passport(client, "guitar", "soul")["badge"] == "in_progress" def test_gold_merge_is_gained_only(client, meta_db): for i in range(5): meta_db.add(f"s{i}.feedpak", 0, 0.9, genre="Soul", arrangements=LEAD) _open(client, "guitar", "Soul") client.post("/api/plugins/career/drill-state", json={ "byNode": {}, "goldImprov": {"soul": {"at": 1, "verifier": "comb"}}}) assert _passport(client, "guitar", "soul")["badge"] == "gold" # A stale relay without the artifact never un-mints. client.post("/api/plugins/career/drill-state", json={"byNode": {}}) assert _passport(client, "guitar", "soul")["badge"] == "gold" # And a different artifact for the same style never overwrites the first — # asserted against the PERSISTED snapshot (the view doesn't expose # artifact contents), so a last-write-wins regression can't stay green. client.post("/api/plugins/career/drill-state", json={ "byNode": {}, "goldImprov": {"soul": {"at": 999, "verifier": "yin"}}}) _, _, gold = career_routes._drill_by_node() assert gold["soul"] == {"at": 1, "verifier": "comb"} def test_gold_matches_raw_style_id_through_family(client, meta_db): # Virtuoso mints under raw STYLE_PALETTES ids ('punk', not 'rock'): a # 'punk rock' passport (family rock) must go gold from a 'punk' artifact. for i in range(5): meta_db.add(f"pk{i}.feedpak", 0, 0.9, genre="Punk Rock", arrangements=LEAD) career_routes._state["passports_content"]["genres"]["rock"] = {} # no drill gate _open(client, "guitar", "Punk Rock") client.post("/api/plugins/career/drill-state", json={ "byNode": {}, "goldImprov": {"punk": {"at": 1, "verifier": "comb"}}}) assert _passport(client, "guitar", "punk rock")["badge"] == "gold" def test_gold_intake_rejects_junk(client, meta_db): # A non-dict goldImprov is a relay bug: loud 400, never a silent drop. res = client.post("/api/plugins/career/drill-state", json={"byNode": {}, "goldImprov": ["metal"]}) assert res.status_code == 400 # Evidence-free artifacts (no verifier) never mint. for i in range(5): meta_db.add(f"j{i}.feedpak", 0, 0.9, genre="Soul", arrangements=LEAD) _open(client, "guitar", "Soul") client.post("/api/plugins/career/drill-state", json={ "byNode": {}, "goldImprov": {"soul": {}}}) assert _passport(client, "guitar", "soul")["badge"] == "earned" # An oversized goldImprov is bounded BEFORE the merge, like byNode. blob = {f"s{i}": {"verifier": "comb", "pad": "x" * 4096} for i in range(200)} res = client.post("/api/plugins/career/drill-state", json={"byNode": {}, "goldImprov": blob}) assert res.status_code == 413 def test_gig_includes_songs_played_on_another_instrument(client, meta_db): # feedBack#… (tester): "Metalcore says 137 songs, only shows 1 in the gig list". # A song played on a DIFFERENT instrument's arrangement has a stats row, so it # was excluded from the unplayed filler — and its played bucket is that other # instrument's, not this passport's — so it fell into a gap and could never be # gigged. A guitar passport with a library of bass-played metalcore got a 404. for i in range(137): meta_db.add(f"mc{i}.feedpak", 0, 0.80, genre="Metalcore", arrangements=BASS) res = client.post("/api/plugins/career/gigs/propose", json={"instrument": "guitar", "genre": "Metalcore", "size": 4}) assert res.status_code == 200, "a full library of the genre must never 404" assert len(res.json()["songs"]) == 4, "the gig must fill from the library, not the gap" def test_gig_reroll_changes_the_set(client, meta_db): # feedBack#… (tester): "Passport re-roll does not change songs". A set drawn # from the filler used to be the library's first N in table order, every time. for i in range(40): meta_db.add_song_only(f"un{i}.feedpak", genre="Metalcore") sets = set() for _ in range(5): r = client.post("/api/plugins/career/gigs/propose", json={"instrument": "guitar", "genre": "Metalcore", "size": 4}) sets.add(tuple(sorted(s["filename"] for s in r.json()["songs"]))) assert len(sets) > 1, "re-roll must be able to produce a different set"