Files
lintunes/tests/test_round70.py
T
travandClaude Opus 5.5 ba795e3c69 v0.32.0: folder tree in Rabbit Sync Settings, quieter art pass, tink, VLC-style Preferences
- Rabbit Sync Settings shows the sidebar's folder tree. A folder has its own
  tristate checkbox; a fully ticked folder is stored as device_sync.folder_ids
  and follows the folder, so playlists added to it later sync too.
- The album-art pass compares covers against the sizes the planner's device
  scan already read (plan.art_sizes) instead of an MTP stat per album, caches
  "no art" as a .none marker, and only unlinks covers that are really there.
  The summary reports covers actually updated rather than every cover.
- A quiet tink when a Rabbit sync, export or URL import finishes, with an
  on/off switch (sound_effects, on by default).
- Preferences is now General / Appearance / Integrations, a list on the left
  and the page on the right, opened big enough for the tallest page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 00:45:42 -07:00

255 lines
10 KiB
Python

"""Round 70: Rabbit Sync Settings as a folder tree, an album-art pass that
does no device I/O when nothing changed, the "tink", and Preferences in
VLC-style pages.
* A fully ticked folder is stored as the folder, so a playlist added to it
later syncs too; unticking one inside makes the folder partial and the
selection falls back to playlist ids.
* The art pass compares against the sizes the planner's scan already read,
never a stat per album over MTP, and "this album has no art" is cached so
an artless album isn't re-opened every sync.
"""
import json
from pathlib import Path
from unittest.mock import patch
from PyQt6.QtCore import QObject, Qt, pyqtSignal
from lintunes import tagging
from lintunes.andtunes import art, layout
from lintunes.andtunes.layout import album_key, art_relpath
from lintunes.andtunes.sync import AndTunesSyncWorker, plan_andtunes_sync
from lintunes.gui import sounds
from lintunes.gui.device_sync_dialog import (
DeviceSyncSettingsDialog, resolve_sync_playlists,
)
from lintunes.models import Track
from lintunes.models.playlist import Playlist, PlaylistType
from lintunes.preferences import Preferences
CHECKED = Qt.CheckState.Checked
UNCHECKED = Qt.CheckState.Unchecked
PARTIAL = Qt.CheckState.PartiallyChecked
class _Lib:
def __init__(self, *playlists):
self.playlists = {p.persistent_id: p for p in playlists}
def _pl(name, pid, parent="", kind=PlaylistType.REGULAR, **kw):
return Playlist(name=name, persistent_id=pid, playlist_type=kind,
parent_persistent_id=parent, **kw)
def _library():
return _Lib(
_pl("Zed", "Z1"),
_pl("Boxes", "F1", kind=PlaylistType.FOLDER),
_pl("Inside B", "B1", parent="F1"),
_pl("Inside A", "A1", parent="F1"),
_pl("Nested", "F2", parent="F1", kind=PlaylistType.FOLDER),
_pl("Deep", "D1", parent="F2"),
_pl("Empty Folder", "F3", kind=PlaylistType.FOLDER),
_pl("Music", "S1", is_system=True),
)
def _item(dialog, pid):
return next(i for i in dialog._items() if i.data(0, Qt.ItemDataRole.UserRole) == pid)
class TestFolderTree:
def test_tree_mirrors_the_sidebar(self, qapp, tmp_path):
dialog = DeviceSyncSettingsDialog(Preferences(tmp_path), _library())
top = [dialog._tree.topLevelItem(i).text(0)
for i in range(dialog._tree.topLevelItemCount())]
# Folders mixed in by name; the empty folder and system list gone.
assert top == ["Boxes", "Zed (0)"]
boxes = _item(dialog, "F1")
assert [boxes.child(i).text(0) for i in range(boxes.childCount())] == [
"Inside A (0)", "Inside B (0)", "Nested"]
def test_ticking_a_folder_ticks_everything_inside(self, qapp, tmp_path):
dialog = DeviceSyncSettingsDialog(Preferences(tmp_path), _library())
_item(dialog, "F1").setCheckState(0, CHECKED)
assert _item(dialog, "D1").checkState(0) == CHECKED
assert dialog.selection() == ([], ["F1"])
def test_unticking_one_inside_makes_the_folder_partial(self, qapp, tmp_path):
dialog = DeviceSyncSettingsDialog(Preferences(tmp_path), _library())
_item(dialog, "F1").setCheckState(0, CHECKED)
_item(dialog, "A1").setCheckState(0, UNCHECKED)
assert _item(dialog, "F1").checkState(0) == PARTIAL
# The fully ticked subfolder is still remembered as a folder.
assert dialog.selection() == (["B1"], ["F2"])
def test_ok_writes_and_reopening_restores(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
dialog = DeviceSyncSettingsDialog(prefs, _library())
_item(dialog, "F1").setCheckState(0, CHECKED)
_item(dialog, "Z1").setCheckState(0, CHECKED)
dialog.accept()
assert prefs.device_sync["folder_ids"] == ["F1"]
assert prefs.device_sync["playlist_ids"] == ["Z1"]
again = DeviceSyncSettingsDialog(prefs, _library())
assert _item(again, "F1").checkState(0) == CHECKED
assert _item(again, "F1").isExpanded()
def test_filter_keeps_ancestors_of_a_match(self, qapp, tmp_path):
dialog = DeviceSyncSettingsDialog(Preferences(tmp_path), _library())
dialog._filter.setText("deep")
assert not _item(dialog, "D1").isHidden()
assert not _item(dialog, "F1").isHidden()
assert _item(dialog, "A1").isHidden()
assert _item(dialog, "Z1").isHidden()
class TestResolve:
def test_a_ticked_folder_follows_new_playlists(self):
library = _library()
sync = {"playlist_ids": ["Z1"], "folder_ids": ["F1"]}
library.playlists["N1"] = _pl("Added Later", "N1", parent="F2")
assert [p.persistent_id for p in resolve_sync_playlists(library, sync)] \
== ["Z1", "A1", "B1", "N1", "D1"] # "Added Later" < "Deep"
def test_old_prefs_and_missing_ids(self):
sync = {"playlist_ids": ["B1", "GONE", "B1"]}
assert [p.persistent_id
for p in resolve_sync_playlists(_library(), sync)] == ["B1"]
# ---- album art ----
def _track(tid, path, album="Record"):
return Track(track_id=tid, name=f"Song {tid}", artist="Band", album=album,
location=str(path), total_time=180_000)
def _copy(src, tmp_path, name):
dest = tmp_path / "local" / name
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(Path(src).read_bytes())
return dest
class TestArtPass:
def test_planner_records_cover_sizes_from_its_scan(self, qapp, tmp_path,
mp3_file):
track = _track(1, mp3_file)
root = tmp_path / "dev"
cover = root / art_relpath(album_key(track))
cover.parent.mkdir(parents=True)
cover.write_bytes(b"j" * 77)
plan = plan_andtunes_sync([_pl("A", "P1", track_ids=[1])],
{1: track}, root)
assert plan.art_sizes == {album_key(track): 77}
def test_second_sync_touches_no_covers(self, qapp, tmp_path, mp3_file,
jpeg_bytes, monkeypatch):
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache"))
bare = _track(2, _copy(mp3_file, tmp_path, "bare.mp3"), album="Bare")
tagging.write_artwork(mp3_file, jpeg_bytes)
with_art = _track(1, mp3_file, album="Covered")
root = tmp_path / "dev"
playlists = [_pl("A", "P1", track_ids=[1, 2])]
tracks = {1: with_art, 2: bare}
first = []
worker = AndTunesSyncWorker(plan_andtunes_sync(playlists, tracks, root))
worker.finished.connect(first.append)
worker._run()
assert first[0]["covers_copied"] >= 1
reads = []
real_read = tagging.read_embedded_artwork
monkeypatch.setattr(tagging, "read_embedded_artwork",
lambda p: reads.append(p) or real_read(p))
writes = []
real_write = Path.write_bytes
def spy_write(self, data):
if "Art" in self.parts:
writes.append(self)
return real_write(self, data)
second = []
with patch.object(Path, "write_bytes", spy_write):
worker = AndTunesSyncWorker(plan_andtunes_sync(playlists, tracks, root))
worker.finished.connect(second.append)
worker._run()
assert second[0]["covers_copied"] == 0
assert writes == []
assert reads == [] # neither album re-opened, art or not
def test_no_art_is_cached_and_invalidated_by_a_new_file(
self, qapp, tmp_path, mp3_file, monkeypatch):
import os
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache"))
track = _track(1, mp3_file)
calls = []
real_read = tagging.read_embedded_artwork
monkeypatch.setattr(tagging, "read_embedded_artwork",
lambda p: calls.append(p) or real_read(p))
assert art.album_art_jpeg(track) is None
assert art.album_art_jpeg(track) is None
assert len(calls) == 1
os.utime(mp3_file, (9e9, 9e9))
assert art.album_art_jpeg(track) is None
assert len(calls) == 2
# ---- sound + preferences ----
class _FakeLastFm(QObject):
login_finished = pyqtSignal(bool, str)
def is_logged_in(self):
return False
def __getattr__(self, name):
return lambda *a, **k: False
class TestSound:
def test_off_means_silent(self, tmp_path):
prefs = Preferences(tmp_path)
prefs.set("sound_effects", False)
with patch.object(sounds, "_tink") as tink:
sounds.play_done(prefs)
tink.assert_not_called()
sounds.play_done(prefs, force=True) # the preview button
tink.return_value.play.assert_called_once()
def test_on_by_default(self, tmp_path):
with patch.object(sounds, "_tink") as tink:
sounds.play_done(Preferences(tmp_path))
tink.return_value.play.assert_called_once()
def test_the_wav_ships(self):
from importlib import resources
assert (resources.files("lintunes") / "sounds" / "tink.wav").is_file()
class TestPreferencesPages:
def test_three_pages_and_switching(self, qapp, tmp_path):
from lintunes.gui.preferences_dialog import PreferencesDialog
PreferencesDialog._last_page = 0
dialog = PreferencesDialog(Preferences(tmp_path), _FakeLastFm())
names = [dialog.pages_list.item(i).text()
for i in range(dialog.pages_list.count())]
assert names == ["General", "Appearance", "Integrations"]
dialog.pages_list.setCurrentRow(2)
assert dialog.pages.currentIndex() == 2
PreferencesDialog._last_page = 0
def test_sound_checkbox_writes_the_pref(self, qapp, tmp_path):
from lintunes.gui.preferences_dialog import PreferencesDialog
prefs = Preferences(tmp_path)
dialog = PreferencesDialog(prefs, _FakeLastFm())
dialog._sound_check.setChecked(False)
assert prefs.get("sound_effects") is False
saved = json.loads((tmp_path / "preferences.json").read_text())
assert saved["sound_effects"] is False