mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 19:29:33 +00:00
* fix(career): a gig's song pool is the whole genre, and re-roll varies it Two tester reports, one root: the gig song pool was built from only two sets — songs played ON THIS PASSPORT'S INSTRUMENT, and songs never played AT ALL (`filename NOT IN song_stats`). A song played on a DIFFERENT instrument's arrangement is in neither: it has a stats row (so the "unplayed" filler skipped it), and its played bucket is that other instrument's, not this passport's. It could never be gigged. - "Metalcore says 137 songs only shows 1 in the gig list" — a library of metalcore all played on another instrument. Reproduced: a guitar passport with 137 bass-played metalcore songs got a 404, zero songs. The "1" the tester saw was whatever handful happened to be on-instrument or truly unplayed. - "Passport re-roll does not change songs" — a set drawn from that filler was the library's first N in table ORDER, every call. Re-roll re-proposes, so it returned the identical set. Reproduced: 3 proposals, byte-identical. _unplayed_genre_songs -> _fill_genre_songs: the pool is now every library song of the genre the set hasn't already picked (a stats row on some other instrument has no bearing on whether a song can be in THIS gig), and it is shuffled so re-roll actually re-rolls. Both reproduced against the real propose logic before the fix and pinned as regression tests (both fail on the pre-fix routes.py). Full career suite green. * fix(career): load the gig's venue pack when the gig starts Tester: "Venue doesn't load when starting song from passport. Loads standard particles." crowd.setManifest(venue) — the call that actually loads a venue's crowd/stage pack — is reached ONLY through pushCrowdManifest, and pushCrowdManifest is called ONLY from refresh(), the career tab's own reload. A gig navigates AWAY from the career tab to the player, so refresh() never runs during it. startGig set the venue override and nulled _appliedManifestVenue but never re-pushed, so the venue visualization turned on (3D highway) while its pack never loaded — the song played over the bare highway backdrop, or over whatever venue a previous refresh() had left applied. startGig now pushes the crowd manifest for the gig venue right after setting the override, using the career state the booking screen already fetched. This is a call-graph fact, not a guess (pushCrowdManifest has exactly one other caller and startGig is not it), but it is fixed by static analysis — I could not reproduce the user-visible symptom locally because this instance happened to have a manifest already applied from a prior refresh. On-device confirmation on a real passport gig is still owed. Guard test: startGig must push the manifest after setting the override (fails on the pre-fix source). Career suite green.
475 lines
23 KiB
Python
475 lines
23 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
|
|
|
|
|
|
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"
|