From 574e476dc36efb6d2cba865ee9298dea7f10e34c Mon Sep 17 00:00:00 2001 From: trav Date: Thu, 20 Aug 2026 21:29:03 -0400 Subject: [PATCH] v0.9.1: playlist merges stop losing your track order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What scrambled `a nissa one` took three defects at once. Reorder it on machine A; open it on machine B and resize the window; B's file is now newer, so the merge takes B's order — the old one — wholesale. _merge_playlist was a 2-way union with no common ancestor: one side's order wholesale, the other side's extras appended at the tail, so a track inserted in the middle on one machine arrived at the bottom on the other. merge_track_order re-inserts each side-only track after the nearest track both copies share instead. _reconcile_playlist (the live-reload path) uses the same helper. The union invariant is unchanged and property-tested over 300 random pairs — a merge never drops a track. Whose order wins is now Playlist.date_modified, bumped only in _set_track_ids (add / remove / reorder / undo) and deliberately not by mark_playlist_settings_dirty, with a file-mtime fallback for playlists written before the field existed. The file's mtime was a lie: the last column is stretch-sized, so Qt re-fires sectionResized whenever the viewport width changes, and a window resize or splitter drag rewrote the open playlist's JSON — moving its mtime and handing Syncthing another conflict — for a width apply_settings overrides on load anyway. _on_section_resized now skips that section; genuine drags on every other column still persist. Replayed on a copy of the real playlists: `a nissa one` keeps its reorder against a newer-by-mtime opponent, and `a nissa ideas` takes the other machine's two mid-list inserts at 5 and 12 rather than 26 and 27. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M8Mzze7shr5pZoEgKQU5NW --- CLAUDE.md | 15 +- TASKS.md | 29 ++- lintunes/__init__.py | 2 +- lintunes/gui/track_table.py | 9 + lintunes/library_manager.py | 17 +- lintunes/models/playlist.py | 7 + lintunes/storage/conflict_resolver.py | 83 +++++++-- tasks-done.md | 30 ++++ tests/test_round39.py | 247 ++++++++++++++++++++++++++ 9 files changed, 403 insertions(+), 36 deletions(-) create mode 100644 tests/test_round39.py diff --git a/CLAUDE.md b/CLAUDE.md index 21fae77..d40b234 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,14 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal `plays/.json` per machine. Writes are atomic (`*.json.tmp` → rename). **`storage/conflict_resolver.py`** merges Syncthing `*.sync-conflict-*` files on startup: play counts take the max, edited fields - take the newest, playlist membership takes the union. + take the newest, playlist membership takes the union. Since Round 39 that + union is **anchor-based** (`merge_track_order`): a track only one copy has is + re-inserted after the nearest track both share, not appended at the tail, so + a middle insert stays in the middle. Whose order wins is decided by + `Playlist.date_modified` — bumped *only* in + `LibraryManager._set_track_ids` — not by the file's mtime, which moves for + cosmetic reasons. `library_manager._reconcile_playlist` uses the same helper + for the live-reload path. - **`lintunes/storage/play_journal.py`** — why play counts can't conflict. Since Round 38 `library.json` holds only a **base** count and each machine owns @@ -187,6 +194,12 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal - **Keep `Player` and `track_table` manager-free where they already are** — cross-cutting data is injected via callbacks/signals (e.g. `track_table` takes a `playlists_for_track` callback rather than importing the manager). +- **Cosmetic table settings must not look like edits.** The last column is + stretch-sized, so Qt re-fires `sectionResized` for it on every viewport width + change; `track_table._on_section_resized` ignores that section, or resizing + the window would rewrite the open playlist's JSON (and hand Syncthing a + conflict) purely for a width `apply_settings` overrides on load anyway. + - **Qt/Wayland gotchas (GNOME/Mutter):** `QDrag.setPixmap` / `setDragCursor` / `QCursor.pos()` are unreliable during a drag — `gui/drag_ghost.py` paints its own child-widget overlay instead. `QAudioOutput` must not be constructed before diff --git a/TASKS.md b/TASKS.md index 5d551ed..b0b0f4c 100644 --- a/TASKS.md +++ b/TASKS.md @@ -12,28 +12,25 @@ When a round closes, move its finished items to `tasks-done.md`. ## Round 37 (2026-08-19) — Export Playlist: done, see tasks-done.md Folder + web-mix export, audio.js/jQuery dropped for a dependency-free -player, lossless-only conversion. Round 36 below is still open. +player, lossless-only conversion. ## Round 38 (2026-08-20) — merge windows + play journals: done, see tasks-done.md +## Round 39 (2026-08-20) — playlist order + honest date_modified: done, see tasks-done.md -## Round 36 — the merge rework (the playlist half is still open) +## Round 36 — the merge rework (closed out by Rounds 38 and 39) The three symptoms in Round 35 shared a root cause; that round fixed the -performance half. Round 38 took the play-count and dialog items; the two -playlist items below are what's left. +performance half. Round 38 took the play-count and dialog items, Round 39 the +two playlist ones. Only the `.stignore` note below is left. -- [ ] **Playlist merges lose position.** `_merge_playlist` is a 2-way union with - no common ancestor: it takes one side's order wholesale and *appends* the - other side's extras, so a track inserted in the middle on one machine - arrives at the tail on the other (this is what happened to `nissa one`). - Replace with an anchor-based merge — insert each side-only track after its - nearest preceding common anchor. Same in `_reconcile_playlist`. -- [ ] **mtime is a lie for playlists.** `mark_playlist_settings_dirty` rewrites - the whole playlist file for UI-only changes, and because the last column - is stretch-sized, *resizing the window* rewrites the open playlist's JSON. - So "most recently edited" often means "most recently resized". Give - `Playlist` a `date_modified` bumped only in `_set_track_ids`, and merge on - that. +- [x] **Playlist merges lose position.** Done in Round 39 — `merge_track_order` + anchors each side-only track to the nearest track both copies share. + Replayed against the real `a nissa one`: the reorder survives and a + mid-list insert lands at index 5, not 26. +- [x] **mtime is a lie for playlists.** Done in Round 39 — `Playlist.date_modified` + is bumped only in `_set_track_ids`, and `_on_section_resized` now ignores + the stretched last column, so resizing the window doesn't rewrite the + playlist file at all. - [x] **`max()` play counts discard concurrent plays.** Done as described — per-machine `plays/.json` journals, machine id in the config dir. Three plays on the real library now write 83 bytes instead of 15 MB, diff --git a/lintunes/__init__.py b/lintunes/__init__.py index 517314e..b94219e 100644 --- a/lintunes/__init__.py +++ b/lintunes/__init__.py @@ -1,3 +1,3 @@ """LinTunes — iTunes-style music library manager and player for Linux.""" -__version__ = "0.9.0" +__version__ = "0.9.1" diff --git a/lintunes/gui/track_table.py b/lintunes/gui/track_table.py index e45dd2b..10c407d 100644 --- a/lintunes/gui/track_table.py +++ b/lintunes/gui/track_table.py @@ -714,6 +714,15 @@ class TrackTableView(QTableView): def _on_section_resized(self, section, _old, new_width): if self._suppress_signals or section >= len(self.model_.fields): return + header = self.horizontalHeader() + if (header.stretchLastSection() + and section == header.logicalIndex(header.count() - 1)): + # The last column is stretch-sized, so Qt re-fires this every time + # the viewport changes width — resizing the window or dragging the + # splitter would otherwise rewrite the whole playlist JSON (and hand + # Syncthing a conflict). Its width is derived, not chosen: there's no + # right edge to drag, and apply_settings overrides it on load. + return field = self.model_.fields[section] if field != INDEX_FIELD: self.column_width_changed.emit(field, new_width) diff --git a/lintunes/library_manager.py b/lintunes/library_manager.py index ad32e6d..d0598e7 100644 --- a/lintunes/library_manager.py +++ b/lintunes/library_manager.py @@ -313,6 +313,11 @@ class LibraryManager(QObject): if playlist is None: return playlist.track_ids = list(ids) + # The single funnel for every content change (add / remove / reorder / + # undo), and deliberately the *only* place date_modified moves — a + # column resize must not look like an edit to the merge. See + # conflict_resolver._playlist_newer. + playlist.date_modified = _utc_now_iso() self._mark_playlist(pid) self.playlist_content_changed.emit(pid) @@ -974,13 +979,13 @@ def _reconcile_track(mem_track, disk_track): def _reconcile_playlist(mem_pl, disk_pl): - """Reconcile a playlist we were locally editing with the disk copy: keep our - order and append any tracks that exist only on the other machine (a merge + """Reconcile a playlist we were locally editing with the disk copy: our order + wins (the local edit is the more recent one), and tracks that exist only on + the other machine are merged back into position rather than appended (a merge never drops tracks).""" - local = list(mem_pl.track_ids) - local_set = set(local) - mem_pl.track_ids = local + [tid for tid in disk_pl.track_ids - if tid not in local_set] + from lintunes.storage.conflict_resolver import merge_track_order + mem_pl.track_ids = merge_track_order(list(mem_pl.track_ids), + list(disk_pl.track_ids)) def _utc_now_iso() -> str: diff --git a/lintunes/models/playlist.py b/lintunes/models/playlist.py index 8dc2c1c..6cc0972 100644 --- a/lintunes/models/playlist.py +++ b/lintunes/models/playlist.py @@ -58,6 +58,10 @@ class Playlist: track_ids: list[int] = field(default_factory=list) settings: PlaylistSettings = field(default_factory=PlaylistSettings) is_system: bool = False + # Bumped only when track_ids change (LibraryManager._set_track_ids), never + # for column widths or sort order — the conflict merge uses it to decide + # whose order wins, and the file's mtime moves for cosmetic reasons. + date_modified: Optional[str] = None # Set only for SMART playlists. Membership (track_ids) is derived from this # by the LibraryManager; for imported playlists track_ids also doubles as a # snapshot fallback when the criteria couldn't be fully parsed. @@ -78,6 +82,8 @@ class Playlist: } if self.parent_persistent_id: d["parent_persistent_id"] = self.parent_persistent_id + if self.date_modified: + d["date_modified"] = self.date_modified if self.smart_criteria is not None: d["smart_criteria"] = self.smart_criteria.to_dict() return d @@ -96,6 +102,7 @@ class Playlist: track_ids=d.get("track_ids", []), settings=PlaylistSettings.from_dict(d.get("settings", {})), is_system=d.get("is_system", False), + date_modified=d.get("date_modified"), smart_criteria=smart_criteria, ) diff --git a/lintunes/storage/conflict_resolver.py b/lintunes/storage/conflict_resolver.py index 481785d..b42b6ca 100644 --- a/lintunes/storage/conflict_resolver.py +++ b/lintunes/storage/conflict_resolver.py @@ -226,43 +226,102 @@ def _merge_track_fields(orig: dict, conflict: dict) -> list[str]: return notes +def merge_track_order(primary: list[int], secondary: list[int]) -> list[int]: + """Union two orderings of a playlist without losing position. + + A plain union appends whatever the other copy had at the tail, so a track + inserted in the *middle* on one machine arrives at the *bottom* on the other + — which is what scrambled `a nissa one`. Instead, every track that exists + only in ``secondary`` is re-inserted after its nearest preceding track that + both copies share (its "anchor"); ``primary`` decides the order of everything + the two have in common. + + The result is always the union of the two lists: a merge must never drop a + track, so anything whose anchor never turns up is flushed at the tail. + """ + common = set(primary) & set(secondary) + + # Walk secondary once: each secondary-only track hangs off the last shared + # track before it (None = it led the list). + pending: dict[object, list[int]] = {} + anchor = None + for tid in secondary: + if tid in common: + anchor = tid + else: + pending.setdefault(anchor, []).append(tid) + + merged = list(pending.pop(None, [])) + seen = set() + for tid in primary: + merged.append(tid) + # First occurrence only — a playlist may hold the same track twice. + if tid in common and tid not in seen: + seen.add(tid) + merged.extend(pending.pop(tid, [])) + for leftover in pending.values(): # anchor never appeared; never drop it + merged.extend(leftover) + return merged + + +def _playlist_newer(original: dict, conflict: dict, + original_path: Path, conflict_path: Path) -> bool: + """Is the incoming copy the more recently *edited* one? + + Prefer the playlist's own ``date_modified``, which only moves when its + contents change. File mtime is a lie: the last column is stretch-sized, so + resizing the window rewrites the open playlist's JSON — "most recently + edited" used to mean "most recently resized". Falls back to mtime for + playlists written before ``date_modified`` existed. + """ + ours, theirs = original.get("date_modified"), conflict.get("date_modified") + if ours and theirs: + return theirs > ours + return conflict_path.stat().st_mtime > original_path.stat().st_mtime + + def _merge_playlist(original_path: Path, conflict_path: Path) -> ConflictSummary: original = read_json(original_path) conflict = read_json(conflict_path) name = original.get("name") or conflict.get("name") or original_path.stem + conflict_newer = _playlist_newer(original, conflict, original_path, conflict_path) + # Smart playlists: membership is derived, so keep the newer criteria and let # it recompute (unioning derived track_ids would resurrect non-matches). if original.get("playlist_type") == "smart" or conflict.get("playlist_type") == "smart": - newer = _keep_newer(original_path, conflict_path) - newer.file, newer.kind = name, "playlist" - newer.lines = ["Smart-playlist rules taken from the most recently edited copy."] - return newer + if conflict_newer: + write_json(original_path, conflict) + return ConflictSummary( + name, "playlist", + ["Smart-playlist rules taken from the most recently edited copy."]) orig_ids = list(original.get("track_ids", [])) conf_ids = list(conflict.get("track_ids", [])) orig_set, conf_set = set(orig_ids), set(conf_ids) - conflict_newer = conflict_path.stat().st_mtime > original_path.stat().st_mtime if conflict_newer: - base_order = conf_ids - extra = [tid for tid in orig_ids if tid not in conf_set] - original["track_ids"] = base_order + extra + # The other copy was edited last, so it sets the order; anything only we + # had is re-inserted at its anchor rather than dumped at the tail. + original["track_ids"] = merge_track_order(conf_ids, orig_ids) if "settings" in conflict: original["settings"] = conflict["settings"] + if conflict.get("date_modified"): + original["date_modified"] = conflict["date_modified"] order_src = "the other machine" + moved_in = len(conf_set - orig_set) else: - extra = [tid for tid in conf_ids if tid not in orig_set] - original["track_ids"] = orig_ids + extra + original["track_ids"] = merge_track_order(orig_ids, conf_ids) order_src = "this machine" + moved_in = len(orig_set - conf_set) write_json(original_path, original) added = len(set(original["track_ids"]) - orig_set) lines = [f"Order kept from {order_src} (most recently edited)."] if added: - lines.append(f"{added} track(s) that were only in the other copy were kept " - "(nothing is removed on a merge).") + lines.append(f"{added} track(s) that were only in the other copy were kept, " + "back in position (nothing is removed on a merge).") return ConflictSummary(name, "playlist", lines) diff --git a/tasks-done.md b/tasks-done.md index 10a4136..2ba7eda 100644 --- a/tasks-done.md +++ b/tasks-done.md @@ -1,5 +1,35 @@ ## Done +### Round 39 (2026-08-20) — Playlist merges stop losing your track order (v0.9.1) + +The playlist half of the Round 36 rework, and what actually scrambled +`a nissa one`. It took three defects together: reorder it on machine A, open it +on machine B and resize the window, and B's file is newer, so the merge takes +B's order — the old one — wholesale. + +- [x] **Anchor-based merge.** `merge_track_order` in `conflict_resolver` unions + two orderings by re-inserting each side-only track after the nearest track + both copies share, instead of appending it at the tail. `_merge_playlist` + and `library_manager._reconcile_playlist` (the live-reload path) both use + it. The union invariant is unchanged and property-tested over 300 random + pairs: a merge never drops a track, duplicates and disjoint lists included. +- [x] **`Playlist.date_modified`, bumped only in `_set_track_ids`** — the single + funnel for add / remove / reorder / undo, and deliberately *not* + `mark_playlist_settings_dirty`. `_playlist_newer` prefers it over the + file's mtime, falling back to mtime for playlists written before the field + existed. The smart-playlist branch uses the same test, which told the same + lie. +- [x] **A window resize no longer rewrites the playlist.** The last column is + stretch-sized, so Qt re-fires `sectionResized` for it whenever the viewport + width changes — a resize or a splitter drag rewrote the open playlist's + JSON, moved its mtime, and handed Syncthing another conflict, all for a + width `apply_settings` overrides on load. `_on_section_resized` skips that + section; genuine drags on every other column still persist. + +Replayed against the real playlists on a copy: on `a nissa one` (23 tracks) the +reorder survives a newer-by-mtime opponent, and on `a nissa ideas` (26 tracks) +the other machine's two mid-list inserts land at 5 and 12 rather than 26 and 27. + ### Round 38 (2026-08-20) — One merge window, and play counts that can't conflict (v0.9.0) Fifteen "Synced changes merged" windows were stacked on the desktop. Two diff --git a/tests/test_round39.py b/tests/test_round39.py new file mode 100644 index 0000000..9bffe97 --- /dev/null +++ b/tests/test_round39.py @@ -0,0 +1,247 @@ +"""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] + assert "this machine" 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 other machine" 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 "back in position" in summaries[0].lines[1] + + 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 == []