soulsync/tests/test_canonical_orchestration.py
BoulderBadgeDad 57e039e34d Canonical: make source selection a job setting (default active-preferred)
Feedback from the live dry-run: the job was pinning whichever source best fit
the files regardless of which source it was, which was surprising — users
expect it to respect their active metadata source. Made it a per-job setting
instead of a baked-in policy.

source_selection (default 'active_preferred'):
- active_preferred — use the active/primary metadata source's release when the
  album has an ID for it AND it clears the score floor; otherwise fall back to
  the best-fit among the other sources. Respects the configured source but
  self-heals when that link is clearly broken (below floor / no ID).
- active_only — only ever the active source; never considers others.
- best_fit — previous behavior: whichever source matches the files best.

resolve_canonical_for_album gains mode + primary_source; the orchestration
threads the primary source through; the job reads source_selection from its
settings. Note: active_preferred respects the active source as long as it clears
the floor, so it will NOT override a deluxe-vs-standard mismatch on the primary
(#767-Bug2) — that's what best_fit is for; the choice is now the user's.

Tests: per-mode coverage in test_canonical_resolver.py (active_preferred uses
primary when it fits, falls back when primary is below floor, keeps primary even
when another fits better; active_only pins primary / never falls back; best_fit
unchanged), orchestration default-mode test, and the setting default. 39
canonical tests pass.
2026-06-02 12:58:59 -07:00

112 lines
4.5 KiB
Python

"""End-to-end orchestration for canonical resolve+store (#765 Stage 2 trigger).
Uses a real temp DB (album + tracks + source IDs) and an INJECTED fetcher, so
the DB gathering + persistence are exercised for real without live APIs.
"""
from __future__ import annotations
from core.metadata.canonical_resolver import (
default_fetch_tracklist,
resolve_and_store_canonical_for_album,
)
from database.music_database import MusicDatabase
STD = [{"duration_ms": 180_000 + i * 10_000, "title": f"Song {i+1}", "track_number": i + 1} for i in range(11)]
DLX = STD + [{"duration_ms": 320_000 + i * 10_000, "title": f"Bonus {i+1}", "track_number": 12 + i} for i in range(6)]
def _seed(db, *, spotify=None, deezer=None, n_files=11):
"""Insert an album (with given source IDs) + n_files tracks whose
durations/titles match the STANDARD release."""
conn = db._get_connection()
cur = conn.cursor()
cur.execute("INSERT INTO artists (id, name) VALUES ('art1', 'Imagine Dragons')")
cur.execute(
"INSERT INTO albums (id, title, artist_id, spotify_album_id, deezer_id) "
"VALUES ('alb1', 'Evolve', 'art1', ?, ?)",
(spotify, deezer),
)
for i in range(n_files):
cur.execute(
"INSERT INTO tracks (id, album_id, artist_id, title, track_number, duration) "
"VALUES (?, 'alb1', 'art1', ?, ?, ?)",
(f"t{i}", f"Song {i+1}", i + 1, 180_000 + i * 10_000),
)
conn.commit()
conn.close()
return "alb1"
def test_resolve_and_store_picks_best_fit_and_persists(tmp_path):
db = MusicDatabase(str(tmp_path / "m.db"))
album_id = _seed(db, spotify="sp_deluxe", deezer="dz_std") # 11 files
table = {("spotify", "sp_deluxe"): DLX, ("deezer", "dz_std"): STD}
out = resolve_and_store_canonical_for_album(
db, album_id,
fetch_tracklist=lambda s, a: table.get((s, a)),
source_priority=["spotify", "deezer"],
mode="best_fit",
)
# best_fit: Deezer's standard matches the 11 files better than Spotify's deluxe.
assert out["source"] == "deezer" and out["album_id"] == "dz_std"
# ...and it was persisted.
stored = db.get_album_canonical(album_id)
assert stored["source"] == "deezer" and stored["album_id"] == "dz_std"
def test_default_mode_prefers_active_source(tmp_path):
# Same setup, but default (active_preferred) mode: primary = spotify, whose
# deluxe still clears the floor -> pinned, even though deezer fits better.
db = MusicDatabase(str(tmp_path / "m.db"))
album_id = _seed(db, spotify="sp_deluxe", deezer="dz_std")
table = {("spotify", "sp_deluxe"): DLX, ("deezer", "dz_std"): STD}
out = resolve_and_store_canonical_for_album(
db, album_id,
fetch_tracklist=lambda s, a: table.get((s, a)),
source_priority=["spotify", "deezer"], # default mode
)
assert out["source"] == "spotify" # active source preferred
def test_resolve_returns_none_when_album_has_no_source_ids(tmp_path):
db = MusicDatabase(str(tmp_path / "m.db"))
album_id = _seed(db, spotify=None, deezer=None)
out = resolve_and_store_canonical_for_album(
db, album_id, fetch_tracklist=lambda s, a: STD, source_priority=["spotify"],
)
assert out is None
assert db.get_album_canonical(album_id) is None
def test_resolve_returns_none_for_missing_album(tmp_path):
db = MusicDatabase(str(tmp_path / "m.db"))
out = resolve_and_store_canonical_for_album(
db, "does-not-exist", fetch_tracklist=lambda s, a: STD, source_priority=["spotify"],
)
assert out is None
# ── default_fetch_tracklist normalization (no DB / no live API) ────────────
def test_default_fetcher_normalizes_dict_items(monkeypatch):
import core.metadata_service as ms
monkeypatch.setattr(
ms, "get_album_tracks_for_source",
lambda s, a: [{"name": "A", "track_number": 1, "duration_ms": 200000},
{"title": "B", "track_number": 2, "duration": 210}], # seconds
raising=False,
)
out = default_fetch_tracklist("spotify", "x")
assert out[0] == {"title": "A", "track_number": 1, "duration_ms": 200000}
assert out[1] == {"title": "B", "track_number": 2, "duration_ms": 210_000} # sec->ms
def test_default_fetcher_handles_failure(monkeypatch):
import core.metadata_service as ms
monkeypatch.setattr(
ms, "get_album_tracks_for_source",
lambda s, a: (_ for _ in ()).throw(RuntimeError("boom")), raising=False,
)
assert default_fetch_tracklist("spotify", "x") is None