v0.12.0: the merge report stops reporting things you didn't do
"unplayed", "missed and never skipped" and "top 100 in past year" showed up in the merge window constantly, and nobody had touched their rules. A live smart playlist's membership is derived, 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 against the same file after every song, so Syncthing conflicted on a list the next load throws away and rebuilds anyway. Membership now stays in memory: save_playlist writes track_ids: [] for a live smart playlist, and the recompute passes touch=False so it marks nothing dirty and moves no timestamp. live_update=False and unsupported criteria are unchanged — their track_ids are a snapshot, which is real content. Since _mark_playlist also dirties the metadata, library_metadata.json stops being rewritten every song too. The rest was presentation. Every summary read at the same weight, so someone resizing a column on the other machine popped and raised the same window as a 21-track reconciliation, described as "Library columns/settings taken from the most recently edited copy." Summaries now carry a level — WARNING for a merge that couldn't resolve cleanly or discarded a side, CHANGE for real content, INFO for cosmetic or derived — and the dialog has a Show: selector that filters to one level and above. It opens at the highest level in the batch, so nothing routine steals focus and a blank window can't happen; a later warning always pulls the view back up to it. The wording says what happened instead of how the merge works: a metadata merge names the keys that actually differed, and adopting the other machine's music folder grades CHANGE rather than INFO, because that one is a setting somebody chose. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y9ZEFi4qNJ39FMiBtiAxy2
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user