Files
lintunes/tests/test_round43.py
T
travandClaude Opus 5 8c097faacc v0.14.0: a removal you make sticks
Round 43 made the merge report honest, and in doing so made its one real
limitation impossible to miss: every merge was a union, so a song removed from a
playlist on one machine was handed straight back by the other on the next sync,
and a track deleted from the library came back with it. A deletion you cannot
make stick is not a deletion.

The union was there for a real reason — two copies with no common ancestor
cannot tell "A added this" from "B removed it" — so the missing evidence is
written down instead of inferred. New lintunes/tombstones.py: a playlist keeps
track_events {track id: [when, add|remove]}, the library keeps deleted_tracks
{track id: when} in library_metadata.json, and a merge applies the newest event
per track across both copies. A removal beats a copy that merely still had the
song; a deliberate re-add afterwards beats the removal; a track nobody touched
still merges as a union, which stays the safe behavior where there is no
evidence either way.

Deliberately not "the newer copy wins wholesale": that one-liner silently drops
a song the other machine added while you were removing one, which
test_an_unrelated_addition_is_not_lost pins.

Events are recorded in the funnels that already exist — _set_track_ids diffs
before/after so a reorder records nothing, _remove_tracks stamps the library,
_restore_tracks clears it so Ctrl+Z takes the tombstone back — and pruned after
30 days at the save boundary.

Three edges worth naming:

* Track ids are never reused. The next id came from max(library.tracks), so
  deleting the highest-numbered track freed its id, and the next import would be
  dropped on sight by the dead id's own tombstone on every machine.
* library_metadata.json is merged before library.json, since it carries the
  record the library merge is filtered against and rglob order is not a plan.
* A merge applying a deletion never touches a music file — it drops the library
  entry only, and test_a_merge_never_touches_a_music_file fails the run if
  send_to_trash is so much as called. Applied removals grade WARNING and name
  the song.

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

385 lines
17 KiB
Python

