Files
feedBack/tests/plugins/career/test_passports.py
be473dc7af Career v3: Gold tier — verified improv upgrades an earned badge (#960)
* feat(career): Gold tier — a family-style goldImprov artifact upgrades an earned badge

The drill-state relay's goldImprov map (virtuoso gold_improv mints,
gained-only merged like drill nodes) turns an earned badge gold when the
passport's genre — or its genre family — has a verified improv artifact.
Gold never substitutes for the badge bar: gold-without-bronze stays
in_progress.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(career): Gold tier frontend — relay, ceremony, slam, gold ink everywhere

The drill-state relay now carries virtuoso's goldImprov map; a badge that
comes back gold gets its own ceremony + notification (tier-suffixed seen
ids — the bronze moment stays seen under its legacy id, a gold slam marks
both), a gold stamp slam in the book, gold ink on the shelf-cover mini
stamp, and the real gold foil chip. The bronze page's dashed 'Gold rung
coming' preview becomes a live invitation to jam the style in Virtuoso.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(career): gold review fixes — family-space style matching, intake guards, rail counter

The review's showstopper: virtuoso mints goldImprov under raw
STYLE_PALETTES ids ('punk', 'djent', 'disco'), which are mostly NOT
family keys — the tier check now matches in family space (artifact style
and passport genre bucket through the same _genre_family keyword match),
so a 'punk' gold reaches a 'punk rock' passport. Also: non-dict
goldImprov 400s loudly instead of silently dropping; evidence-free
artifacts (no verifier) never mint; goldImprov gets the same pre-merge
size bound byNode has (junk under the cap could otherwise persist
forever and wedge every later relay at the post-merge check); the
instrument-rail badge counter counts gold (earning gold no longer made a
badge vanish from the rail); first-artifact-wins is now asserted against
the persisted snapshot instead of vacuously.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 11:26:36 +02:00

448 lines
22 KiB
Python

"""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