"""Tests for the multi-chart grouping engine (P5a): several charts (feedpak rows) of the same WORK collapse to one representative card via a materialized work_display read-model + a `group=1` `WHERE is_group_representative=1` predicate. Counting works (not charts), auto-pick, chart_group_pref, split, and keyset paging under grouping all stay correct.""" import importlib import sys import pytest from fastapi.testclient import TestClient @pytest.fixture() def server(tmp_path, monkeypatch, isolate_logging): monkeypatch.setenv("CONFIG_DIR", str(tmp_path)) monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1") sys.modules.pop("server", None) srv = importlib.import_module("server") try: yield srv finally: conn = getattr(getattr(srv, "meta_db", None), "conn", None) if conn is not None: getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)() conn.close() sys.modules.pop("server", None) @pytest.fixture() def client(server): return TestClient(server.app) def _put(server, fn, title, artist, arrangements=1, mtime=0.0): arr = [{"name": "Lead", "index": i} for i in range(arrangements)] server.meta_db.put(fn, mtime, 0, {"title": title, "artist": artist, "arrangements": arr}) def _grouped(client, **params): params["group"] = 1 return client.get("/api/library", params=params).json() # ── Baseline: ungrouped shows every chart ──────────────────────────────────── def test_ungrouped_shows_all_charts(client, server): _put(server, "a.archive", "Song", "Artist") _put(server, "b.archive", "Song", "Artist") body = client.get("/api/library").json() assert body["total"] == 2 assert {s["filename"] for s in body["songs"]} == {"a.archive", "b.archive"} # ungrouped rows carry no chart_count (only the grouped path attaches it) assert "chart_count" not in body["songs"][0] # ── Grouping collapses a work to one representative ─────────────────────────── def test_grouping_collapses_to_one_representative(client, server): _put(server, "a.archive", "Song", "Artist", arrangements=1) _put(server, "b.archive", "Song", "Artist", arrangements=2) body = _grouped(client) assert body["total"] == 1 assert len(body["songs"]) == 1 rep = body["songs"][0] assert rep["filename"] == "b.archive" # most arrangements wins auto-pick assert rep["chart_count"] == 2 def test_autopick_plays_tiebreak(client, server): _put(server, "a.archive", "S", "A", arrangements=1) _put(server, "b.archive", "S", "A", arrangements=1) server.meta_db.record_session("a.archive", 0, score=100, accuracy=0.5) # a has a play assert _grouped(client)["songs"][0]["filename"] == "a.archive" def test_autopick_filename_tiebreak(client, server): _put(server, "b.archive", "S", "A") _put(server, "a.archive", "S", "A") # equal arrangements + plays + mtime → lowest filename assert _grouped(client)["songs"][0]["filename"] == "a.archive" # ── chart_group_pref override ──────────────────────────────────────────────── def test_preferred_overrides_autopick(client, server): _put(server, "a.archive", "S", "A", arrangements=2) # would auto-win _put(server, "b.archive", "S", "A", arrangements=1) wk = server.meta_db.work_key_for("b.archive") server.meta_db.set_chart_preferred(wk, "b.archive") assert _grouped(client)["songs"][0]["filename"] == "b.archive" def test_set_preferred_incremental_reflip(client, server): _put(server, "a.archive", "S", "A", arrangements=2) _put(server, "b.archive", "S", "A", arrangements=1) assert _grouped(client)["songs"][0]["filename"] == "a.archive" # builds read-model wk = server.meta_db.work_key_for("a.archive") server.meta_db.set_chart_preferred(wk, "b.archive") # incremental re-flip body = _grouped(client) assert body["songs"][0]["filename"] == "b.archive" assert body["songs"][0]["chart_count"] == 2 # group_size unchanged def test_clear_preferred_returns_to_autopick(client, server): _put(server, "a.archive", "S", "A", arrangements=2) _put(server, "b.archive", "S", "A", arrangements=1) wk = server.meta_db.work_key_for("a.archive") server.meta_db.set_chart_preferred(wk, "b.archive") assert _grouped(client)["songs"][0]["filename"] == "b.archive" server.meta_db.clear_chart_preferred(wk) assert _grouped(client)["songs"][0]["filename"] == "a.archive" # auto again # ── Split / un-split ───────────────────────────────────────────────────────── def test_split_makes_singleton(client, server): _put(server, "a.archive", "S", "A") _put(server, "b.archive", "S", "A") assert _grouped(client)["total"] == 1 server.meta_db.split_chart("b.archive") body = _grouped(client) assert body["total"] == 2 # two works now assert {s["filename"] for s in body["songs"]} == {"a.archive", "b.archive"} assert all(s["chart_count"] == 1 for s in body["songs"]) def test_unsplit_rejoins(client, server): _put(server, "a.archive", "S", "A") _put(server, "b.archive", "S", "A") server.meta_db.split_chart("b.archive") assert _grouped(client)["total"] == 2 server.meta_db.unsplit_chart("b.archive") assert _grouped(client)["total"] == 1 # ── Stats + A–Z count works, not charts ────────────────────────────────────── def test_stats_counts_works_not_charts(client, server): _put(server, "a.archive", "Song", "Artist") _put(server, "b.archive", "Song", "Artist") # same work as a _put(server, "c.archive", "Other", "Artist") assert client.get("/api/library/stats").json()["total_songs"] == 3 assert client.get("/api/library/stats", params={"group": 1}).json()["total_songs"] == 2 def test_sort_letters_counts_works(client, server): _put(server, "a.archive", "Song", "Alpha") _put(server, "b.archive", "Song", "Alpha") # same work _put(server, "c.archive", "Track", "Beta") sl = client.get("/api/library/stats", params={"group": 1, "sort_letters": 1, "sort": "artist"}).json()["sort_letters"] assert sl.get("A") == 1 and sl.get("B") == 1 # ── Keyset paging under grouping (the critical integration) ─────────────────── def test_grouped_keyset_pagination(client, server): for i in range(5): _put(server, f"w{i}_a.archive", f"Song {i}", "Artist") _put(server, f"w{i}_b.archive", f"Song {i}", "Artist") # 2 charts per work # Title sort: artist sorts page by OFFSET now (title-secondary ordering), # and this test PROVES the grouped keyset, so it pins a keyset sort. body = client.get("/api/library", params={"group": 1, "size": 3, "sort": "title"}).json() assert body["total"] == 5 and len(body["songs"]) == 3 cur = body["next_cursor"] assert cur body2 = client.get("/api/library", params={"group": 1, "size": 3, "sort": "title", "after": cur}).json() assert len(body2["songs"]) == 2 p1 = {s["filename"] for s in body["songs"]} p2 = {s["filename"] for s in body2["songs"]} assert not (p1 & p2) # no skip / dupe across pages # ── Freshness: adding a chart regroups; overrides survive reindex ───────────── def test_new_chart_updates_group_size(client, server): _put(server, "a.archive", "S", "A") assert _grouped(client)["songs"][0]["chart_count"] == 1 _put(server, "b.archive", "S", "A") # dirties read-model body = _grouped(client) assert body["total"] == 1 and body["songs"][0]["chart_count"] == 2 def test_preferred_survives_reindex(client, server): _put(server, "a.archive", "S", "A", arrangements=2) _put(server, "b.archive", "S", "A", arrangements=1) wk = server.meta_db.work_key_for("b.archive") server.meta_db.set_chart_preferred(wk, "b.archive") assert _grouped(client)["songs"][0]["filename"] == "b.archive" # A rescan re-indexes both charts (INSERT OR REPLACE INTO songs). _put(server, "a.archive", "S", "A", arrangements=2) _put(server, "b.archive", "S", "A", arrangements=1) assert _grouped(client)["songs"][0]["filename"] == "b.archive" # pref survived # ── work_key normalization folds trivial differences ───────────────────────── def test_work_key_folds_the_and_punctuation(server): wk = server.meta_db._work_key assert wk("The Beatles", "Hey Jude") == wk("beatles", "hey jude!") assert wk("AC/DC", "T.N.T.") == wk("acdc", "tnt") assert wk("Metallica", "One") != wk("Metallica", "Two")