"""Round 43: the merge report says who, what, and where.
The window kept reporting "Order kept from this machine (most recently edited)"
for playlists that had been edited on the *other* machine, and offered a backup
folder holding two directories with one hex-named JSON each.
Three defects behind that:
* "this machine" was inferred from which copy held the plain filename. Syncthing
decides that, and it sets the local copy aside as readily as a remote one — so
the label was a coin flip presented as a fact. The device ID in the conflict
filename, the one piece of real evidence, was matched by a bare ``\\w+`` and
deleted with the file.
* The decision itself leaned local: ``date_modified`` was only consulted when
*both* copies had one (an iTunes playlist never reordered here has none), and
every merge rewrote the file it kept, so its mtime — the fallback — got
fresher each time while the conflict file kept its origin's.
* Nothing was actionable: ``merge_track_order`` knows where every re-inserted
track landed and threw it away, no song was ever named, and a track the other
copy had *deleted* came back silently.
"""
import json
import os
import time
import pytest
from lintunes import sync_identity
from lintunes.models import Library, Playlist, PlaylistType
from lintunes.storage import json_storage, conflict_resolver
from lintunes.storage.conflict_resolver import CONFLICT_PATTERN, CHANGE, INFO
def _conflict_name(original: str, device: str = "ABCDEFG") -> str:
stem, _, ext = original.rpartition(".")
return f"{stem}.sync-conflict-20240101-120000-{device}.{ext}"
def _library(tmp_path, playlists=(), tracks=()):
lib = Library()
for p in playlists:
lib.playlists[p.persistent_id] = p
for track in tracks:
lib.tracks[track.track_id] = track
json_storage.save_library(lib, tmp_path)
return lib
def _playlist(ids, stamp=None, name="a nissa ideas"):
pl = Playlist(persistent_id="AAAA1111", name=name,
playlist_type=PlaylistType.REGULAR, track_ids=list(ids))
pl.date_modified = stamp
return pl
def _pair(tmp_path, ours, theirs, our_stamp=None, their_stamp=None,
device="ABCDEFG", their_name=None, tracks=()):
"""Write our playlist plus a conflict file holding theirs."""
_library(tmp_path, [_playlist(ours, our_stamp)], tracks)
pfile = tmp_path / "playlists" / "AAAA1111.json"
other = json.loads(pfile.read_text())
other["track_ids"] = list(theirs)
if their_stamp:
other["date_modified"] = their_stamp
elif "date_modified" in other:
del other["date_modified"]
if their_name:
other["name"] = their_name
cfile = tmp_path / "playlists" / _conflict_name("AAAA1111.json", device)
cfile.write_text(json.dumps(other))
return pfile, cfile
class TestWhichCopyWon:
"""The half-stamped case: one machine edited, the other never has."""
def test_the_stamped_copy_wins_over_an_unstamped_one(self, tmp_path):
"""A stamp only exists once LinTunes recorded an edit, so "stamped vs
never edited" is evidence — and it used to fall through to mtime, which
is exactly the `sci vibes` case where their copy had no stamp at all."""
pfile, cfile = _pair(tmp_path, ours=[1, 2, 3], theirs=[3, 2, 1],
our_stamp=None, their_stamp="2026-08-20T18:00:00")
os.utime(cfile, (time.time() - 10_000, time.time() - 10_000)) # older file
summaries = conflict_resolver.resolve_conflicts(tmp_path)
assert json.loads(pfile.read_text())["track_ids"] == [3, 2, 1]
assert "set aside" in summaries[0].lines[0]
def test_and_the_other_way_round(self, tmp_path):
pfile, cfile = _pair(tmp_path, ours=[3, 2, 1], theirs=[1, 2, 3],
our_stamp="2026-08-20T18:00:00", their_stamp=None)
os.utime(cfile, (time.time() + 10_000, time.time() + 10_000)) # newer file
summaries = conflict_resolver.resolve_conflicts(tmp_path)
assert json.loads(pfile.read_text())["track_ids"] == [3, 2, 1]
assert "already here" in summaries[0].lines[0]
def test_neither_stamped_still_falls_back_to_mtime(self, tmp_path):
pfile, cfile = _pair(tmp_path, ours=[1, 2, 3], theirs=[3, 2, 1])
os.utime(cfile, (time.time() + 10_000, time.time() + 10_000))
summaries = conflict_resolver.resolve_conflicts(tmp_path)
assert json.loads(pfile.read_text())["track_ids"] == [3, 2, 1]
assert any("file timestamp" in line for line in summaries[0].lines)
class TestTheMtimeRatchet:
"""Every merge used to rewrite the file it kept, so the copy in place got a
fresher mtime each round while the conflict file kept its origin's — the
fallback got more biased with every merge."""
def test_a_merge_that_changes_nothing_does_not_rewrite_the_file(self, tmp_path):
pfile, _ = _pair(tmp_path, ours=[1, 2, 3], theirs=[1, 2, 3])
before = pfile.stat().st_mtime_ns
time.sleep(0.01)
conflict_resolver.resolve_conflicts(tmp_path)
assert pfile.stat().st_mtime_ns == before
def test_a_union_neither_copy_had_is_stamped_as_new(self, tmp_path):
"""Content neither side had is newer than both. Saying so is what stops
the two machines trading the same tracks back and forth."""
pfile, _ = _pair(tmp_path, ours=[1, 2], theirs=[3],
our_stamp="2026-08-20T18:00:00",
their_stamp="2026-08-19T09:00:00")
conflict_resolver.resolve_conflicts(tmp_path)
merged = json.loads(pfile.read_text())
assert set(merged["track_ids"]) == {1, 2, 3}
assert merged["date_modified"] > "2026-08-20T18:00:00"
def test_a_losing_copys_stamp_never_walks_ours_backwards(self, tmp_path):
"""Their list can be a superset of ours while being the older edit. The
merged file is still content we did not have — taking the loser's stamp
would make this file claim to be older than it is and lose the next
comparison for no reason."""
pfile, _ = _pair(tmp_path, ours=[1], theirs=[1, 2],
our_stamp="2026-08-20T18:00:00",
their_stamp="2026-08-19T09:00:00")
conflict_resolver.resolve_conflicts(tmp_path)
merged = json.loads(pfile.read_text())
assert merged["track_ids"] == [1, 2]
assert merged["date_modified"] > "2026-08-20T18:00:00"
def test_but_taking_their_list_wholesale_keeps_their_stamp(self, tmp_path):
pfile, _ = _pair(tmp_path, ours=[1, 2], theirs=[1, 2, 3],
our_stamp="2026-08-19T09:00:00",
their_stamp="2026-08-20T18:00:00")
conflict_resolver.resolve_conflicts(tmp_path)
assert json.loads(pfile.read_text())["date_modified"] == "2026-08-20T18:00:00"
class TestWhoWroteIt:
def test_the_pattern_captures_the_device_token(self):
match = CONFLICT_PATTERN.match(
"AAAA1111.sync-conflict-20240101-120000-CMFNCIX.json")
assert match.group(1) == "AAAA1111"
assert match.group(2) == "20240101-120000"
assert match.group(3) == "CMFNCIX"
assert match.group(4) == ".json"
def test_the_original_is_still_found_with_the_extension_in_group_four(
self, tmp_path):
_pair(tmp_path, ours=[1], theirs=[2])
found = conflict_resolver._find_conflict_files(tmp_path)
assert len(found) == 1
assert found[0].original_path.name == "AAAA1111.json"
assert found[0].device == "ABCDEFG"
def test_a_known_device_is_named_in_the_report(self, tmp_path, monkeypatch):
monkeypatch.setattr(sync_identity, "_cache",
{"names": {"CMFNCIX": "trave14"}, "self": "EA4TRYN"})
_pair(tmp_path, ours=[1, 2], theirs=[2, 1], device="CMFNCIX",
our_stamp="2026-08-20T18:00:00", their_stamp="2026-08-19T09:00:00")
summaries = conflict_resolver.resolve_conflicts(tmp_path)
assert summaries[0].device_label == "trave14"
assert any("trave14" in line for line in summaries[0].lines)
def test_our_own_device_is_named_as_this_machine(self, monkeypatch):
monkeypatch.setattr(sync_identity, "_cache",
{"names": {"EA4TRYN": "console"}, "self": "EA4TRYN"})
assert sync_identity.label_for("EA4TRYN") == "console (this machine)"
def test_no_syncthing_config_means_no_claim_about_machines(
self, tmp_path, monkeypatch):
"""A machine without Syncthing installed, or with its config somewhere
we don't look, must still merge — just without naming anyone."""
monkeypatch.setenv("HOME", str(tmp_path / "elsewhere"))
monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "elsewhere" / "state"))
sync_identity.reset_cache()
try:
assert sync_identity.device_names() == {}
assert sync_identity.self_device_id() is None
assert sync_identity.label_for("CMFNCIX") is None
_pair(tmp_path, ours=[1, 2], theirs=[2, 1],
our_stamp="2026-08-20T18:00:00", their_stamp="2026-08-19T09:00:00")
summaries = conflict_resolver.resolve_conflicts(tmp_path)
assert summaries[0].device_label == ""
assert "written on" not in summaries[0].lines[0]
finally:
sync_identity.reset_cache()
class TestNamingTheSongs:
def _tracks(self):
from lintunes.models import Track
return [Track(track_id=1, name="Kid A", artist="Radiohead"),
Track(track_id=2, name="Abeille", artist="Pola"),
Track(track_id=3, name="morning.", artist="jinsang")]
def test_re_inserted_tracks_are_named_with_their_position(self, tmp_path):
_pair(tmp_path, ours=[1, 3], theirs=[1, 2, 3], tracks=self._tracks(),
our_stamp="2026-08-20T18:00:00", their_stamp="2026-08-19T09:00:00")
summaries = conflict_resolver.resolve_conflicts(tmp_path)
body = "\n".join(summaries[0].lines)
assert "Pola — Abeille" in body
assert "position 2" in body
assert "Radiohead — Kid A" in body # what it landed after
def test_tracks_the_other_copy_dropped_are_reported_not_hidden(self, tmp_path):
"""A union resurrects a track deleted on the other machine. That used to
happen in silence, so the deletion bounced back every sync with no clue
why."""
_pair(tmp_path, ours=[1, 2], theirs=[1], tracks=self._tracks(),
our_stamp="2026-08-20T18:00:00", their_stamp="2026-08-19T09:00:00")
summaries = conflict_resolver.resolve_conflicts(tmp_path)
body = "\n".join(summaries[0].lines)
assert "the other copy did not have" in body
assert "Pola — Abeille" in body
# Round 44: a removal now carries across on its own, so the note no
# longer tells you to delete it here by hand.
assert "Most likely you added it here" in body
def test_the_window_caps_the_list_and_the_backup_holds_all_of_it(self, tmp_path):
from lintunes.models import Track
tracks = [Track(track_id=i, name=f"Song {i}", artist="A") for i in range(1, 21)]
_pair(tmp_path, ours=[1], theirs=list(range(1, 21)), tracks=tracks,
our_stamp="2026-08-20T18:00:00", their_stamp="2026-08-19T09:00:00")
summaries = conflict_resolver.resolve_conflicts(tmp_path)
named = [line for line in summaries[0].lines if line.startswith(" • ")]
assert len(named) == conflict_resolver.TRACKS_TO_NAME
assert any("…and 13 more" in line for line in summaries[0].lines)
assert len([line for line in summaries[0].detail
if line.startswith(" • ")]) == 19
def test_a_rename_on_the_winning_copy_is_adopted_and_reported(self, tmp_path):
"""Only track_ids and settings were ever taken from the winner, so a
rename or a folder move made elsewhere was discarded by every merge."""
pfile, _ = _pair(tmp_path, ours=[1, 2], theirs=[2, 1],
their_name="a nissa two",
our_stamp="2026-08-19T09:00:00",
their_stamp="2026-08-20T18:00:00")
summaries = conflict_resolver.resolve_conflicts(tmp_path)
assert json.loads(pfile.read_text())["name"] == "a nissa two"
assert summaries[0].file == "a nissa two"
assert any("Renamed from" in line for line in summaries[0].lines)
class TestTheBackupReport:
def test_what_changed_is_written_beside_the_snapshots(self, tmp_path):
from lintunes.models import Track
_pair(tmp_path, ours=[1], theirs=[1, 2], device="CMFNCIX",
tracks=[Track(track_id=1, name="Kid A", artist="Radiohead"),
Track(track_id=2, name="Abeille", artist="Pola")],
our_stamp="2026-08-20T18:00:00", their_stamp="2026-08-19T09:00:00")
summaries = conflict_resolver.resolve_conflicts(tmp_path)
report = (tmp_path / ".resolved" / summaries[0].backup_dir.rsplit("/", 1)[-1]
/ "what-changed.txt").read_text()
assert "a nissa ideas" in report
assert "Pola — Abeille" in report
# The conflict filename is the only record of the device, and the file
# itself is deleted by the merge.
assert "sync-conflict-20240101-120000-CMFNCIX.json" in report
def test_restore_still_only_touches_the_original_snapshot(self, tmp_path):
pfile, _ = _pair(tmp_path, ours=[1, 2], theirs=[3],
our_stamp="2026-08-20T18:00:00",
their_stamp="2026-08-19T09:00:00")
summaries = conflict_resolver.resolve_conflicts(tmp_path)
assert set(json.loads(pfile.read_text())["track_ids"]) == {1, 2, 3}
from pathlib import Path
conflict_resolver.restore_backup(Path(summaries[0].backup_dir), tmp_path)
assert json.loads(pfile.read_text())["track_ids"] == [1, 2]
class TestTheLiveReloadPath:
"""Same symptom with no conflict file: Syncthing delivers a clean update
while we hold the playlist dirty."""
def _manager(self, tmp_path, qapp, ids, stamp):
from lintunes.library_manager import LibraryManager
lib = _library(tmp_path, [_playlist(ids, stamp)])
return LibraryManager(lib, tmp_path)
def _write_disk_copy(self, tmp_path, ids, stamp, name=None):
pfile = tmp_path / "playlists" / "AAAA1111.json"
data = json.loads(pfile.read_text())
data["track_ids"] = list(ids)
data["date_modified"] = stamp
if name:
data["name"] = name
pfile.write_text(json.dumps(data))
def test_a_newer_disk_reorder_is_not_reverted(self, tmp_path, qapp):
manager = self._manager(tmp_path, qapp, [1, 2, 3], "2026-08-19T09:00:00")
manager.library.playlists["AAAA1111"].track_ids = [1, 2, 3]
manager._dirty_playlist_content.add("AAAA1111")
self._write_disk_copy(tmp_path, [3, 2, 1], "2026-08-20T18:00:00")
manager.reload_from_disk()
assert manager.library.playlists["AAAA1111"].track_ids == [3, 2, 1]
def test_our_newer_edit_still_wins(self, tmp_path, qapp):
manager = self._manager(tmp_path, qapp, [1, 2, 3], "2026-08-19T09:00:00")
pl = manager.library.playlists["AAAA1111"]
pl.track_ids = [3, 1, 2]
pl.date_modified = "2026-08-21T10:00:00"
manager._dirty_playlist_content.add("AAAA1111")
self._write_disk_copy(tmp_path, [1, 2, 3], "2026-08-20T18:00:00")
manager.reload_from_disk()
assert manager.library.playlists["AAAA1111"].track_ids == [3, 1, 2]
def test_a_column_drag_does_not_make_us_the_edited_copy(self, tmp_path, qapp):
"""`_dirty_playlists` is set by a sort click too, and that used to route
the reload through the keep-local branch — reverting the other machine's
rename and reorder, then flushing the revert back to disk."""
manager = self._manager(tmp_path, qapp, [1, 2, 3], "2026-08-19T09:00:00")
manager.mark_playlist_settings_dirty("AAAA1111")
self._write_disk_copy(tmp_path, [3, 2, 1], "2026-08-20T18:00:00",
name="renamed elsewhere")
manager.reload_from_disk()
playlist = manager.library.playlists["AAAA1111"]
assert playlist.track_ids == [3, 2, 1]
assert playlist.name == "renamed elsewhere"
def test_a_rename_now_records_an_edit(self, tmp_path, qapp):
"""Without a stamp a rename is invisible to every merge."""
manager = self._manager(tmp_path, qapp, [1], None)
manager.rename_playlist("AAAA1111", "a nissa three")
assert manager.library.playlists["AAAA1111"].date_modified
assert "AAAA1111" in manager._dirty_playlist_content
def test_a_superset_on_disk_does_not_age_our_stamp(self, tmp_path, qapp):
"""Same hazard as the conflict path: the disk copy can hold every track
we have and still be the older edit."""
manager = self._manager(tmp_path, qapp, [1], "2026-08-21T10:00:00")
pl = manager.library.playlists["AAAA1111"]
pl.track_ids = [1]
manager._dirty_playlist_content.add("AAAA1111")
self._write_disk_copy(tmp_path, [1, 2], "2026-08-20T18:00:00")
manager.reload_from_disk()
pl = manager.library.playlists["AAAA1111"]
assert pl.track_ids == [1, 2]
assert pl.date_modified > "2026-08-21T10:00:00"