feat(career): practice invitations — closest stamps + bring-these-up (#953)

* feat(career): practice invitations — closest stamps + bring-these-up

Career v3, WS1. The passport now points at the practice that pays:

- Stubs carry next_star_at (the same primitive _stars() uses) and each
  passport exposes `nearest`: the top 3 non-qualifying songs by distance
  to their next star, in the worklist order.
- "Closest stamps" strip above the shelf: the in-progress graded
  passports nearest to minting, each row naming the ask — N more songs
  (with the nearest title + best %) or the blocking Virtuoso drill.
  Rows open the passport.
- "Bring these up" list on the stubs page of in-progress passports;
  earned pages stay memorabilia (no homework on a won badge).
- Invitation-voiced throughout: no meters, no completion pressure.

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

* docs(test): comment says qualifying-bar ranking, matching the assertion

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos
2026-07-14 01:10:55 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 6cc0312661
commit 831117fb96
5 changed files with 157 additions and 17 deletions
+27
View File
@@ -245,3 +245,30 @@ def test_family_drills_stay_per_instrument(client, meta_db):
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"])