soulsync/tests/test_playlist_reconcile.py
BoulderBadgeDad ecd2500c39 Server playlist editor: surface 'accurate but out of order' + read-only server-order view
The editor renders the server column in SOURCE order (reconcile_playlist pairs
each server track to its source row), so a reordered-but-same-membership playlist
read as '5 matched / in sync' when Navidrome's real order actually differed — the
reorder never reaching the server was invisible.

- compute_order_status() (pure, tested): matched tracks' server positions must be
  strictly ascending in source order; uses RELATIVE order so missing/extra tracks
  never false-flag. reconcile entries now carry server_index (additive).
- endpoint returns order_status + server_order (the server's actual sequence).
- editor shows an amber 'out of order' badge on the server column when membership
  matches but sequence differs, opening a read-only modal of the real server order.
  One-way: source order stays the source of truth; no server-side editing.

Tests reproduce the reported 'Real Love Baby moved to #2' case + guard against
false-flagging on missing/extra. The actual 'sync order' WRITE is a separate
follow-up (membership/extra semantics + live identity-preservation test pending).
2026-06-24 12:15:47 -07:00

226 lines
11 KiB
Python

"""Extreme battery for the playlist sync-editor reconcile (#768).
Covers: the reported YouTube failure (Bug A — "Artist - Title" source matching
its clean server copy instead of showing unmatched + orphan extra), the
source_track_id echo (Bug B), and parity with the original three-pass behavior
(override → exact → fuzzy → extra), plus duplicate-server-track handling.
"""
from __future__ import annotations
from core.sync.playlist_reconcile import compute_order_status, norm_title, reconcile_playlist
def _src(name, artist, sid="", **kw):
return {"name": name, "artist": artist, "source_track_id": sid, **kw}
def _svr(title, artist, tid):
return {"title": title, "artist": artist, "id": tid, "ratingKey": tid}
def _status(combined):
return [(c["match_status"],
(c["source_track"] or {}).get("name"),
(c["server_track"] or {}).get("title")) for c in combined]
# ── Bug A: the reported YouTube case ──────────────────────────────────────
def test_youtube_artist_title_source_matches_clean_server_track():
source = [_src("Arctic Monkeys - Do I Wanna Know?", "Official Arctic Monkeys", "sp1")]
server = [_svr("Do I Wanna Know?", "Arctic Monkeys", "nv72")]
combined = reconcile_playlist(source, server)
assert len(combined) == 1
assert combined[0]["match_status"] == "matched"
assert combined[0]["server_track"]["id"] == "nv72"
# ...and the server track is NOT left as an orphan extra.
assert not any(c["match_status"] == "extra" for c in combined)
def test_youtube_match_does_not_leave_unmatched_or_extra():
# Before the fix this produced one 'missing' + one 'extra'.
source = [_src("The Killers - Mr. Brightside", "The KillersVEVO", "sp2")]
server = [_svr("Mr. Brightside", "The Killers", "nv5")]
statuses = [c["match_status"] for c in reconcile_playlist(source, server)]
assert statuses == ["matched"]
# ── Bug B: source_track_id is echoed back ─────────────────────────────────
def test_source_track_id_present_on_matched_entry():
source = [_src("Do I Wanna Know?", "Arctic Monkeys", "spotify:track:abc")]
server = [_svr("Do I Wanna Know?", "Arctic Monkeys", "nv1")]
combined = reconcile_playlist(source, server)
assert combined[0]["source_track"]["source_track_id"] == "spotify:track:abc"
def test_source_track_id_present_on_missing_entry():
# A genuinely-missing source must still carry its id so it can be
# manually matched and persisted (the #768 manual-match loop).
source = [_src("Some Obscure B-Side", "Some Artist", "spotify:track:xyz")]
server = [_svr("Completely Different", "Other Artist", "nv9")]
combined = reconcile_playlist(source, server)
missing = [c for c in combined if c["match_status"] == "missing"]
assert missing and missing[0]["source_track"]["source_track_id"] == "spotify:track:xyz"
# ── parity: override / exact / fuzzy / extra ──────────────────────────────
def test_override_pair_wins_first():
source = [_src("Anything", "Whoever", "s1")]
server = [_svr("Totally Different Title", "Nobody", "nvX")]
combined = reconcile_playlist(source, server, override_pairs={0: 0})
assert combined[0]["match_status"] == "matched"
assert combined[0]["confidence"] == 1.0
assert combined[0].get("override") is True
def test_exact_normalized_match_strips_feat():
source = [_src("Stay (feat. Justin Bieber)", "The Kid LAROI", "s1")]
server = [_svr("Stay", "The Kid LAROI", "nv1")]
assert reconcile_playlist(source, server)[0]["match_status"] == "matched"
def test_fuzzy_match_above_threshold():
source = [_src("Mr Brightside", "The Killers", "s1")]
server = [_svr("Mr. Brightside", "The Killers", "nv1")]
c = reconcile_playlist(source, server)[0]
assert c["match_status"] == "matched"
assert c["confidence"] >= 0.75
def test_truly_absent_track_is_missing_and_unrelated_server_is_extra():
source = [_src("Nonexistent Song", "Ghost Artist", "s1")]
server = [_svr("Yellow", "Coldplay", "nv1")]
statuses = sorted(c["match_status"] for c in reconcile_playlist(source, server))
assert statuses == ["extra", "missing"]
def test_each_server_track_claimed_once_no_double_match():
# Two identical source rows must not both claim the single server track.
source = [_src("Yellow", "Coldplay", "s1"), _src("Yellow", "Coldplay", "s2")]
server = [_svr("Yellow", "Coldplay", "nv1")]
combined = reconcile_playlist(source, server)
matched = [c for c in combined if c["match_status"] == "matched"]
missing = [c for c in combined if c["match_status"] == "missing"]
assert len(matched) == 1 and len(missing) == 1
def test_duplicate_server_tracks_one_matched_one_extra():
# The #768 duplicate scenario: two copies of the same track on the server.
source = [_src("Do I Wanna Know?", "Arctic Monkeys", "s1")]
server = [_svr("Do I Wanna Know?", "Arctic Monkeys", "nv72"),
_svr("Do I Wanna Know?", "Arctic Monkeys", "nv73")]
combined = reconcile_playlist(source, server)
assert sorted(c["match_status"] for c in combined) == ["extra", "matched"]
# ── #766: source borrows the matched server cover ─────────────────────────
def _svr_art(title, artist, tid, thumb):
return {"title": title, "artist": artist, "id": tid, "ratingKey": tid, "thumb": thumb}
def test_artless_source_borrows_matched_server_cover():
# YouTube-style source row, no art of its own, matched to a server track
# that has a cover -> source side borrows it.
source = [_src("Arctic Monkeys - Do I Wanna Know?", "Official Arctic Monkeys", "s1")]
server = [_svr_art("Do I Wanna Know?", "Arctic Monkeys", "nv1", "/api/navidrome/cover/al42")]
combined = reconcile_playlist(source, server)
assert combined[0]["match_status"] == "matched"
assert combined[0]["source_track"]["image_url"] == "/api/navidrome/cover/al42"
def test_source_keeps_its_own_art_when_present():
# A Spotify-style source row with its own CDN art must NOT be overwritten.
source = [_src("Do I Wanna Know?", "Arctic Monkeys", "s1", image_url="https://cdn/spotify.jpg")]
server = [_svr_art("Do I Wanna Know?", "Arctic Monkeys", "nv1", "/api/navidrome/cover/al42")]
combined = reconcile_playlist(source, server)
assert combined[0]["source_track"]["image_url"] == "https://cdn/spotify.jpg"
def test_unmatched_source_has_no_cover_to_borrow():
source = [_src("Totally Absent Song", "Ghost", "s1")]
server = [_svr_art("Something Else", "Nobody", "nv1", "/api/navidrome/cover/al99")]
combined = reconcile_playlist(source, server)
missing = [c for c in combined if c["match_status"] == "missing"]
assert missing and not missing[0]["source_track"]["image_url"]
def test_borrow_skipped_when_server_track_has_no_thumb():
source = [_src("Do I Wanna Know?", "Arctic Monkeys", "s1")]
server = [_svr("Do I Wanna Know?", "Arctic Monkeys", "nv1")] # no thumb
combined = reconcile_playlist(source, server)
assert combined[0]["match_status"] == "matched"
assert not combined[0]["source_track"]["image_url"]
def test_norm_title_helper_parity():
assert norm_title("Stay (feat. X)") == "stay"
assert norm_title("Song (2019 Remaster)") == "song"
assert norm_title("Album (Deluxe Edition)") == "album"
# ── order status: server playlist accurate-but-out-of-order detection ─────────
# The editor renders the server column in SOURCE order, so a reordered-but-same-
# membership playlist used to read "in sync" when the real Navidrome order differed.
# compute_order_status surfaces that drift (one-way: source order is truth).
def test_reconcile_attaches_server_index_to_matched():
source = [_src("Yellow", "Coldplay", "s1")]
server = [_svr("Filler", "X", "nv0"), _svr("Yellow", "Coldplay", "nv1")]
combined = reconcile_playlist(source, server)
matched = [c for c in combined if c["match_status"] == "matched"][0]
assert matched["server_index"] == 1 # Yellow is at server position 1
def test_in_order_when_server_matches_source_sequence():
titles = ["Mandinka", "Real Love Baby", "Liquid Indian", "Heaven or Las Vegas", "hospital beach"]
source = [_src(t, "A", f"s{i}") for i, t in enumerate(titles)]
server = [_svr(t, "A", f"nv{i}") for i, t in enumerate(titles)] # same order
status = compute_order_status(reconcile_playlist(source, server))
assert status == {"matched": 5, "in_order": True, "out_of_order": False}
def test_out_of_order_reproduces_real_love_baby_case():
# Source (Spotify): Real Love Baby at position 2. Server (Navidrome): still last.
src_titles = ["Mandinka", "Real Love Baby", "Liquid Indian", "Heaven or Las Vegas", "hospital beach"]
svr_titles = ["Mandinka", "Liquid Indian", "Heaven or Las Vegas", "hospital beach", "Real Love Baby"]
source = [_src(t, "A", f"s{i}") for i, t in enumerate(src_titles)]
server = [_svr(t, "A", f"nv{i}") for i, t in enumerate(svr_titles)]
status = compute_order_status(reconcile_playlist(source, server))
assert status["matched"] == 5
assert status["out_of_order"] is True # the bug: looked synced, wasn't
def test_missing_tracks_do_not_false_flag_out_of_order():
# 2 tracks missing on the server, but the present ones are in the right relative
# order -> NOT out of order (membership is a separate axis).
source = [_src(t, "A", f"s{i}") for i, t in enumerate(["one", "two", "three", "four"])]
server = [_svr("one", "A", "nv0"), _svr("three", "A", "nv1")] # two/four missing, order ok
status = compute_order_status(reconcile_playlist(source, server))
assert status["matched"] == 2
assert status["out_of_order"] is False
def test_missing_and_shuffled_still_flags_out_of_order():
source = [_src(t, "A", f"s{i}") for i, t in enumerate(["one", "two", "three", "four"])]
server = [_svr("three", "A", "nv0"), _svr("one", "A", "nv1")] # present pair is reversed
status = compute_order_status(reconcile_playlist(source, server))
assert status["matched"] == 2
assert status["out_of_order"] is True
def test_extras_ignored_for_order():
# An extra server track (not in source) must not affect the order verdict.
source = [_src("a", "A", "s0"), _src("b", "A", "s1")]
server = [_svr("a", "A", "nv0"), _svr("zzz extra", "A", "nv1"), _svr("b", "A", "nv2")]
status = compute_order_status(reconcile_playlist(source, server))
assert status["matched"] == 2 and status["out_of_order"] is False
def test_fewer_than_two_matches_never_out_of_order():
assert compute_order_status([])["out_of_order"] is False
one = reconcile_playlist([_src("a", "A", "s0")], [_svr("a", "A", "nv0")])
assert compute_order_status(one)["out_of_order"] is False