"""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 other machine" 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