"""Round 28: cancelable device sync. `DeviceSyncWorker.cancel()` stops the transfer at the next chunk boundary. The contract under test: the in-flight partial file is removed, tracks that fully landed stay, and the m3u is rewritten to list only what is really on the device — so a cancelled sync is always coherent and a later re-sync completes it to exactly the same state an uninterrupted sync would have produced. (The hover-expanding ✕ button and confirm dialog are plain Qt in MainWindow — no MainWindow tests exist, GUI verified by hand.) Tests run the worker synchronously (`_run()`), so a `progress` handler runs inline and can call `cancel()` at a chosen emit — each 100-byte file emits exactly once, making "cancel during file N" deterministic. """ from lintunes.device_sync import CHUNK, DeviceSyncWorker, plan_sync 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 def _run(plan, cancel_on_emit=None): """Run a plan synchronously; optionally cancel() on the Nth progress emit. Returns {"finished"|"cancelled"|"error": payload}.""" worker = DeviceSyncWorker(plan) results = {} emits = [0] def on_progress(done, total, label): emits[0] += 1 if cancel_on_emit is not None and emits[0] == cancel_on_emit: worker.cancel() worker.progress.connect(on_progress) worker.finished.connect(lambda s: results.update(finished=s)) worker.cancelled.connect(lambda s: results.update(cancelled=s)) worker.failed.connect(lambda m: results.update(error=m)) worker._run() return worker, results class TestCancel: def test_cancel_before_run_copies_nothing(self, qapp, tmp_path): a = _audio(tmp_path, "a.mp3") plan = plan_sync("Mix", [_track(1, "A", "X", a)], tmp_path / "Music") worker = DeviceSyncWorker(plan) results = {} worker.finished.connect(lambda s: results.update(finished=s)) worker.cancelled.connect(lambda s: results.update(cancelled=s)) worker.cancel() worker._run() assert "cancelled" in results and "finished" not in results assert results["cancelled"]["copied"] == 0 assert not (plan.dest_dir / "X - A.mp3").exists() def test_cancel_during_second_file_keeps_first(self, qapp, tmp_path): a = _audio(tmp_path, "a.mp3", b"a" * 100) b = _audio(tmp_path, "b.mp3", b"b" * 100) c = _audio(tmp_path, "c.mp3", b"c" * 100) tracks = [_track(1, "A", "X", a), _track(2, "B", "X", b), _track(3, "C", "X", c)] plan = plan_sync("Mix", tracks, tmp_path / "Music") worker, results = _run(plan, cancel_on_emit=2) summary = results["cancelled"] assert summary["copied"] == 1 and summary["total"] == 3 assert (plan.dest_dir / "X - A.mp3").read_bytes() == b"a" * 100 assert not (plan.dest_dir / "X - B.mp3").exists() # partial removed assert not (plan.dest_dir / "X - C.mp3").exists() # never started m3u = (plan.dest_dir / "Mix.m3u").read_text(encoding="utf-8") assert "X - A.mp3" in m3u assert "X - B.mp3" not in m3u and "X - C.mp3" not in m3u def test_mid_file_cancel_removes_partial(self, qapp, tmp_path): big = _audio(tmp_path, "big.mp3", b"z" * (3 * CHUNK)) plan = plan_sync("Mix", [_track(1, "Big", "X", big)], tmp_path / "Music") worker, results = _run(plan, cancel_on_emit=1) # after first chunk assert results["cancelled"]["copied"] == 0 assert not (plan.dest_dir / "X - Big.mp3").exists() def test_cancel_signal_is_exclusive(self, qapp, tmp_path): a = _audio(tmp_path, "a.mp3") plan = plan_sync("Mix", [_track(1, "A", "X", a)], tmp_path / "Music") worker, results = _run(plan, cancel_on_emit=1) assert "cancelled" in results assert "finished" not in results and "error" not in results def test_m3u_stays_coherent_after_stale_deletion(self, qapp, tmp_path): # Old m3u references a stale file; cancel lands after stale deletion # but before the new copy finishes — the rewritten m3u must reference # neither the deleted stale file nor the never-copied track. kept = _audio(tmp_path, "kept.mp3", b"k" * 40) new = _audio(tmp_path, "new.mp3", b"n" * 60) dest = tmp_path / "Music" / "Mix" dest.mkdir(parents=True) (dest / "X - Kept.mp3").write_bytes(b"k" * 40) (dest / "Gone - Old.mp3").write_bytes(b"g" * 30) (dest / "Mix.m3u").write_text( "#EXTM3U\n#EXTINF:1,Gone - Old\nGone - Old.mp3\n" "#EXTINF:1,X - Kept\nX - Kept.mp3\n") tracks = [_track(1, "Kept", "X", kept), _track(2, "New", "X", new)] plan = plan_sync("Mix", tracks, tmp_path / "Music") worker, results = _run(plan, cancel_on_emit=1) # during "New" copy assert not (dest / "Gone - Old.mp3").exists() m3u = (dest / "Mix.m3u").read_text(encoding="utf-8") assert "X - Kept.mp3" in m3u assert "Gone - Old.mp3" not in m3u and "X - New.mp3" not in m3u def test_cancelled_sync_recovers_to_identical_state(self, qapp, tmp_path): # trav's scenario: full sync, playlist changes, re-sync cancelled # midway, then a third sync — must equal an uninterrupted sync. t1 = _track(1, "One", "X", _audio(tmp_path, "1.mp3", b"1" * 80)) t2 = _track(2, "Two", "X", _audio(tmp_path, "2.mp3", b"2" * 80)) t3 = _track(3, "Three", "X", _audio(tmp_path, "3.mp3", b"3" * 80)) t4 = _track(4, "Four", "X", _audio(tmp_path, "4.mp3", b"4" * 80)) music = tmp_path / "Music" _run(plan_sync("Mix", [t1, t2, t3], music)) # initial sync final = [t1, t3, t4] # -Two, +Four _run(plan_sync("Mix", final, music), cancel_on_emit=1) # cancelled worker, results = _run(plan_sync("Mix", final, music)) # recovery assert "finished" in results reference = tmp_path / "Reference" _run(plan_sync("Mix", final, reference)) # clean baseline got = {p.name: p.read_bytes() for p in (music / "Mix").iterdir()} want = {p.name: p.read_bytes() for p in (reference / "Mix").iterdir()} assert got == want def test_cancel_after_run_is_noop(self, qapp, tmp_path): a = _audio(tmp_path, "a.mp3") plan = plan_sync("Mix", [_track(1, "A", "X", a)], tmp_path / "Music") worker, results = _run(plan) assert "finished" in results worker.cancel() # after the fact: no crash, no state change assert (plan.dest_dir / "X - A.mp3").exists()