mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
feat(playlists): flag songs that are not in your current tuning (#1009)
* feat(playlists): flag songs that are not in your current tuning
Making the library's tuning filter instrument-aware does not repair playlists
already built under the old guitar-first behaviour. Those keep their
wrong-tuning songs, so a player still hits a surprise retune mid-practice and
reasonably concludes nothing was fixed.
Adds a per-playlist check: each row is marked against the player's current
tuning, with a summary ("3 of 24 songs are not in your tuning"), a filter to
show only those, and an explicit removal that lists every affected song by
title and states they stay in the library. Flagging is the feature -- nothing
is ever removed without being asked for, and removal reuses the existing
per-song DELETE rather than adding a bulk destructive endpoint.
Reuses the tuner capability's coverage report and `window.feedBack
.workingTuning`, the same pair the library cards already score against,
rather than introducing another source of truth.
Two deliberate departures:
- A coverage report reads "not covered" both for a real mismatch and for a
bail-out it could not evaluate. Only a report carrying an actual reason
counts as a mismatch; the rest render as unknown. This differs from the
library grid, which paints every not-covered song amber -- acceptable on a
grid, not on a hand-curated playlist where a false warning costs trust.
- With no tuning perspective available it makes no claim at all, rather than
defaulting to guitar and reproducing the original bug in a new place.
Playlist rows carry `tuning_offsets` and `bass_only`; a tuning *name* cannot
be scored, since two "Custom Tuning" rows are different tunings.
Fully correct once the instrument-aware tuning filter lands. That dependency
is confined to `rowTuningForCheck()` in static/v3/playlists.js, marked SEAM.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFDokqh2H6mEjk1Kgbi6JW
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
* build(tailwind): regenerate for the playlist tuning-check classes
CI's tailwind-fresh gate rebuilds static/tailwind.min.css and hard-fails if
the committed file differs. The new chip/summary/filter markup introduces
classes the previous build never saw.
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
* fix(playlists): stay within the shipped Tailwind class set
Reverts the regenerated static/tailwind.min.css and reworks the tuning-check
markup to use only classes already in the committed sheet.
Regenerating that file is not reproducible off CI: nothing pins tailwindcss,
autoprefixer or caniuse-lite, so a local `npx -y tailwindcss@3.4.19` resolves
different browser data and rewrites unrelated bytes -- a clean checkout of
main rebuilds with the -webkit-backdrop-filter prefixes dropped. Committing
that output fails the tailwind-fresh gate no matter how many times it is
regenerated.
Six utilities were new: bg-fb-good/10, border-fb-accent/50,
hover:bg-fb-accent/10, list-disc, list-inside, max-h-48, plus gap-x-3/gap-y-2.
Substituted bg-fb-good/30, the amber border already used by the mismatch
state, hover:bg-fb-card, a literal bullet in a div, max-h-32 and gap-3. Visual
intent is unchanged.
The removal-confirm test pinned the <li> markup; it now accepts either
wrapper, since what it guards is that every song is named and escaped ahead
of any DELETE, not which element wraps it.
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
* Use instrument tuning in playlist checks
---------
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
cc75cb876a
commit
1745b13ba7
@@ -266,3 +266,94 @@ def test_new_playlist_after_manual_reorder_sorts_alphabetically_after_positioned
|
||||
assert client.post("/api/playlists/reorder", json={"order": [b, a]}).status_code == 400
|
||||
assert client.post("/api/playlists/reorder", json={"order": [z, aa, b, a]}).status_code == 200
|
||||
assert _ids(client) == [z, aa, b, a]
|
||||
|
||||
|
||||
# ── Tuning-check payload (per-song data the playlist tuning check scores) ────
|
||||
# A playlist grouped BY TUNING is a run you can practise without retuning, so
|
||||
# the detail view flags rows your instrument can't reach. Scoring needs more
|
||||
# than the tuning NAME: two "Custom Tuning" rows are different tunings, and a
|
||||
# bass-only chart has to be measured against bass base pitches.
|
||||
|
||||
def test_playlist_songs_carry_tuning_offsets_for_the_check(client, server):
|
||||
db = server.meta_db
|
||||
db.put("drop.archive", 0, 0, {"title": "Drop", "tuning_name": "Drop D",
|
||||
"tuning_offsets": "-2 0 0 0 0 0"})
|
||||
pid = client.post("/api/playlists", json={"name": "T"}).json()["id"]
|
||||
client.post(f"/api/playlists/{pid}/songs", json={"filename": "drop.archive"})
|
||||
song = client.get(f"/api/playlists/{pid}").json()["songs"][0]
|
||||
assert song["tuning_offsets"] == "-2 0 0 0 0 0"
|
||||
assert song["tuning_name"] == "Drop D"
|
||||
|
||||
|
||||
def test_playlist_songs_carry_role_specific_tunings(client, server):
|
||||
db = server.meta_db
|
||||
db.put("roles.archive", 0, 0, {
|
||||
"title": "Roles",
|
||||
"tuning_name": "E Standard",
|
||||
"tuning_offsets": "0 0 0 0 0 0",
|
||||
"bass_tuning_name": "A Standard",
|
||||
"bass_tuning_offsets": "-2 -2 -2 -2 -2 -2",
|
||||
"rhythm_tuning_name": "Drop D",
|
||||
"rhythm_tuning_offsets": "-2 0 0 0 0 0",
|
||||
})
|
||||
pid = client.post("/api/playlists", json={"name": "Roles"}).json()["id"]
|
||||
client.post(f"/api/playlists/{pid}/songs", json={"filename": "roles.archive"})
|
||||
song = client.get(f"/api/playlists/{pid}").json()["songs"][0]
|
||||
assert song["bass_tuning_name"] == "A Standard"
|
||||
assert song["bass_tuning_offsets"] == "-2 -2 -2 -2 -2 -2"
|
||||
assert song["rhythm_tuning_name"] == "Drop D"
|
||||
assert song["rhythm_tuning_offsets"] == "-2 0 0 0 0 0"
|
||||
|
||||
|
||||
def test_playlist_songs_flag_bass_only_charts(client, server):
|
||||
# Every arrangement a bass part → bass_only, so coverage scores the row
|
||||
# against bass strings. A chart that ALSO has a guitar part must not be
|
||||
# flagged, or a guitarist's row gets measured on the wrong instrument.
|
||||
db = server.meta_db
|
||||
db.put("bassonly.archive", 0, 0, {"title": "Bass Only", "arrangements": [
|
||||
{"name": "Bass"}, {"name": "Alt. Bass"}]})
|
||||
db.put("mixed.archive", 0, 0, {"title": "Mixed", "arrangements": [
|
||||
{"name": "Lead"}, {"name": "Bass"}]})
|
||||
db.put("noarr.archive", 0, 0, {"title": "No Arrangements"})
|
||||
pid = client.post("/api/playlists", json={"name": "B"}).json()["id"]
|
||||
for fn in ("bassonly.archive", "mixed.archive", "noarr.archive"):
|
||||
client.post(f"/api/playlists/{pid}/songs", json={"filename": fn})
|
||||
got = {s["filename"]: s["bass_only"] for s in client.get(f"/api/playlists/{pid}").json()["songs"]}
|
||||
assert got == {"bassonly.archive": True, "mixed.archive": False, "noarr.archive": False}
|
||||
|
||||
|
||||
def test_bass_only_flag_survives_adversarial_arrangement_data(client, server):
|
||||
# Corrupt/odd `arrangements` must not 500 the playlist, and must not claim
|
||||
# bass — an unscoreable row is left for the client to report as "unknown".
|
||||
db = server.meta_db
|
||||
cases = {
|
||||
"empty.archive": [],
|
||||
"unnamed.archive": [{"name": ""}],
|
||||
"nullname.archive": [{"name": None}],
|
||||
"substring.archive": [{"name": "Bassoon"}], # not a bass part
|
||||
"cased.archive": [{"name": "BASS"}], # is one
|
||||
}
|
||||
for fn, arrs in cases.items():
|
||||
db.put(fn, 0, 0, {"title": fn, "arrangements": arrs})
|
||||
pid = client.post("/api/playlists", json={"name": "Adv"}).json()["id"]
|
||||
for fn in cases:
|
||||
client.post(f"/api/playlists/{pid}/songs", json={"filename": fn})
|
||||
r = client.get(f"/api/playlists/{pid}")
|
||||
assert r.status_code == 200
|
||||
got = {s["filename"]: s["bass_only"] for s in r.json()["songs"]}
|
||||
assert got == {"empty.archive": False, "unnamed.archive": False,
|
||||
"nullname.archive": False, "substring.archive": False,
|
||||
"cased.archive": True}
|
||||
|
||||
|
||||
def test_playlist_song_with_no_tuning_data_reports_empty_not_missing(client, server):
|
||||
# The key must always be present: the client distinguishes "no tuning data"
|
||||
# (unknown — say nothing) from "wrong tuning" (flag it), and a missing key
|
||||
# would make every row unscoreable by accident rather than by fact.
|
||||
db = server.meta_db
|
||||
db.put("bare.archive", 0, 0, {"title": "Bare"})
|
||||
pid = client.post("/api/playlists", json={"name": "Bare"}).json()["id"]
|
||||
client.post(f"/api/playlists/{pid}/songs", json={"filename": "bare.archive"})
|
||||
song = client.get(f"/api/playlists/{pid}").json()["songs"][0]
|
||||
assert song["tuning_offsets"] == ""
|
||||
assert song["bass_only"] is False
|
||||
|
||||
Reference in New Issue
Block a user