Files
lintunes/tests/test_round42.py
T
travandClaude Opus 5 c8de124543 v0.13.0: the merge report says who, what, and where
The merge window kept reporting "Order kept from this machine (most recently
edited)" for playlists edited on the other machine. Three defects, confirmed
against the real snapshots in .resolved/:

* "this machine" was inferred from which copy held the plain filename. That is
  Syncthing's call, not a statement about authorship — it sets the local copy
  aside as readily as a remote one. In the 9:34 PM `* a fresh master` merge the
  copy labelled "the other machine" was this machine's own 3:15 PM merge output,
  so the label was exactly backwards. The 7-char device ID in the conflict
  filename — the only real evidence — was matched by a bare \w+ and deleted with
  the file. New sync_identity.py decodes it against Syncthing's config.xml and
  works out which device is us from cert.pem.

* The decision leaned local. date_modified was only consulted when *both* copies
  had one, and an iTunes playlist never reordered here has none — so the honest
  comparison was skipped exactly when one machine had edited and the other
  hadn't. A stamped copy now beats an unstamped one; mtime is the fallback only
  when neither side has ever been edited. And every merge used to rewrite the
  file it kept whether or not anything changed, freshening its mtime while the
  conflict file kept its origin's: a ratchet. No-op merges write nothing, and a
  merge whose result is a union neither copy had stamps date_modified, so the
  other machine adopts it instead of trading the same 19 tracks back and forth.

* Nothing was actionable. Re-inserted tracks are now named with their position
  ("Pola — Abeille -> position 24, after ..."), six in the window and all of
  them in what-changed.txt at the top of the backup snapshot, alongside the real
  conflict filename and its device. Tracks only this copy has are reported too
  rather than resurrected in silence.

Also fixed while in here: a rename or folder move made elsewhere was discarded
by every merge (only track_ids and settings were adopted); _reconcile_playlist
asserted the local edit was newer and never checked, so a reorder synced in from
the other machine was undone and flushed back to disk, and the branch reaching
it was gated on a dirty flag that a column drag sets; and _merge_metadata
decided the music folder from whichever copy an mtime coin flip had kept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y9ZEFi4qNJ39FMiBtiAxy2
2026-08-27 15:16:49 -04:00

368 lines
16 KiB
Python

