mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-24 05:41:53 +00:00
* fix(library): tuning filter answers for your instrument, not always guitar The library indexed exactly one tuning per song, chosen guitar-first (lead > rhythm > combo, bass only as a last resort), and nothing consulted the player's instrument. A bassist filtering by tuning was shown the guitar chart's tuning, so playlists built by tuning contained songs needing a retune. Reported by a tester building bass practice sets; Covet "Shibuya" is the clean case, with a custom guitar tuning over a standard bass chart. Indexes each arrangement role's own tuning and makes the facet, filter, sort and labels answer for one perspective. `guitar-lead` reads the original unprefixed columns and adds no payload keys, so the default response is unchanged. The same defect existed inside guitar -- lead and rhythm charts can disagree -- so perspective is three-valued (guitar-lead, guitar-rhythm, bass) driven by one PERSPECTIVES table rather than parallel column families. Songs with no chart for the perspective fall back to the song-level tuning rather than vanishing (18 of 59 packs in the test library have no bass chart), but the fallback is marked inferred in the facet counts and on the row instead of being silently coalesced. "Only real charts" reuses the existing `arrangements_has` filter rather than adding one. Bass-specific handling, from measured content: - Bass tuning arrays are padded to six entries; charts never reference string index 4 or 5. Truncated to four before naming and grouping. - Grouping uses a canonical open-pitch key, so [-2,0,0,0] and [-2,0,0,0,0,0] are one facet row instead of two. - Offsets above +1 semitone are refused a name. Bassists tune down, near never up; one pack ships [5,5,5,5,4,4] (A-D-G-C, unplayable, and its own notes sit in the song's real key under standard tuning). Naming that would send a player to retune to a tuning that does not exist. Rhythm deliberately does not truncate -- padding is a bass finding, and cutting a seven-string array would invent a tuning the chart lacks. Adds an opt-in `tuning_match=playable` mode alongside exact match: a chart is offered when your lowest open pitch is at or below its lowest open pitch, so a five-string bass covers four-string standard and drop-D with no retune. Open strings only -- note range is not indexed and the scan stays manifest-only -- so it fails conservative: unknown low pitch is excluded, and the upper bound is unchecked and documented rather than guessed. Existing installs would otherwise never populate: the tree-signature fast path reports "unchanged" forever on a settled library. Rows with NULL marker columns re-extract, and the fast path is disabled until that backfill converges (writes use '' rather than NULL, so it self-clears). 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> * test(v3): accept the tuning-perspective indirection in the badge guard The album-art badge now reads shownTuningName(), so the source-pattern guard no longer matched the inline `tuning_name || tuning` form and CI went red. Accept the helper, and pin the helper's own fallback in a companion test so the guard still fails if a guitar player's tuning label is ever dropped. Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com> --------- Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
722 lines
35 KiB
Python
722 lines
35 KiB
Python
"""Instrument-aware tuning in the library (the KwasimodoZAZA bass report).
|
||
|
||
A song's BASS chart is often tuned differently from its guitar chart, but the
|
||
library indexed exactly one guitar-first tuning per song — so a bass player
|
||
filtering "Drop D" got songs whose GUITAR is in Drop D, and playlists built
|
||
that way were wrong.
|
||
|
||
These tests round-trip through the real extractors, the real scanner
|
||
derivation, the real SQLite schema/migration, and the real HTTP surface. The
|
||
only thing stubbed is metadata EXTRACTION in the scan tests (the production
|
||
process pool can't reach an in-process mock) — never the code under test.
|
||
|
||
Real-library notes, all confirmed against actual pack contents:
|
||
|
||
* Bass arrangements usually store SIX-element offset arrays even when the
|
||
chart is a 4-string part — slots 4-5 are PADDING (no bass chart in the
|
||
corpus references string index 4 or 5). So bass offsets are truncated to 4
|
||
before naming or grouping. The feedpak spec has no string-count field, so 4
|
||
is a documented default, not a read value.
|
||
* AC/DC "Girls Got Rhythm" stores [5,5,5,5,4,4] — every string up a fourth,
|
||
which no bassist plays. That is BAD DATA, and it must never be NAMED, or the
|
||
library sends a player to retune to a tuning that does not exist.
|
||
* Covet "Shibuya" (custom guitar tuning, dead-standard bass) is the headline
|
||
regression: the tester's bug in a single song.
|
||
"""
|
||
|
||
import importlib
|
||
import json
|
||
import sys
|
||
|
||
import pytest
|
||
import yaml
|
||
from fastapi.testclient import TestClient
|
||
|
||
import sloppak as sloppak_mod
|
||
from scan_worker import _extract_meta_for_file
|
||
from tunings import (
|
||
PERSPECTIVES, bass_offsets_are_plausible, bass_tuning_key, bass_tuning_name,
|
||
chart_is_playable_in, normalize_bass_offsets, perspective_tuning_key,
|
||
tuning_name,
|
||
)
|
||
|
||
|
||
# ── Fixtures ─────────────────────────────────────────────────────────────────
|
||
|
||
@pytest.fixture()
|
||
def server_mod(tmp_path, monkeypatch):
|
||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
||
sys.modules.pop("server", None)
|
||
mod = importlib.import_module("server")
|
||
yield mod
|
||
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
||
if conn is not None:
|
||
getattr(sys.modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||
conn.close()
|
||
|
||
|
||
@pytest.fixture()
|
||
def client(server_mod):
|
||
c = TestClient(server_mod.app)
|
||
try:
|
||
yield c
|
||
finally:
|
||
c.close()
|
||
|
||
|
||
def _pack(root, name, arrangements):
|
||
"""A directory-form pack whose manifest carries per-arrangement tunings."""
|
||
d = root / name
|
||
d.mkdir(parents=True)
|
||
(d / "manifest.yaml").write_text(yaml.safe_dump({
|
||
"title": name, "artist": "A", "duration": 100,
|
||
"arrangements": arrangements, "stems": [],
|
||
}), encoding="utf-8")
|
||
return d
|
||
|
||
|
||
def _put(server_mod, *, filename, title, tuning_name_="E Standard",
|
||
tuning_sort_key=0, tuning_offsets="0 0 0 0 0 0",
|
||
bass_tuning_name="", bass_tuning_sort_key=0, bass_tuning_offsets="",
|
||
bass_tuning_key=""):
|
||
server_mod.meta_db.put(filename, 1.0, 1, {
|
||
"title": title, "artist": "A", "album": "A - LP", "year": "2010",
|
||
"duration": 200.0, "tuning": tuning_name_, "arrangements": [],
|
||
"has_lyrics": False, "format": "sloppak", "stem_ids": [],
|
||
"tuning_name": tuning_name_,
|
||
"tuning_sort_key": tuning_sort_key,
|
||
"tuning_offsets": tuning_offsets,
|
||
"bass_tuning_name": bass_tuning_name,
|
||
"bass_tuning_sort_key": bass_tuning_sort_key,
|
||
"bass_tuning_offsets": bass_tuning_offsets,
|
||
"bass_tuning_key": bass_tuning_key,
|
||
})
|
||
|
||
|
||
# ── 1. Extraction: sloppak ───────────────────────────────────────────────────
|
||
|
||
def test_sloppak_extract_indexes_both_tunings_when_they_differ(tmp_path):
|
||
"""The reported case: guitar down a step, bass in standard. BOTH must be
|
||
indexed — previously only the guitar tuning survived."""
|
||
d = _pack(tmp_path, "differ.sloppak", [
|
||
{"name": "Lead", "tuning": [-2, 0, 0, -1, -2, 0]},
|
||
{"name": "Bass", "tuning": [0, 0, 0, 0, 0, 0]},
|
||
])
|
||
meta = sloppak_mod.extract_meta(d)
|
||
assert meta["tuning_offsets"] == [-2, 0, 0, -1, -2, 0]
|
||
assert meta["bass_tuning_offsets"] == [0, 0, 0, 0, 0, 0]
|
||
|
||
|
||
def test_sloppak_extract_leaves_bass_absent_without_bass_arrangement(tmp_path):
|
||
"""No bass chart → None, NOT a copy of the guitar tuning. The library
|
||
falls back explicitly, so 'no bass part' stays distinguishable."""
|
||
d = _pack(tmp_path, "nobass.sloppak", [
|
||
{"name": "Lead", "tuning": [-2, -2, -2, -2, -2, -2]},
|
||
{"name": "Rhythm", "tuning": [-2, -2, -2, -2, -2, -2]},
|
||
])
|
||
meta = sloppak_mod.extract_meta(d)
|
||
assert meta["tuning_offsets"] == [-2, -2, -2, -2, -2, -2]
|
||
assert meta["bass_tuning_offsets"] is None
|
||
|
||
|
||
def test_sloppak_extract_bass_wins_over_guitar_first_ordering(tmp_path):
|
||
"""The bass entry is listed FIRST in the manifest; the song tuning must
|
||
still be the guitar's while the bass column takes the bass entry — the two
|
||
selections are independent, not 'first wins'."""
|
||
d = _pack(tmp_path, "order.sloppak", [
|
||
{"name": "Bass", "tuning": [-4, -4, -4, -4, -4, -4]},
|
||
{"name": "Lead", "tuning": [0, 0, 0, 0, 0, 0]},
|
||
])
|
||
meta = sloppak_mod.extract_meta(d)
|
||
assert meta["tuning_offsets"] == [0, 0, 0, 0, 0, 0]
|
||
assert meta["bass_tuning_offsets"] == [-4, -4, -4, -4, -4, -4]
|
||
|
||
|
||
def test_sloppak_extract_ignores_bass_arrangement_without_a_tuning(tmp_path):
|
||
"""A bass chart that authors no tuning gives us nothing to index; the
|
||
column stays empty rather than defaulting to a wrong all-zeros."""
|
||
d = _pack(tmp_path, "untuned.sloppak", [
|
||
{"name": "Lead", "tuning": [-2, -2, -2, -2, -2, -2]},
|
||
{"name": "Bass"},
|
||
])
|
||
assert sloppak_mod.extract_meta(d)["bass_tuning_offsets"] is None
|
||
|
||
|
||
def test_sloppak_extract_falls_back_to_an_alt_bass_chart(tmp_path):
|
||
"""Only a "Bass 2" chart exists. Using it beats reporting the guitar
|
||
tuning as the player's bass tuning."""
|
||
d = _pack(tmp_path, "altbass.sloppak", [
|
||
{"name": "Lead", "tuning": [0, 0, 0, 0, 0, 0]},
|
||
{"name": "Bass 2", "tuning": [-2, 0, 0, 0, 0, 0]},
|
||
])
|
||
assert sloppak_mod.extract_meta(d)["bass_tuning_offsets"] == [-2, 0, 0, 0, 0, 0]
|
||
|
||
|
||
# ── 2. Scanner derivation (name / sort key / offsets string) ─────────────────
|
||
|
||
def test_scan_worker_derives_bass_columns_like_the_guitar_ones(tmp_path):
|
||
"""Guitar columns keep all six strings; bass columns are TRUNCATED to the
|
||
bass's four (the stored tail is padding — see tunings.normalize_bass_offsets)."""
|
||
d = _pack(tmp_path, "derive.sloppak", [
|
||
{"name": "Lead", "tuning": [-2, -2, -2, -2, -2, -2]},
|
||
{"name": "Bass", "tuning": [0, 0, 0, 0, 0, 0]},
|
||
])
|
||
meta = _extract_meta_for_file(d)
|
||
assert meta["tuning_name"] == "D Standard"
|
||
assert meta["tuning_sort_key"] == -12
|
||
assert meta["tuning_offsets"] == "-2 -2 -2 -2 -2 -2"
|
||
assert meta["bass_tuning_name"] == "E Standard"
|
||
assert meta["bass_tuning_sort_key"] == 0
|
||
assert meta["bass_tuning_offsets"] == "0 0 0 0"
|
||
# Canonical key = absolute open pitches of a 4-string bass in standard.
|
||
assert meta["bass_tuning_key"] == "bass:28:33:38:43"
|
||
|
||
|
||
def test_bass_padding_is_truncated_before_naming_and_grouping(tmp_path):
|
||
"""The padded tail must never reach the namer or the group key: a bass
|
||
stored six-wide and the same tuning stored four-wide must produce
|
||
IDENTICAL indexed columns."""
|
||
six = _extract_meta_for_file(_pack(tmp_path, "six.sloppak", [
|
||
{"name": "Bass", "tuning": [-2, 0, 0, 0, 0, 0]}]))
|
||
four = _extract_meta_for_file(_pack(tmp_path, "four.sloppak", [
|
||
{"name": "Bass", "tuning": [-2, 0, 0, 0]}]))
|
||
for col in ("bass_tuning_name", "bass_tuning_offsets",
|
||
"bass_tuning_sort_key", "bass_tuning_key"):
|
||
assert six[col] == four[col], col
|
||
assert six["bass_tuning_name"] == "Drop D"
|
||
|
||
|
||
def test_scan_worker_bass_columns_empty_without_a_bass_arrangement(tmp_path):
|
||
"""Empty string, never None: '' is the indexed 'we looked, no bass chart'
|
||
state, while NULL means 'never extracted' and triggers a re-scan."""
|
||
d = _pack(tmp_path, "nobass2.sloppak", [{"name": "Lead", "tuning": [0] * 6}])
|
||
meta = _extract_meta_for_file(d)
|
||
assert meta["bass_tuning_name"] == ""
|
||
assert meta["bass_tuning_sort_key"] == 0
|
||
assert meta["bass_tuning_offsets"] == ""
|
||
|
||
|
||
def test_implausible_bass_tuning_is_never_named(tmp_path):
|
||
"""Real library data, and it is BAD DATA: AC/DC "Girls Got Rhythm" stores
|
||
a bass tuning of [5,5,5,5,4,4] — every string up a perfect fourth, which
|
||
no bassist plays (roughly double string tension), on a song whose guitar
|
||
chart is dead standard.
|
||
|
||
Truncation alone would leave [5,5,5,5] = "all strings up a 4th", which the
|
||
namer WOULD happily name. Naming it would send a player off to retune to a
|
||
tuning that does not exist, so the plausibility guard must refuse: bassists
|
||
tune down, essentially never up."""
|
||
d = _pack(tmp_path, "weird.sloppak", [
|
||
{"name": "Lead", "tuning": [0, 0, 0, 0, 0, 0]},
|
||
{"name": "Bass", "tuning": [5, 5, 5, 5, 4, 4]},
|
||
])
|
||
meta = _extract_meta_for_file(d)
|
||
assert meta["bass_tuning_name"] == "Custom Tuning"
|
||
assert meta["bass_tuning_offsets"] == "5 5 5 5"
|
||
assert meta["bass_tuning_sort_key"] == 20
|
||
|
||
|
||
@pytest.mark.parametrize("offsets", [
|
||
[5, 5, 5, 5], [5, 5, 5, 5, 4, 4], [2, 2, 2, 2], [12, 12, 12, 12],
|
||
])
|
||
def test_up_tuned_bass_offsets_are_refused_by_the_guard(offsets):
|
||
"""Anything above +1 semitone is data we do not trust. Note the namer
|
||
ALONE would name several of these ([2,2,2,2] -> "F# Standard"), which is
|
||
exactly the retune-to-nowhere the guard exists to prevent."""
|
||
norm = normalize_bass_offsets(offsets)
|
||
assert bass_offsets_are_plausible(norm) is False
|
||
assert bass_tuning_name(norm) == "Custom Tuning"
|
||
|
||
|
||
@pytest.mark.parametrize("offsets,expected", [
|
||
([0, 0, 0, 0], "E Standard"), # standard
|
||
([-1, -1, -1, -1], "Eb Standard"), # down a semitone
|
||
([-2, 0, 0, 0], "Drop D"), # drop
|
||
([1, 1, 1, 1], "F Standard"), # +1 is the plausible ceiling, still named
|
||
])
|
||
def test_plausible_bass_tunings_are_still_named(offsets, expected):
|
||
"""The guard must not over-fire: real down-tunings, standard, and the +1
|
||
ceiling all keep their names."""
|
||
assert bass_tuning_name(offsets) == expected
|
||
|
||
|
||
# ── 3. Storage round-trip + the pre-migration re-extract marker ──────────────
|
||
|
||
def test_put_get_round_trips_the_bass_columns(server_mod):
|
||
_put(server_mod, filename="rt.sloppak", title="RT",
|
||
tuning_name_="D Standard", tuning_sort_key=-12,
|
||
tuning_offsets="-2 -2 -2 -2 -2 -2",
|
||
bass_tuning_name="E Standard", bass_tuning_offsets="0 0 0 0 0 0")
|
||
got = server_mod.meta_db.get("rt.sloppak", 1.0, 1)
|
||
assert got["tuning_name"] == "D Standard"
|
||
assert got["bass_tuning_name"] == "E Standard"
|
||
assert got["bass_tuning_offsets"] == "0 0 0 0 0 0"
|
||
|
||
|
||
def test_put_never_writes_null_bass_columns(server_mod):
|
||
"""A freshly-scanned row is by definition extracted, so even a song with
|
||
no bass chart stores '' — otherwise it would look pre-migration forever
|
||
and the scanner would re-extract it on every single pass."""
|
||
_put(server_mod, filename="fresh.sloppak", title="Fresh")
|
||
row = server_mod.meta_db.conn.execute(
|
||
"SELECT bass_tuning_name FROM songs WHERE filename = 'fresh.sloppak'").fetchone()
|
||
assert row[0] == ""
|
||
assert server_mod.meta_db.get("fresh.sloppak", 1.0, 1)["bass_tuning_name"] == ""
|
||
|
||
|
||
def test_pre_migration_row_reads_back_as_null(server_mod):
|
||
"""A row written before the columns existed (simulated with raw SQL that
|
||
omits them) reads back None — the marker the scanner keys its re-extract
|
||
on. If this ever became '' the backfill would silently never run."""
|
||
server_mod.meta_db.conn.execute(
|
||
"INSERT INTO songs (filename, mtime, size, title, artist, album, year, "
|
||
"duration, tuning, arrangements, has_lyrics, format, stem_count, "
|
||
"stem_ids, tuning_name, tuning_sort_key, tuning_offsets) "
|
||
"VALUES ('old.sloppak', 1.0, 1, 'Old', 'A', 'A - LP', '2010', 200.0, "
|
||
"'E Standard', '[]', 0, 'sloppak', 0, '[]', 'E Standard', 0, '0 0 0 0 0 0')")
|
||
server_mod.meta_db.conn.commit()
|
||
got = server_mod.meta_db.get("old.sloppak", 1.0, 1)
|
||
assert got["bass_tuning_name"] is None
|
||
# Same for the canonical key: coalescing this to '' would make the
|
||
# scanner's re-extract check unfireable and strand the backfill.
|
||
assert got["bass_tuning_key"] is None
|
||
|
||
|
||
def test_a_row_missing_only_the_canonical_key_still_re_extracts(server_mod):
|
||
"""A row scanned by an EARLIER build of this feature has bass_tuning_name
|
||
but no bass_tuning_key. It must still be re-queued, or its custom tunings
|
||
would group on the old serialization-dependent key forever."""
|
||
_put(server_mod, filename="halfway.sloppak", title="Halfway",
|
||
bass_tuning_name="Drop D", bass_tuning_offsets="-2 0 0 0")
|
||
server_mod.meta_db.conn.execute(
|
||
"UPDATE songs SET bass_tuning_key = NULL WHERE filename = 'halfway.sloppak'")
|
||
server_mod.meta_db.conn.commit()
|
||
cached = server_mod.meta_db.get("halfway.sloppak", 1.0, 1)
|
||
assert cached["bass_tuning_name"] == "Drop D"
|
||
assert cached["bass_tuning_key"] is None # → the scanner re-queues it
|
||
|
||
|
||
# ── 4. The migration actually backfills (the highest-risk gap) ───────────────
|
||
|
||
@pytest.fixture()
|
||
def scan_server(tmp_path, monkeypatch, isolate_logging, reset_scan_state):
|
||
"""Server with the background scan forced in-process (see
|
||
test_feedpak_extension.py::scan_server — the production spawn pool can't
|
||
reach an in-process mock)."""
|
||
import concurrent.futures
|
||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
||
monkeypatch.delenv("DLC_DIR", raising=False)
|
||
sys.modules.pop("server", None)
|
||
mod = importlib.import_module("server")
|
||
import scan as scan_mod
|
||
monkeypatch.setattr(
|
||
scan_mod, "_make_scan_executor",
|
||
lambda: concurrent.futures.ThreadPoolExecutor(max_workers=4),
|
||
)
|
||
yield mod
|
||
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
||
if conn is not None:
|
||
getattr(sys.modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||
conn.close()
|
||
|
||
|
||
def test_existing_library_backfills_bass_tuning_on_next_scan(tmp_path, scan_server):
|
||
"""END TO END for every CURRENT user: a settled library whose rows predate
|
||
the bass columns must re-extract on the next scan.
|
||
|
||
Both guards are exercised together — the row-level "bass column is NULL →
|
||
re-queue" AND the tree-signature fast path, which on an unchanged library
|
||
would otherwise skip the listing pass entirely and strand the backfill.
|
||
Then a second scan must NOT re-extract (the backfill converges, it doesn't
|
||
re-scan the whole library every launch).
|
||
"""
|
||
import unittest.mock as mock
|
||
|
||
dlc = tmp_path / "dlc"
|
||
dlc.mkdir()
|
||
(dlc / "song.feedpak").write_bytes(b"")
|
||
# json.dumps, not %s: a Windows path interpolated raw produces invalid JSON
|
||
# escapes (\U, \d), the config silently fails to parse, and the scan then
|
||
# reports "no DLC folder configured" and extracts nothing.
|
||
(tmp_path / "config.json").write_text(
|
||
json.dumps({"dlc_dir": str(dlc)}), encoding="utf-8")
|
||
|
||
scan = importlib.import_module("scan")
|
||
seen: list[str] = []
|
||
|
||
def mock_extract(f, dlc_dir):
|
||
seen.append(f.name)
|
||
return {"title": f.name, "artist": "A", "album": "",
|
||
"bass_tuning_name": "Drop D", "bass_tuning_sort_key": -2,
|
||
"bass_tuning_offsets": "-2 0 0 0 0 0"}
|
||
|
||
with mock.patch("scan_worker._extract_meta_for_file", new=mock_extract):
|
||
scan.background_scan()
|
||
assert "song.feedpak" in seen
|
||
|
||
# Simulate the pre-migration state: the row exists and is otherwise
|
||
# fresh (mtime/size match), but its bass columns were never extracted.
|
||
scan.appstate.meta_db.conn.execute(
|
||
"UPDATE songs SET bass_tuning_name = NULL, bass_tuning_sort_key = NULL, "
|
||
"bass_tuning_offsets = NULL")
|
||
scan.appstate.meta_db.conn.commit()
|
||
|
||
seen.clear()
|
||
scan.background_scan()
|
||
assert "song.feedpak" in seen, (
|
||
"a row with NULL bass columns must re-extract — otherwise no "
|
||
"existing library ever gets the bass tuning")
|
||
|
||
row = scan.appstate.meta_db.conn.execute(
|
||
"SELECT bass_tuning_name, bass_tuning_offsets FROM songs "
|
||
"WHERE filename = 'song.feedpak'").fetchone()
|
||
assert row == ("Drop D", "-2 0 0 0 0 0")
|
||
|
||
# Converged: the fast path is back and nothing re-extracts.
|
||
seen.clear()
|
||
scan.background_scan()
|
||
assert seen == []
|
||
|
||
|
||
# ── 5. The facet endpoint ────────────────────────────────────────────────────
|
||
|
||
@pytest.fixture()
|
||
def facet_seeded(server_mod):
|
||
"""Three shapes, matching the real library's distribution:
|
||
differ — guitar D Standard, bass E Standard (the bug)
|
||
match — both Drop D (common)
|
||
nobass — guitar Drop D, no bass chart (fallback, common)
|
||
"""
|
||
_put(server_mod, filename="differ.sloppak", title="Differ",
|
||
tuning_name_="D Standard", tuning_sort_key=-12,
|
||
tuning_offsets="-2 -2 -2 -2 -2 -2",
|
||
bass_tuning_name="E Standard", bass_tuning_sort_key=0,
|
||
bass_tuning_offsets="0 0 0 0 0 0")
|
||
_put(server_mod, filename="match.sloppak", title="Match",
|
||
tuning_name_="Drop D", tuning_sort_key=-2, tuning_offsets="-2 0 0 0 0 0",
|
||
bass_tuning_name="Drop D", bass_tuning_sort_key=-2,
|
||
bass_tuning_offsets="-2 0 0 0 0 0")
|
||
_put(server_mod, filename="nobass.sloppak", title="NoBass",
|
||
tuning_name_="Drop D", tuning_sort_key=-2, tuning_offsets="-2 0 0 0 0 0")
|
||
|
||
|
||
def _facet(client, **kw):
|
||
return {t["name"]: t["count"]
|
||
for t in client.get("/api/library/tuning-names", params=kw).json()["tunings"]}
|
||
|
||
|
||
def test_facet_defaults_to_the_guitar_tuning(client, facet_seeded):
|
||
assert _facet(client) == {"D Standard": 1, "Drop D": 2}
|
||
|
||
|
||
def test_facet_bass_groups_by_bass_tuning_with_guitar_fallback(client, facet_seeded):
|
||
"""differ counts under its BASS tuning (E Standard), match under Drop D,
|
||
and nobass — having no bass chart — falls back to its guitar Drop D rather
|
||
than vanishing from the facet."""
|
||
assert _facet(client, instrument="bass") == {"E Standard": 1, "Drop D": 2}
|
||
|
||
|
||
def test_facet_ignores_an_unknown_instrument(client, facet_seeded):
|
||
"""An unknown value must not silently change filter semantics."""
|
||
assert _facet(client, instrument="theremin") == _facet(client)
|
||
|
||
|
||
# ── 6. The filter: the actual reported bug ───────────────────────────────────
|
||
|
||
def _files(client, **kw):
|
||
return {s["filename"] for s in client.get("/api/library", params=kw).json()["songs"]}
|
||
|
||
|
||
def test_bass_filter_excludes_a_song_whose_only_match_is_its_guitar_tuning(
|
||
client, facet_seeded):
|
||
"""THE BUG. Filtering bass "D Standard" must NOT return `differ` — its
|
||
D Standard is the GUITAR chart; its bass is in E Standard."""
|
||
assert _files(client, tunings="D Standard") == {"differ.sloppak"}
|
||
assert _files(client, tunings="D Standard", instrument="bass") == set()
|
||
|
||
|
||
def test_bass_filter_returns_songs_by_their_bass_tuning(client, facet_seeded):
|
||
"""…and the converse: bass "E Standard" finds `differ`, which the guitar
|
||
filter would never return."""
|
||
assert _files(client, tunings="E Standard") == set()
|
||
assert _files(client, tunings="E Standard", instrument="bass") == {"differ.sloppak"}
|
||
|
||
|
||
def test_bass_filter_keeps_songs_without_a_bass_arrangement_via_fallback(
|
||
client, facet_seeded):
|
||
"""The most common shape. `nobass` has no bass chart, so it must still be
|
||
reachable under its guitar tuning instead of disappearing for bass users —
|
||
and the facet's count for that pill must equal what the filter returns."""
|
||
got = _files(client, tunings="Drop D", instrument="bass")
|
||
assert got == {"match.sloppak", "nobass.sloppak"}
|
||
assert _facet(client, instrument="bass")["Drop D"] == len(got)
|
||
|
||
|
||
def test_custom_bass_tunings_stay_distinct_under_their_offsets(client, server_mod):
|
||
"""Two unnameable bass tunings both label "Custom Tuning"; the facet keys
|
||
them on raw offsets so selecting one doesn't drag in the other. Uses the
|
||
real [5,5,5,5,4,4] shape from the library."""
|
||
_put(server_mod, filename="c1.sloppak", title="C1",
|
||
bass_tuning_name="Custom Tuning", bass_tuning_sort_key=28,
|
||
bass_tuning_offsets="5 5 5 5 4 4")
|
||
_put(server_mod, filename="c2.sloppak", title="C2",
|
||
bass_tuning_name="Custom Tuning", bass_tuning_sort_key=-7,
|
||
bass_tuning_offsets="-3 -1 -1 -1 -1 0")
|
||
keys = [t["key"] for t in client.get(
|
||
"/api/library/tuning-names", params={"instrument": "bass"}).json()["tunings"]
|
||
if t["name"] == "Custom Tuning"]
|
||
assert sorted(keys) == sorted(["5 5 5 5 4 4", "-3 -1 -1 -1 -1 0"])
|
||
assert _files(client, tunings="5 5 5 5 4 4", instrument="bass") == {"c1.sloppak"}
|
||
|
||
|
||
def test_stats_facet_counts_agree_with_the_bass_filter(client, facet_seeded):
|
||
"""The A–Z rail / count surface must apply the same instrument-aware
|
||
predicate as the grid, or the header count contradicts the results."""
|
||
body = client.get("/api/library/stats", params={
|
||
"tunings": "Drop D", "instrument": "bass"}).json()
|
||
assert body["total_songs"] == 2
|
||
|
||
|
||
# ── 7. Sort ──────────────────────────────────────────────────────────────────
|
||
|
||
def test_tuning_sort_respects_the_instrument(client, facet_seeded):
|
||
"""Tuning sort is musical distance from E Standard. For a bass player that
|
||
distance must be measured on the BASS tuning: `differ` is the furthest
|
||
song by guitar (D Standard, |−12|) but the nearest by bass (E Standard, 0),
|
||
so it moves from last to first."""
|
||
def order(**kw):
|
||
return [s["filename"] for s in client.get(
|
||
"/api/library", params={"sort": "tuning", **kw}).json()["songs"]]
|
||
|
||
guitar = order()
|
||
assert guitar[-1] == "differ.sloppak"
|
||
bass = order(instrument="bass")
|
||
assert bass[0] == "differ.sloppak"
|
||
|
||
|
||
# ── 8. Song payload ──────────────────────────────────────────────────────────
|
||
|
||
# ── 9. Real-library offset SHAPES ────────────────────────────────────────────
|
||
# Measured across the 59-pack test library: bass offset lists are NOT reliably
|
||
# 4 or reliably 6 — 41 store six elements, 1 stores four. Two six-element ones
|
||
# diverge in the tail (AC/DC "Girls Got Rhythm" [5,5,5,5,4,4]; Intervals
|
||
# "Libra" [-2,0,0,0,0,0]). Nothing may crash or mislabel on any of them.
|
||
|
||
@pytest.mark.parametrize("offsets,expected", [
|
||
([0, 0, 0, 0], "E Standard"), # four-element (the 1 outlier)
|
||
([0, 0, 0, 0, 0, 0], "E Standard"), # six-element all-equal (39 of them)
|
||
([-1, -1, -1, -1], "Eb Standard"), # four-element, down a semitone
|
||
([5, 5, 5, 5, 4, 4], "Custom Tuning"), # AC/DC — divergent tail
|
||
([-2, 0, 0, 0, 0, 0], "Drop D"), # Intervals — drop + trailing zeros
|
||
([0, 0, 0, 0, 0], "Custom Tuning"), # five: no naming convention → custom
|
||
])
|
||
def test_real_library_bass_offset_shapes_name_without_crashing(offsets, expected):
|
||
assert tuning_name(offsets) == expected
|
||
|
||
|
||
@pytest.mark.parametrize("offsets", [
|
||
[0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [5, 5, 5, 5, 4, 4], [-2, 0, 0, 0, 0, 0],
|
||
])
|
||
def test_real_library_bass_offset_shapes_survive_extraction(tmp_path, offsets):
|
||
"""Each shape must round-trip the real extractor + scanner derivation,
|
||
landing on the NORMALIZED (truncated, plausibility-checked) columns."""
|
||
norm = normalize_bass_offsets(offsets)
|
||
d = _pack(tmp_path, "shape.sloppak", [
|
||
{"name": "Lead", "tuning": [0, 0, 0, 0, 0, 0]},
|
||
{"name": "Bass", "tuning": offsets},
|
||
])
|
||
meta = _extract_meta_for_file(d)
|
||
assert meta["bass_tuning_name"] == bass_tuning_name(norm)
|
||
assert meta["bass_tuning_offsets"] == " ".join(str(o) for o in norm)
|
||
assert meta["bass_tuning_sort_key"] == sum(norm)
|
||
assert meta["bass_tuning_key"] == bass_tuning_key(norm)
|
||
|
||
|
||
def test_named_bass_tunings_group_across_serialization_lengths(client, server_mod):
|
||
"""The length question does NOT fragment NAMED tunings: a bass stored as
|
||
four elements and one stored as six both name "E Standard", and the facet
|
||
groups by name — so they land in ONE row with a combined count. This is the
|
||
common case (40 of the 42 bass arrangements in the real library)."""
|
||
_put(server_mod, filename="four.sloppak", title="Four",
|
||
bass_tuning_name=tuning_name([0, 0, 0, 0]), bass_tuning_offsets="0 0 0 0")
|
||
_put(server_mod, filename="six.sloppak", title="Six",
|
||
bass_tuning_name=tuning_name([0, 0, 0, 0, 0, 0]),
|
||
bass_tuning_offsets="0 0 0 0 0 0")
|
||
assert _facet(client, instrument="bass") == {"E Standard": 2}
|
||
assert _files(client, tunings="E Standard", instrument="bass") == {
|
||
"four.sloppak", "six.sloppak"}
|
||
|
||
|
||
def test_drop_d_bass_groups_across_serialization_lengths(client, server_mod):
|
||
"""Same for the Intervals shape: [-2,0,0,0,0,0] and [-2,0,0,0] both name
|
||
"Drop D", so trailing zeros can't split a named tuning into two rows."""
|
||
_put(server_mod, filename="d6.sloppak", title="D6",
|
||
bass_tuning_name=tuning_name([-2, 0, 0, 0, 0, 0]),
|
||
bass_tuning_sort_key=-2, bass_tuning_offsets="-2 0 0 0 0 0")
|
||
_put(server_mod, filename="d4.sloppak", title="D4",
|
||
bass_tuning_name=tuning_name([-2, 0, 0, 0]),
|
||
bass_tuning_sort_key=-2, bass_tuning_offsets="-2 0 0 0")
|
||
assert _facet(client, instrument="bass") == {"Drop D": 2}
|
||
|
||
|
||
def test_equivalent_custom_bass_tunings_group_into_one_facet_row(client, server_mod):
|
||
"""Two CUSTOM bass tunings that are the same physical tuning must be ONE
|
||
facet row, however they were serialized. They group on canonical PITCHES
|
||
(bass_tuning_key), so the offsets string no longer fragments them —
|
||
previously this produced two rows with split counts."""
|
||
key = bass_tuning_key([-3, -1, -1, -1])
|
||
_put(server_mod, filename="c6.sloppak", title="C6",
|
||
bass_tuning_name="Custom Tuning", bass_tuning_sort_key=-6,
|
||
bass_tuning_offsets="-3 -1 -1 -1", bass_tuning_key=key)
|
||
_put(server_mod, filename="c4.sloppak", title="C4",
|
||
bass_tuning_name="Custom Tuning", bass_tuning_sort_key=-6,
|
||
bass_tuning_offsets="-3 -1 -1 -1", bass_tuning_key=key)
|
||
rows = client.get("/api/library/tuning-names",
|
||
params={"instrument": "bass"}).json()["tunings"]
|
||
customs = [t for t in rows if t["name"] == "Custom Tuning"]
|
||
assert len(customs) == 1 and customs[0]["count"] == 2
|
||
assert _files(client, tunings=customs[0]["key"], instrument="bass") == {
|
||
"c6.sloppak", "c4.sloppak"}
|
||
|
||
|
||
def test_canonical_key_is_pitch_not_serialization(tmp_path):
|
||
"""The property that makes the grouping robust: two serializations of one
|
||
tuning yield the same key, and two genuinely different tunings do not."""
|
||
assert bass_tuning_key(normalize_bass_offsets([-2, 0, 0, 0, 0, 0])) == \
|
||
bass_tuning_key(normalize_bass_offsets([-2, 0, 0, 0]))
|
||
assert bass_tuning_key([-2, 0, 0, 0]) != bass_tuning_key([-3, 0, 0, 0])
|
||
# Absolute open pitches of a standard 4-string bass (E1 A1 D2 G2).
|
||
assert bass_tuning_key([0, 0, 0, 0]) == "bass:28:33:38:43"
|
||
|
||
|
||
def test_custom_bass_facet_row_selects_exactly_what_it_counted(client, server_mod):
|
||
"""Whatever the grouping rule, the invariant that must NEVER break: every
|
||
facet row's count equals the number of songs its own key returns. This is
|
||
what makes the seam safe to change — a normalization that merged rows but
|
||
not the filter would fail here."""
|
||
_put(server_mod, filename="x6.sloppak", title="X6",
|
||
bass_tuning_name="Custom Tuning", bass_tuning_sort_key=28,
|
||
bass_tuning_offsets="5 5 5 5 4 4")
|
||
_put(server_mod, filename="x4.sloppak", title="X4",
|
||
bass_tuning_name="Custom Tuning", bass_tuning_sort_key=20,
|
||
bass_tuning_offsets="5 5 5 5")
|
||
_put(server_mod, filename="plain.sloppak", title="Plain",
|
||
bass_tuning_name="E Standard", bass_tuning_offsets="0 0 0 0 0 0")
|
||
for row in client.get("/api/library/tuning-names",
|
||
params={"instrument": "bass"}).json()["tunings"]:
|
||
got = _files(client, tunings=row["key"], instrument="bass")
|
||
assert len(got) == row["count"], (
|
||
f"facet row {row['key']!r} counted {row['count']} but selects {len(got)}")
|
||
|
||
|
||
# ── 10. THE HEADLINE REGRESSION ──────────────────────────────────────────────
|
||
|
||
def test_covet_shibuya_is_findable_by_a_bassist(tmp_path, server_mod, client):
|
||
"""Covet - "Shibuya" (Effloresce): the guitar is in a custom tuning
|
||
[-2,0,0,-1,-2,0] while the bass is dead standard. This is the tester's bug
|
||
in one song — a bassist filtering "E Standard" never saw it, because the
|
||
library only knew the guitar's custom tuning.
|
||
|
||
Round-tripped through the REAL extractor and scanner derivation, not
|
||
hand-written columns, so it covers the whole chain."""
|
||
d = _pack(tmp_path, "shibuya.sloppak", [
|
||
{"name": "Lead", "tuning": [-2, 0, 0, -1, -2, 0]},
|
||
{"name": "Bass", "tuning": [0, 0, 0, 0, 0, 0]},
|
||
])
|
||
meta = _extract_meta_for_file(d)
|
||
server_mod.meta_db.put("shibuya.sloppak", 1.0, 1, {
|
||
**meta, "title": "Shibuya", "artist": "Covet", "album": "Effloresce"})
|
||
|
||
# The guitar chart really is a custom tuning…
|
||
assert meta["tuning_name"] == "Custom Tuning"
|
||
# …and the bass chart really is standard.
|
||
assert meta["bass_tuning_name"] == "E Standard"
|
||
|
||
# Before the fix a bassist filtering E Standard got nothing.
|
||
assert _files(client, tunings="E Standard") == set()
|
||
assert _files(client, tunings="E Standard", instrument="bass") == {"shibuya.sloppak"}
|
||
|
||
# And it appears in the bass facet under E Standard, as a REAL bass chart
|
||
# (not an inferred fallback).
|
||
row = next(t for t in client.get(
|
||
"/api/library/tuning-names", params={"instrument": "bass"}).json()["tunings"]
|
||
if t["name"] == "E Standard")
|
||
assert row["count"] == 1 and row["inferred_count"] == 0
|
||
|
||
|
||
# ── 11. Provenance: the fallback must be honest, never silent ────────────────
|
||
|
||
def test_facet_reports_how_many_rows_are_inferred_from_the_guitar_chart(
|
||
client, facet_seeded):
|
||
"""The fallback keeps no-bass-chart songs visible (a third of a real
|
||
library), but the UI must be able to say so. `nobass` has no bass chart and
|
||
rides under the guitar's Drop D; `match` has a real one."""
|
||
rows = {t["name"]: t for t in client.get(
|
||
"/api/library/tuning-names", params={"instrument": "bass"}).json()["tunings"]}
|
||
assert rows["Drop D"]["count"] == 2
|
||
assert rows["Drop D"]["inferred_count"] == 1 # nobass only
|
||
assert rows["E Standard"]["inferred_count"] == 0 # differ has a real bass chart
|
||
|
||
|
||
def test_guitar_facet_reports_no_inferred_rows(client, facet_seeded):
|
||
"""Guitar is never a fallback perspective, so nothing is ever inferred."""
|
||
rows = client.get("/api/library/tuning-names").json()["tunings"]
|
||
assert all(t["inferred_count"] == 0 for t in rows)
|
||
|
||
|
||
def test_song_rows_mark_an_inferred_tuning(client, facet_seeded):
|
||
"""A bass player's row must be distinguishable: native bass chart vs
|
||
borrowed from the guitar. Without this the card silently presents a guitar
|
||
tuning as the bass tuning — the original bug in a new place."""
|
||
rows = {s["filename"]: s for s in client.get(
|
||
"/api/library", params={"instrument": "bass"}).json()["songs"]}
|
||
assert rows["differ.sloppak"]["tuning_inferred"] is False
|
||
assert rows["nobass.sloppak"]["tuning_inferred"] is True
|
||
assert rows["differ.sloppak"]["tuning_perspective"] == "bass"
|
||
|
||
|
||
def test_guitar_rows_carry_no_bass_perspective_fields(client, facet_seeded):
|
||
"""The guitar payload is untouched — no perspective/inferred keys at all."""
|
||
row = client.get("/api/library").json()["songs"][0]
|
||
assert "tuning_inferred" not in row and "tuning_perspective" not in row
|
||
|
||
|
||
def test_arrangements_has_bass_is_the_real_bass_chart_lever(server_mod, client):
|
||
"""'Only songs with a real bass chart' is the EXISTING `arrangements_has`
|
||
filter — no new filter, no "confirmed tunings" checkbox. It composes with
|
||
the tuning filter, so a bassist who wants to exclude inferred rows already
|
||
can, and it is already expressible in a saved collection rule."""
|
||
def put_with_arrs(fn, arrs, **kw):
|
||
server_mod.meta_db.put(fn, 1.0, 1, {
|
||
"title": fn, "artist": "A", "album": "A - LP", "year": "2010",
|
||
"duration": 200.0, "tuning": "Drop D", "arrangements": arrs,
|
||
"has_lyrics": False, "format": "sloppak", "stem_ids": [],
|
||
"tuning_name": "Drop D", "tuning_sort_key": -2,
|
||
"tuning_offsets": "-2 0 0 0 0 0", **kw})
|
||
|
||
put_with_arrs("withbass.sloppak",
|
||
[{"index": 0, "name": "Lead"}, {"index": 1, "name": "Bass"}],
|
||
bass_tuning_name="Drop D", bass_tuning_sort_key=-2,
|
||
bass_tuning_offsets="-2 0 0 0",
|
||
bass_tuning_key=bass_tuning_key([-2, 0, 0, 0]))
|
||
put_with_arrs("nobass.sloppak", [{"index": 0, "name": "Lead"}])
|
||
|
||
# Both are reachable under the bass Drop D pill (the fallback keeps the
|
||
# no-bass-chart song visible)…
|
||
assert _files(client, tunings="Drop D", instrument="bass") == {
|
||
"withbass.sloppak", "nobass.sloppak"}
|
||
# …and the existing arrangements_has lever narrows to real bass charts.
|
||
assert _files(client, tunings="Drop D", instrument="bass",
|
||
arrangements_has="Bass") == {"withbass.sloppak"}
|
||
|
||
|
||
def test_song_rows_carry_the_bass_tuning_for_the_client(client, facet_seeded):
|
||
"""The card renders the bass tuning client-side, so the row must ship it —
|
||
and ship '' (not the guitar value) when there is no bass chart, so the
|
||
client's fallback stays the client's decision."""
|
||
rows = {s["filename"]: s for s in client.get("/api/library").json()["songs"]}
|
||
assert rows["differ.sloppak"]["tuning_name"] == "D Standard"
|
||
assert rows["differ.sloppak"]["bass_tuning_name"] == "E Standard"
|
||
assert rows["differ.sloppak"]["bass_tuning_offsets"] == "0 0 0 0 0 0"
|
||
assert rows["nobass.sloppak"]["bass_tuning_name"] == ""
|