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>
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user