"""Round 42: the merge report stops shouting, and smart playlists stop
conflicting.
Two halves of one complaint — the merge window was full of entries for things
nobody did:
* A live smart playlist's membership is derived from its rules, but it was being
*persisted*, and `recompute_smart_playlist` rewrites it (and bumped
`date_modified`) every time a play count moves. Both machines did that
continuously against the same file, so `unplayed` handed Syncthing a conflict
once a song — for a list the next load throws away and recomputes anyway.
* Every summary was presented at the same weight, so a column resize on the
other machine popped and raised the same window as a 21-track reconciliation.
Summaries are now graded WARNING / CHANGE / INFO and the dialog shows one
level and above, opening at the highest level in the batch.
"""
import json
import pytest
from lintunes.library_manager import LibraryManager
from lintunes.models import Library, Playlist, PlaylistType, Track
from lintunes.smart import SmartCriteria, SmartGroup, SmartRule
from lintunes.storage import json_storage, conflict_resolver
from lintunes.storage.conflict_resolver import (
WARNING, CHANGE, INFO, ConflictSummary, at_least, max_level)
def _rock_criteria(**kwargs) -> SmartCriteria:
return SmartCriteria(
root=SmartGroup("all", [SmartRule("genre", "is", "Rock")]), **kwargs)
def _library(*genres) -> Library:
lib = Library()
for i, genre in enumerate(genres, start=1):
lib.tracks[i] = Track(track_id=i, name=f"t{i}", genre=genre)
return lib
def _conflict_name(original: str) -> str:
stem, _, ext = original.rpartition(".")
return f"{stem}.sync-conflict-20240101-120000-ABCDEFG.{ext}"
def _playlist_file(tmp_path, pid="AAAA1111"):
return tmp_path / "playlists" / f"{pid}.json"
# --------------------------------------------------------------------------- #
# Derived membership is never persisted
# --------------------------------------------------------------------------- #
class TestDerivedMembership:
def _saved(self, tmp_path, criteria, track_ids=(1, 2)):
pl = Playlist(name="unplayed", persistent_id="AAAA1111",
playlist_type=PlaylistType.SMART,
track_ids=list(track_ids), smart_criteria=criteria)
lib = Library()
lib.playlists[pl.persistent_id] = pl
json_storage.save_library(lib, tmp_path)
return json.loads(_playlist_file(tmp_path).read_text())
def test_live_smart_playlist_stores_no_track_ids(self, tmp_path):
data = self._saved(tmp_path, _rock_criteria())
assert data["track_ids"] == []
assert data["derived_membership"] is True
def test_non_live_smart_playlist_keeps_its_snapshot(self, tmp_path):
data = self._saved(tmp_path, _rock_criteria(live_update=False))
assert data["track_ids"] == [1, 2]
assert "derived_membership" not in data
def test_unsupported_criteria_keep_the_imported_snapshot(self, tmp_path):
data = self._saved(tmp_path, _rock_criteria(unsupported=True))
assert data["track_ids"] == [1, 2]
def test_regular_playlist_is_untouched(self, tmp_path):
pl = Playlist(name="mix", persistent_id="BBBB2222",
playlist_type=PlaylistType.REGULAR, track_ids=[3, 1])
lib = Library()
lib.playlists[pl.persistent_id] = pl
json_storage.save_library(lib, tmp_path)
data = json.loads(_playlist_file(tmp_path, "BBBB2222").read_text())
assert data["track_ids"] == [3, 1]
def test_membership_survives_a_save_load_roundtrip(self, qapp, tmp_path):
library = _library("Rock", "Jazz", "Rock")
json_storage.save_library(library, tmp_path) # library.json for the reload
mgr = LibraryManager(library, tmp_path)
pl = mgr.create_smart_playlist("rockers", _rock_criteria())
assert pl.track_ids == [1, 3]
mgr.flush()
# Nothing on disk, but a fresh start rebuilds it from the rules.
assert json.loads(
_playlist_file(tmp_path, pl.persistent_id).read_text())["track_ids"] == []
restarted = LibraryManager(json_storage.load_library(tmp_path), tmp_path)
assert restarted.library.playlists[pl.persistent_id].track_ids == []
restarted.recompute_all_smart()
assert restarted.library.playlists[pl.persistent_id].track_ids == [1, 3]
class TestRecomputeIsNotAnEdit:
"""A recompute must leave no trace: no dirty file, no timestamp. Bumping
date_modified there made Round 39's "who edited last" tiebreaker meaningless
for every smart playlist."""
def _manager(self, tmp_path, criteria):
mgr = LibraryManager(_library("Rock", "Jazz"), tmp_path)
pl = mgr.create_smart_playlist("rockers", criteria)
mgr.flush()
mgr._dirty_playlists.clear()
return mgr, pl
def test_derived_recompute_leaves_no_trace(self, qapp, tmp_path):
mgr, pl = self._manager(tmp_path, _rock_criteria())
stamp = pl.date_modified
mgr.library.tracks[2].genre = "Rock" # track 2 now matches
assert mgr.recompute_smart_playlist(pl.persistent_id) is True
assert pl.track_ids == [1, 2] # in memory, recomputed
assert pl.date_modified == stamp # not an edit
assert pl.persistent_id not in mgr._dirty_playlists
def test_derived_recompute_still_announces_itself(self, qapp, tmp_path):
"""No file write, but the views must still redraw."""
mgr, pl = self._manager(tmp_path, _rock_criteria())
seen = []
mgr.playlist_content_changed.connect(seen.append)
mgr.library.tracks[2].genre = "Rock"
mgr.recompute_smart_playlist(pl.persistent_id)
assert seen == [pl.persistent_id]
def test_a_snapshot_playlist_still_persists_its_recompute(self, qapp, tmp_path):
mgr, pl = self._manager(tmp_path, _rock_criteria(live_update=False))
stamp = pl.date_modified
mgr.library.tracks[2].genre = "Rock"
assert mgr.recompute_smart_playlist(pl.persistent_id, force=True) is True
assert pl.date_modified != stamp
assert pl.persistent_id in mgr._dirty_playlists
def test_a_real_edit_still_counts_as_one(self, qapp, tmp_path):
"""The touch=False path must not leak into user edits."""
mgr = LibraryManager(_library("Rock", "Jazz"), tmp_path)
pl = mgr.create_playlist("mix")
mgr.flush()
mgr._dirty_playlists.clear()
stamp = pl.date_modified
mgr.add_tracks_to_playlist(pl.persistent_id, [1])
assert pl.date_modified != stamp
assert pl.persistent_id in mgr._dirty_playlists
# --------------------------------------------------------------------------- #
# Severity
# --------------------------------------------------------------------------- #
class TestLevels:
def test_ordering(self):
assert at_least(WARNING, INFO) and at_least(INFO, INFO)
assert not at_least(INFO, CHANGE)
assert max_level([ConflictSummary("a", "other", level=INFO),
ConflictSummary("b", "other", level=WARNING)]) == WARNING
assert max_level([]) == INFO
def _write_conflict(self, tmp_path, name, payload):
path = tmp_path / name
path.parent.mkdir(parents=True, exist_ok=True)
cfile = path.parent / _conflict_name(path.name)
cfile.write_text(json.dumps(payload))
return cfile
def _smart_pair(self, tmp_path, their_criteria, their_stamp):
pl = Playlist(name="unplayed", persistent_id="AAAA1111",
playlist_type=PlaylistType.SMART,
smart_criteria=_rock_criteria(),
date_modified="2026-08-01T00:00:00")
lib = Library()
lib.playlists[pl.persistent_id] = pl
json_storage.save_library(lib, tmp_path)
theirs = json.loads(_playlist_file(tmp_path).read_text())
theirs["smart_criteria"] = their_criteria.to_dict()
theirs["date_modified"] = their_stamp
theirs["track_ids"] = [7, 8, 9] # their derived membership drifted
self._write_conflict(tmp_path, "playlists/AAAA1111.json", theirs)
return conflict_resolver.resolve_conflicts(tmp_path)
def test_identical_smart_rules_are_info(self, tmp_path):
summaries = self._smart_pair(tmp_path, _rock_criteria(),
"2026-08-09T00:00:00")
assert summaries[0].level == INFO
assert "identical" in summaries[0].lines[0]
def test_genuinely_edited_smart_rules_are_a_change(self, tmp_path):
other = SmartCriteria(
root=SmartGroup("all", [SmartRule("genre", "is", "Jazz")]))
summaries = self._smart_pair(tmp_path, other, "2026-08-09T00:00:00")
assert summaries[0].level == CHANGE
assert "the copy Syncthing set aside" in summaries[0].lines[0]
def test_metadata_is_always_info_and_says_what_differed(self, tmp_path):
lib = Library()
lib.music_folder = "/media/muzak"
json_storage.save_library(lib, tmp_path)
theirs = json.loads((tmp_path / "library_metadata.json").read_text())
theirs["library_settings"]["column_widths"] = {"name": 400}
self._write_conflict(tmp_path, "library_metadata.json", theirs)
summaries = conflict_resolver.resolve_conflicts(tmp_path)
assert summaries[0].level == INFO
assert "column layout" in summaries[0].lines[0]
def test_but_adopting_a_music_folder_is_a_change(self, tmp_path):
"""The one thing in this file nobody sets by resizing a window. Round 40
made the music folder win on its own stamp rather than the file's mtime;
when that fires, the merge changed a deliberate setting."""
lib = Library()
lib.music_folder = "/media/muzak"
lib.music_folder_set_at = "2026-08-01T00:00:00"
json_storage.save_library(lib, tmp_path)
theirs = json.loads((tmp_path / "library_metadata.json").read_text())
theirs["music_folder"] = "/media/elsewhere"
theirs["music_folder_rel"] = "../elsewhere"
theirs["music_folder_set_at"] = "2026-08-09T00:00:00"
self._write_conflict(tmp_path, "library_metadata.json", theirs)
summaries = conflict_resolver.resolve_conflicts(tmp_path)
assert summaries[0].level == CHANGE
assert "Music folder" in summaries[0].lines[-1]
assert json.loads((tmp_path / "library_metadata.json").read_text())[
"music_folder"] == "/media/elsewhere"
def test_a_cosmetic_only_playlist_merge_is_info(self, tmp_path):
pl = Playlist(name="mix", persistent_id="BBBB2222",
playlist_type=PlaylistType.REGULAR, track_ids=[1, 2, 3],
date_modified="2026-08-01T00:00:00")
lib = Library()
lib.playlists[pl.persistent_id] = pl
json_storage.save_library(lib, tmp_path)
theirs = json.loads(_playlist_file(tmp_path, "BBBB2222").read_text())
theirs["settings"]["column_widths"] = {"name": 500}
theirs["date_modified"] = "2026-08-09T00:00:00"
self._write_conflict(tmp_path, "playlists/BBBB2222.json", theirs)
summaries = conflict_resolver.resolve_conflicts(tmp_path)
assert summaries[0].level == INFO
assert "column layout" in summaries[0].lines[0]
def test_a_real_order_merge_is_a_change(self, tmp_path):
pl = Playlist(name="mix", persistent_id="BBBB2222",
playlist_type=PlaylistType.REGULAR, track_ids=[1, 2, 3],
date_modified="2026-08-01T00:00:00")
lib = Library()
lib.playlists[pl.persistent_id] = pl
json_storage.save_library(lib, tmp_path)
theirs = json.loads(_playlist_file(tmp_path, "BBBB2222").read_text())
theirs["track_ids"] = [1, 99, 2, 3]
theirs["date_modified"] = "2026-08-09T00:00:00"
self._write_conflict(tmp_path, "playlists/BBBB2222.json", theirs)
summaries = conflict_resolver.resolve_conflicts(tmp_path)
assert summaries[0].level == CHANGE
assert json.loads(
_playlist_file(tmp_path, "BBBB2222").read_text())["track_ids"] == [1, 99, 2, 3]
def test_a_deleted_file_edited_elsewhere_is_a_warning(self, tmp_path):
json_storage.save_library(Library(), tmp_path)
self._write_conflict(tmp_path, "playlists/CCCC3333.json",
{"name": "gone", "persistent_id": "CCCC3333"})
summaries = conflict_resolver.resolve_conflicts(tmp_path)
assert summaries[0].level == WARNING
def test_an_unmergeable_file_is_a_warning(self, tmp_path):
json_storage.save_library(Library(), tmp_path)
(tmp_path / "something.json").write_text("{}")
self._write_conflict(tmp_path, "something.json", {"a": 1})
summaries = conflict_resolver.resolve_conflicts(tmp_path)
assert summaries[0].level == WARNING
def test_a_play_journal_conflict_is_a_warning(self, tmp_path):
json_storage.save_library(Library(), tmp_path)
(tmp_path / "plays").mkdir(exist_ok=True)
(tmp_path / "plays" / "boxA.json").write_text(
json.dumps({"1": {"plays": 2}}))
self._write_conflict(tmp_path, "plays/boxA.json", {"1": {"plays": 5}})
summaries = conflict_resolver.resolve_conflicts(tmp_path)
assert summaries[0].level == WARNING
assert json.loads(
(tmp_path / "plays" / "boxA.json").read_text())["1"]["plays"] == 5
# --------------------------------------------------------------------------- #
# The dialog
# --------------------------------------------------------------------------- #
@pytest.fixture
def dialog_cls():
from lintunes.gui.conflict_dialog import ConflictSummaryDialog
return ConflictSummaryDialog
def _summary(name, level, line="something happened"):
return ConflictSummary(name, "playlist", [line], backup_dir="/tmp/b",
level=level)
class TestConflictDialog:
def test_an_all_info_batch_opens_on_the_info_view(self, qapp, dialog_cls):
dlg = dialog_cls([_summary("library_metadata.json", INFO)], None)
assert dlg.level == INFO
assert "library_metadata.json" in dlg._body.toPlainText()
assert "Nothing at this level" not in dlg._body.toPlainText()
def test_a_warning_batch_hides_the_routine_entries(self, qapp, dialog_cls):
dlg = dialog_cls([_summary("library_metadata.json", INFO),
_summary("plays/boxA.json", WARNING)], None)
body = dlg._body.toPlainText()
assert dlg.level == WARNING
assert "plays/boxA.json" in body
assert "library_metadata.json" not in body
assert "1 routine entry hidden" in body
def test_a_later_warning_pulls_the_view_back_up(self, qapp, dialog_cls):
dlg = dialog_cls([_summary("library_metadata.json", INFO)], None)
assert dlg.add_event([_summary("plays/boxA.json", WARNING)]) is True
assert dlg.level == WARNING
assert "library_metadata.json" not in dlg._body.toPlainText()
def test_routine_events_do_not_ask_to_be_raised(self, qapp, dialog_cls):
dlg = dialog_cls([_summary("mix", CHANGE)], None)
assert dlg.level == CHANGE
assert dlg.add_event([_summary("library_metadata.json", INFO)]) is False
# Still recorded — it's there once the user dials the filter down.
dlg._on_level_picked(dlg._level_box.findData(INFO))
assert "library_metadata.json" in dlg._body.toPlainText()
def test_a_user_choice_survives_a_quieter_batch(self, qapp, dialog_cls):
dlg = dialog_cls([_summary("mix", CHANGE)], None)
dlg._on_level_picked(dlg._level_box.findData(WARNING))
dlg.add_event([_summary("other mix", CHANGE)])
assert dlg.level == WARNING
assert "Nothing at this level" in dlg._body.toPlainText()
def test_filtered_out_events_leave_no_orphan_headers(self, qapp, dialog_cls):
dlg = dialog_cls([_summary("mix", CHANGE)], None)
dlg.add_event([_summary("library_metadata.json", INFO)])
headers = [ln for ln in dlg._body.toPlainText().splitlines()
if ln.startswith("── ")]
assert len(headers) == 1 # only the event that still has content