Files
lintunes/tests/test_round39.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

251 lines
10 KiB
Python

"""Round 39: playlist merges stop losing your track order.
Three defects, and it took all three to scramble `a nissa one`:
* `_merge_playlist` was a 2-way union with no common ancestor — it took one
side's order wholesale and *appended* the other side's extras, so a track
inserted in the middle on one machine arrived at the bottom on the other.
* Whose order won was decided by the file's mtime, and the last column is
stretch-sized — so resizing the window rewrote the open playlist's JSON and
"most recently edited" meant "most recently resized".
* Those cosmetic rewrites were themselves handing Syncthing conflicts.
"""
import json
import random
import pytest
from lintunes.models import Library, Playlist, PlaylistType
from lintunes.storage import json_storage, conflict_resolver
from lintunes.storage.conflict_resolver import merge_track_order
def _save(tmp_path, playlists=()):
lib = Library()
for p in playlists:
lib.playlists[p.persistent_id] = p
json_storage.save_library(lib, tmp_path)
return lib
def _conflict_name(original: str) -> str:
stem, _, ext = original.rpartition(".")
return f"{stem}.sync-conflict-20240101-120000-ABCDEFG.{ext}"
class TestAnchoredOrder:
"""merge_track_order: a track inserted in the middle stays in the middle."""
def test_middle_insert_is_not_appended(self):
# The other copy slipped 99 between 2 and 3. It belongs there, not last.
assert merge_track_order([1, 2, 3, 4], [1, 2, 99, 3, 4]) == [1, 2, 99, 3, 4]
def test_both_sides_inserted_at_different_points(self):
assert merge_track_order([1, 7, 2, 3], [1, 2, 8, 3]) == [1, 7, 2, 8, 3]
def test_consecutive_inserts_keep_their_relative_order(self):
assert merge_track_order([1, 2], [1, 8, 9, 2]) == [1, 8, 9, 2]
def test_a_leading_insert_goes_to_the_head(self):
assert merge_track_order([1, 2, 3], [7, 1, 2, 3]) == [7, 1, 2, 3]
def test_primary_decides_the_order_of_shared_tracks(self):
# Both copies hold 1-2-3; primary reversed them. Primary wins.
assert merge_track_order([3, 2, 1], [1, 2, 3]) == [3, 2, 1]
def test_trailing_insert_still_lands_after_its_anchor(self):
assert merge_track_order([1, 2, 3], [1, 2, 3, 4]) == [1, 2, 3, 4]
@pytest.mark.parametrize("primary,secondary", [
([], []),
([1, 2, 3], []),
([], [1, 2, 3]),
([1, 2, 3], [4, 5, 6]), # disjoint
([1, 2, 2, 3], [1, 2, 3, 3]), # the same track twice
])
def test_edge_cases_never_drop_a_track(self, primary, secondary):
merged = merge_track_order(primary, secondary)
assert set(merged) == set(primary) | set(secondary)
def test_nothing_is_ever_dropped(self):
"""The invariant that matters most: a merge is a union, always."""
rng = random.Random(39)
for _ in range(300):
base = rng.sample(range(20), rng.randint(0, 10))
a = [t for t in base if rng.random() > 0.3] + rng.sample(range(20, 30), 2)
b = [t for t in base if rng.random() > 0.3] + rng.sample(range(30, 40), 2)
rng.shuffle(a)
merged = merge_track_order(a, b)
assert set(merged) == set(a) | set(b)
# Primary's entries keep primary's exact sequence — only tracks it
# never had get woven in between them.
assert [t for t in merged if t in set(a)] == a
class TestHonestDateModified:
"""The merge decides on the playlist's own date_modified, not file mtime."""
def _setup(self, tmp_path, ours, theirs, our_stamp, their_stamp):
pl = Playlist(name="a nissa one", persistent_id="AAAA1111",
playlist_type=PlaylistType.REGULAR, track_ids=ours,
date_modified=our_stamp)
_save(tmp_path, [pl])
pfile = tmp_path / "playlists" / "AAAA1111.json"
other = json.loads(pfile.read_text())
other["track_ids"] = theirs
if their_stamp is None:
other.pop("date_modified", None)
else:
other["date_modified"] = their_stamp
cfile = tmp_path / "playlists" / _conflict_name("AAAA1111.json")
cfile.write_text(json.dumps(other))
return pfile, cfile
def test_a_resize_no_longer_beats_a_reorder(self, tmp_path):
"""The `a nissa one` regression. We reordered; the other machine only
opened it and resized the window, so its *file* is newer — but its
date_modified is older, and our order must survive."""
pfile, cfile = self._setup(
tmp_path,
ours=[3, 1, 2], # we carefully reordered
theirs=[1, 2, 3], # they never touched the contents
our_stamp="2026-08-20T18:00:00",
their_stamp="2026-08-19T09:00:00")
import os, time
os.utime(cfile, (time.time() + 100, time.time() + 100)) # their file is newer
summaries = conflict_resolver.resolve_conflicts(tmp_path)
assert json.loads(pfile.read_text())["track_ids"] == [3, 1, 2]
# Round 43: the report names the copy by which file Syncthing left the
# plain name on, not by a guess about which machine typed it.
assert "the copy that was already here" in summaries[0].lines[0]
def test_the_genuinely_newer_edit_still_wins(self, tmp_path):
pfile, _ = self._setup(
tmp_path, ours=[1, 2, 3], theirs=[3, 2, 1],
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())["track_ids"] == [3, 2, 1]
assert "the copy Syncthing set aside" in summaries[0].lines[0]
def test_their_insert_lands_in_position_not_at_the_tail(self, tmp_path):
pfile, _ = self._setup(
tmp_path, ours=[1, 2, 3], theirs=[1, 2, 99, 3],
our_stamp="2026-08-20T18:00:00",
their_stamp="2026-08-19T09:00:00")
summaries = conflict_resolver.resolve_conflicts(tmp_path)
assert json.loads(pfile.read_text())["track_ids"] == [1, 2, 99, 3]
assert any("put back where it had been" in line
for line in summaries[0].lines)
def test_falls_back_to_mtime_before_the_field_existed(self, tmp_path):
"""Playlists written by an older version carry no stamp."""
pfile, cfile = self._setup(
tmp_path, ours=[1, 2, 3], theirs=[3, 2, 1],
our_stamp=None, their_stamp=None)
import os, time
os.utime(cfile, (time.time() + 100, time.time() + 100))
conflict_resolver.resolve_conflicts(tmp_path)
assert json.loads(pfile.read_text())["track_ids"] == [3, 2, 1]
def test_smart_playlist_rules_also_use_date_modified(self, tmp_path):
from lintunes.smart import SmartCriteria
pl = Playlist(name="Recent", persistent_id="BBBB2222",
playlist_type=PlaylistType.SMART, track_ids=[1],
smart_criteria=SmartCriteria(), date_modified="2026-08-20T18:00:00")
_save(tmp_path, [pl])
pfile = tmp_path / "playlists" / "BBBB2222.json"
ours = json.loads(pfile.read_text())
other = dict(ours)
other["name"] = "Stale"
other["date_modified"] = "2026-08-19T09:00:00"
cfile = tmp_path / "playlists" / _conflict_name("BBBB2222.json")
cfile.write_text(json.dumps(other))
import os, time
os.utime(cfile, (time.time() + 100, time.time() + 100))
conflict_resolver.resolve_conflicts(tmp_path)
assert json.loads(pfile.read_text())["name"] == "Recent"
class TestDateModifiedMovesOnlyForContent:
@pytest.fixture
def manager(self, tmp_path, qapp, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
from lintunes.library_manager import LibraryManager
from lintunes.models import Track
lib = Library()
for tid in (1, 2, 3):
lib.tracks[tid] = Track(track_id=tid, name=f"T{tid}")
lib.playlists["AAAA1111"] = Playlist(
name="a nissa one", persistent_id="AAAA1111",
playlist_type=PlaylistType.REGULAR, track_ids=[1, 2, 3])
return LibraryManager(lib, tmp_path)
def _stamp(self, manager):
return manager.library.playlists["AAAA1111"].date_modified
def test_reorder_bumps_it(self, manager):
assert self._stamp(manager) is None
manager.move_tracks_in_playlist("AAAA1111", [2], 0)
assert self._stamp(manager) is not None
def test_add_and_remove_bump_it(self, manager):
manager.remove_tracks_from_playlist("AAAA1111", [0])
first = self._stamp(manager)
assert first is not None
manager.add_tracks_to_playlist("AAAA1111", [1])
assert self._stamp(manager) >= first
def test_undo_bumps_it_too(self, manager):
manager.move_tracks_in_playlist("AAAA1111", [2], 0)
before = self._stamp(manager)
manager.undo_stack.undo()
assert manager.library.playlists["AAAA1111"].track_ids == [1, 2, 3]
assert self._stamp(manager) >= before
def test_a_column_resize_does_not(self, manager):
"""The whole point: cosmetic settings must not look like an edit."""
manager.mark_playlist_settings_dirty("AAAA1111")
assert self._stamp(manager) is None
class TestStretchedColumnIsNotPersisted:
@pytest.fixture
def table(self, qapp):
from lintunes.gui.track_table import TrackTableView
from lintunes.models import Track
view = TrackTableView()
view.set_tracks([Track(track_id=1, name="A", artist="B")])
return view
def test_a_middle_column_still_persists(self, table):
seen = []
table.column_width_changed.connect(lambda f, w: seen.append((f, w)))
table.horizontalHeader().resizeSection(1, 123)
assert seen and seen[0][1] == 123
def test_the_stretched_last_column_does_not(self, table):
"""Qt re-fires sectionResized for the stretched column on every viewport
width change — a window resize used to rewrite the playlist JSON."""
header = table.horizontalHeader()
last = header.logicalIndex(header.count() - 1)
seen = []
table.column_width_changed.connect(lambda f, w: seen.append((f, w)))
header.resizeSection(last, 321)
assert seen == []
def test_resizing_the_widget_is_silent(self, table):
seen = []
table.column_width_changed.connect(lambda f, w: seen.append((f, w)))
table.resize(400, 300)
table.resize(900, 300)
assert seen == []