soulsync/tests/test_canonical_manual_lock.py
BoulderBadgeDad 2d2ee34df8 #758: a manual album match pins + locks the canonical version
Users manually match an album to the regular edition, but enrichment/
repair keeps treating it as the deluxe (missing songs, renumbered tracks).
Root cause: an album has TWO identities — the enrichment match
(spotify_album_id, which manual-match sets and the worker already honors)
and a SEPARATE canonical version pin (canonical_album_id, added by #777).
The canonical pin is what track-number repair / reorganize / missing-track
detection actually read, and library_manual_match never wrote it — so it
was resolved independently and landed on the deluxe edition.

(So #777 did NOT solve #758: it added canonical pinning, but manual
matches didn't write the pin.)

Fix: a manual ALBUM match on a canonical-recognised source now also pins
AND locks the canonical version to the chosen release:
- new canonical_locked column (same migration pattern as the other
  canonical cols).
- set_album_canonical(..., locked=False) gains an atomic WHERE-clause
  guard: an auto write can't overwrite a locked pin; a manual write
  (locked=True) always wins. get_album_canonical exposes `locked`.
- library_manual_match pins canonical for album matches via the pure
  should_pin_manual_canonical(entity_type, source).

The auto resolve job already skips already-pinned albums, so the lock is
protected on two fronts; the new guard also covers any future
re-resolution. A new manual match still overrides.

18 tests: the pure gate (+ a sync-invariant test vs _ALBUM_ID_COLUMNS)
and the DB lock seam (auto can't clobber a manual lock; manual overrides;
auto-over-auto still works). Additive — locked defaults False, so the
auto path is unchanged unless a manual lock exists. Full suite clean.
2026-06-05 23:28:19 -07:00

99 lines
3.8 KiB
Python

"""#758 — a manual album match pins (and LOCKS) the canonical album version, so
re-resolution / the auto canonical job can't drag it back to the deluxe edition.
Two seams:
- should_pin_manual_canonical (pure): when a manual match should pin canonical.
- set_album_canonical / get_album_canonical (DB): the lock can't be overwritten
by an auto write, but a new manual write still wins.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from core.metadata.canonical_version import (
CANONICAL_ALBUM_SOURCES,
should_pin_manual_canonical,
)
from database.music_database import MusicDatabase
# ---------------------------------------------------------------------------
# should_pin_manual_canonical — pure
# ---------------------------------------------------------------------------
@pytest.mark.parametrize('source', ['spotify', 'itunes', 'deezer', 'discogs', 'hydrabase'])
def test_pins_album_on_recognised_source(source):
assert should_pin_manual_canonical('album', source) is True
@pytest.mark.parametrize('entity', ['artist', 'track'])
def test_does_not_pin_non_album(entity):
assert should_pin_manual_canonical(entity, 'spotify') is False
@pytest.mark.parametrize('source', ['lastfm', 'genius', 'musicbrainz', 'audiodb', 'tidal'])
def test_does_not_pin_source_canonical_cant_read(source):
# No album-version data the canonical tools read → nothing to pin.
assert should_pin_manual_canonical('album', source) is False
def test_sources_stay_in_sync_with_album_id_columns():
# The set must mirror the canonical reader's column map; if a source is
# added there, this fails until CANONICAL_ALBUM_SOURCES is updated.
from core.library_reorganize import _ALBUM_ID_COLUMNS
assert CANONICAL_ALBUM_SOURCES == set(_ALBUM_ID_COLUMNS)
# ---------------------------------------------------------------------------
# set_album_canonical / get_album_canonical — the lock (DB)
# ---------------------------------------------------------------------------
def _insert_album(db, album_id):
conn = db._get_connection()
conn.execute("INSERT OR IGNORE INTO artists (id, name) VALUES ('ar1', 'Artist')")
conn.execute("INSERT INTO albums (id, artist_id, title) VALUES (?, 'ar1', 'Album')", (album_id,))
conn.commit()
conn.close()
@pytest.fixture
def db(tmp_path: Path) -> MusicDatabase:
d = MusicDatabase(database_path=str(tmp_path / "ml.db"))
_insert_album(d, 'al1')
return d
def test_manual_lock_set_and_read(db):
assert db.set_album_canonical('al1', 'spotify', 'REG', 1.0, locked=True) is True
c = db.get_album_canonical('al1')
assert c['source'] == 'spotify' and c['album_id'] == 'REG' and c['locked'] is True
def test_auto_cannot_overwrite_manual_lock(db):
db.set_album_canonical('al1', 'spotify', 'REG', 1.0, locked=True)
# The auto resolve job tries to re-pin the deluxe — must be refused.
assert db.set_album_canonical('al1', 'spotify', 'DELUXE', 0.9, locked=False) is False
c = db.get_album_canonical('al1')
assert c['album_id'] == 'REG' and c['locked'] is True # unchanged
def test_new_manual_match_overrides_existing_pin(db):
db.set_album_canonical('al1', 'spotify', 'OLD', 0.8, locked=False) # auto pin
# User manually picks a different edition — manual always wins.
assert db.set_album_canonical('al1', 'itunes', 'NEW', 1.0, locked=True) is True
c = db.get_album_canonical('al1')
assert c['source'] == 'itunes' and c['album_id'] == 'NEW' and c['locked'] is True
def test_auto_overwrites_auto(db):
db.set_album_canonical('al1', 'spotify', 'A', 0.8, locked=False)
assert db.set_album_canonical('al1', 'spotify', 'B', 0.9, locked=False) is True
assert db.get_album_canonical('al1')['album_id'] == 'B'
def test_unresolved_album_returns_none(db):
_insert_album(db, 'al2')
assert db.get_album_canonical('al2') is None