Files
lintunes/tests/test_round27.py
T
travandClaude Fable 5 c9c8971243 v0.2.2: device-sync guards — quit warning, sleep/shutdown inhibition
Quitting mid-transfer now asks (Keep Syncing / Quit Anyway) from
closeEvent, which every quit path hits — including the self-update
restart, which previously bypassed closeEvent via a bare quit() and now
routes through close(). A running sync holds its own GNOME inhibitor
(suspend + logout, "Syncing a playlist to a device") so the machine
won't sleep or shutdown/restart under a copy.

Along the way: the Inhibit D-Bus call marshaled Python ints as signed
against GNOME's (susu) signature, so every call was rejected and the
playback sleep inhibitor had silently never worked — _uint fixes both
holders (verified live: cookies taken, IsInhibited flips, releases
clean).

Also investigated trav's mid-copy Syncthing question: the transfer works
from a click-time snapshot and never reads live library state, so remote
changes can't corrupt it — no state freeze needed. Hardened the one real
gap: a source file relocated under the queue (remote metadata edit
moving files) is now skipped, dropped from the m3u, and reported,
instead of aborting the whole sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 17:59:25 -04:00

141 lines
5.3 KiB
Python

"""Round 27: device-sync guards.
A running transfer now (a) holds a GNOME suspend+logout inhibitor so the
machine won't sleep or restart under it, (b) is protected by a quit-warning
in MainWindow.closeEvent (not testable headless — no MainWindow tests exist),
and (c) tolerates a source file vanishing mid-queue: Syncthing applying a
remote metadata edit can relocate a music file between planning and copying,
so the worker skips it, drops it from the m3u, and reports it instead of
aborting the whole transfer. Device-side errors still abort.
"""
from lintunes.device_sync import DeviceSyncWorker, plan_sync
from lintunes.inhibit import INHIBIT_LOGOUT, INHIBIT_SUSPEND, SleepInhibitor
from lintunes.models import Track
def _track(tid, name, artist, path):
return Track(track_id=tid, name=name, artist=artist,
location=str(path), total_time=180_000)
def _audio(tmp_path, filename, data=b"x" * 100):
path = tmp_path / "local" / filename
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
return path
class _FakeIface:
"""Stands in for the QDBusInterface: records calls, hands out cookies."""
def __init__(self):
self.calls = []
self._next_cookie = 41
def isValid(self):
return True
def call(self, method, *args):
from PyQt6.QtDBus import QDBusMessage
self.calls.append((method, *args))
stub = QDBusMessage.createMethodCall("a.b", "/", "a.b", method)
if method == "Inhibit":
self._next_cookie += 1
return stub.createReply([self._next_cookie])
return stub.createReply([])
class TestSleepInhibitor:
def _patched(self, monkeypatch, **kwargs):
# _uint wraps ints in write-only QDBusArguments; identity here so the
# fake can record and assert on the raw values.
from lintunes import inhibit as inhibit_module
monkeypatch.setattr(inhibit_module, "_uint", lambda v: v)
inhibitor = SleepInhibitor(**kwargs)
inhibitor._iface = _FakeIface()
return inhibitor
def test_custom_reason_and_flags_reach_dbus(self, qapp, monkeypatch):
inhibitor = self._patched(
monkeypatch, reason="Syncing a playlist to a device",
flags=INHIBIT_LOGOUT | INHIBIT_SUSPEND)
inhibitor.inhibit()
method, app_id, xid, reason, flags = inhibitor._iface.calls[0]
assert method == "Inhibit"
assert reason == "Syncing a playlist to a device"
assert flags == 5 # logout (1) + suspend (4)
def test_defaults_unchanged_for_playback(self, qapp, monkeypatch):
inhibitor = self._patched(monkeypatch)
inhibitor.inhibit()
method, app_id, xid, reason, flags = inhibitor._iface.calls[0]
assert reason == "Playing music"
assert flags == INHIBIT_SUSPEND == 4
def test_release_uninhibits_with_cookie(self, qapp, monkeypatch):
inhibitor = self._patched(monkeypatch)
inhibitor.inhibit()
cookie = inhibitor._cookie
inhibitor.release()
assert ("Uninhibit", cookie) in inhibitor._iface.calls
assert inhibitor._cookie is None
def test_second_inhibit_while_held_is_noop(self, qapp, monkeypatch):
inhibitor = self._patched(monkeypatch)
inhibitor.inhibit()
inhibitor.inhibit()
assert len([c for c in inhibitor._iface.calls
if c[0] == "Inhibit"]) == 1
def test_release_without_hold_is_noop(self, qapp, monkeypatch):
inhibitor = self._patched(monkeypatch)
inhibitor.release()
assert inhibitor._iface.calls == []
class TestVanishedSource:
def _run(self, plan):
worker = DeviceSyncWorker(plan)
results = {}
worker.finished.connect(lambda s: results.update(s))
worker.failed.connect(lambda m: results.update(error=m))
worker._run()
return results
def test_vanished_source_is_skipped_not_fatal(self, qapp, tmp_path):
a = _audio(tmp_path, "a.mp3", b"a" * 50)
b = _audio(tmp_path, "b.mp3", b"b" * 70)
tracks = [_track(1, "A", "X", a), _track(2, "B", "Y", b)]
plan = plan_sync("Mix", tracks, tmp_path / "Music")
a.unlink() # Syncthing relocated it between plan and copy
summary = self._run(plan)
assert "error" not in summary
assert summary["vanished"] == 1
assert summary["copied"] == 1
assert not (plan.dest_dir / "X - A.mp3").exists()
assert (plan.dest_dir / "Y - B.mp3").read_bytes() == b"b" * 70
def test_vanished_track_left_out_of_m3u(self, qapp, tmp_path):
a = _audio(tmp_path, "a.mp3")
b = _audio(tmp_path, "b.mp3")
tracks = [_track(1, "A", "X", a), _track(2, "B", "Y", b)]
plan = plan_sync("Mix", tracks, tmp_path / "Music")
a.unlink()
self._run(plan)
m3u = (plan.dest_dir / "Mix.m3u").read_text(encoding="utf-8")
assert "X - A.mp3" not in m3u
assert "Y - B.mp3" in m3u
def test_all_sources_vanished_still_finishes(self, qapp, tmp_path):
a = _audio(tmp_path, "a.mp3")
plan = plan_sync("Mix", [_track(1, "A", "X", a)], tmp_path / "Music")
a.unlink()
summary = self._run(plan)
assert summary["vanished"] == 1 and summary["copied"] == 0
assert (plan.dest_dir / "Mix.m3u").read_text() == "#EXTM3U\n"