Files
lintunes/tests/test_round28.py
T
travandClaude Fable 5 b6b6dc01f7 v0.3.0: cancelable device sync
A "✕" button sits left of the sync progress group and expands to
"cancel transfer" on hover; clicking asks Cancel Transfer / Keep
Copying (the copy keeps running under the dialog). Cancel stops the
worker at the next chunk boundary, removes the in-flight partial file,
and rewrites the m3u to list only tracks actually on the device — so a
cancelled sync always leaves a coherent partial playlist. That last bit
also closes a pre-existing gap: stale files are deleted before copying,
so an interruption could previously leave the old m3u pointing at
deleted files.

Recoverability proven both ways: a test asserts cancel-then-resync ends
byte-identical to an uninterrupted sync, and a live run against the
real Rabbit (cancel mid-file-2 of 3) recovered with kept=1/copies=2.

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

154 lines
6.9 KiB
Python

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