"""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"