soulsync/tests/downloads/test_downloads_cancel.py
dev e594ac9799 fix(downloads): keep terminal tasks visible during active batch + concurrent pool
Three fixes from on-device testing of best-quality mode:

1. clear_completed_local no longer prunes terminal tasks that belong to a
   STILL-ACTIVE batch (one with non-terminal work remaining). The 5-min
   "Clean Completed Downloads" automation was yanking completed/failed/
   unverified rows out of download_tasks mid-run — and failed/cancelled
   aren't in library_history — so they only reappeared after the batch
   ended. Now the whole active batch stays intact until it finishes.

2. search_all_sources runs every source CONCURRENTLY (asyncio.gather)
   instead of sequentially, so the pool waits only for the slowest source
   (e.g. usenet/Prowlarr) in parallel rather than summing all latencies.

3. The pool log now reports per-source contribution counts
   (e.g. "usenet=0, hifi=11, soulseek=1") instead of just echoing the
   chain, so a release-level source that returns nothing for a track-title
   query is visible rather than appearing to have been searched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 20:08:56 +02:00

226 lines
7.8 KiB
Python

"""Tests for core/downloads/cancel.py — slskd cancel + clear + local task pruning."""
from __future__ import annotations
import pytest
from core.downloads import cancel
from core.runtime_state import (
batch_locks,
download_batches,
download_tasks,
)
@pytest.fixture(autouse=True)
def reset_state():
"""Each test gets clean download_tasks / download_batches / batch_locks."""
download_tasks.clear()
download_batches.clear()
batch_locks.clear()
yield
download_tasks.clear()
download_batches.clear()
batch_locks.clear()
# ---------------------------------------------------------------------------
# Fakes
# ---------------------------------------------------------------------------
class _FakeSoulseek:
def __init__(self, cancel_result=True, cancel_all_result=True, clear_result=True):
self._cancel_result = cancel_result
self._cancel_all_result = cancel_all_result
self._clear_result = clear_result
self.cancel_calls = []
self.cancel_all_calls = 0
self.clear_calls = 0
async def cancel_download(self, download_id, username, remove=False):
self.cancel_calls.append((download_id, username, remove))
return self._cancel_result
async def cancel_all_downloads(self):
self.cancel_all_calls += 1
return self._cancel_all_result
async def clear_all_completed_downloads(self):
self.clear_calls += 1
return self._clear_result
def _sync_run_async(coro):
"""Drain a coroutine on a fresh loop."""
import asyncio
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
loop.close()
# ---------------------------------------------------------------------------
# cancel_single_download
# ---------------------------------------------------------------------------
def test_cancel_single_passes_args_with_remove_true():
sk = _FakeSoulseek()
result = cancel.cancel_single_download(sk, _sync_run_async, 'dl-123', 'user-x')
assert result is True
assert sk.cancel_calls == [('dl-123', 'user-x', True)]
def test_cancel_single_propagates_failure():
sk = _FakeSoulseek(cancel_result=False)
result = cancel.cancel_single_download(sk, _sync_run_async, 'dl', 'u')
assert result is False
# ---------------------------------------------------------------------------
# cancel_all_active
# ---------------------------------------------------------------------------
def test_cancel_all_happy_path():
sk = _FakeSoulseek()
sweeps = []
success, msg = cancel.cancel_all_active(sk, _sync_run_async, lambda: sweeps.append(1))
assert success is True
assert msg == "All downloads cancelled and cleared."
assert sk.cancel_all_calls == 1
assert sk.clear_calls == 1
assert sweeps == [1]
def test_cancel_all_returns_failure_if_cancel_step_fails():
sk = _FakeSoulseek(cancel_all_result=False)
sweeps = []
success, msg = cancel.cancel_all_active(sk, _sync_run_async, lambda: sweeps.append(1))
assert success is False
assert msg == "Failed to cancel active downloads."
# Clear/sweep should NOT run when cancel fails
assert sk.clear_calls == 0
assert sweeps == []
def test_cancel_all_runs_sweep_even_if_clear_returns_false():
"""Clear returning False is not a hard error — sweep still runs (matches original)."""
sk = _FakeSoulseek(clear_result=False)
sweeps = []
success, msg = cancel.cancel_all_active(sk, _sync_run_async, lambda: sweeps.append(1))
assert success is True
assert sweeps == [1]
# ---------------------------------------------------------------------------
# clear_finished_active
# ---------------------------------------------------------------------------
def test_clear_finished_happy_path_calls_sweep():
sk = _FakeSoulseek()
sweeps = []
success = cancel.clear_finished_active(sk, _sync_run_async, lambda: sweeps.append(1))
assert success is True
assert sk.clear_calls == 1
assert sweeps == [1]
def test_clear_finished_failure_skips_sweep():
sk = _FakeSoulseek(clear_result=False)
sweeps = []
success = cancel.clear_finished_active(sk, _sync_run_async, lambda: sweeps.append(1))
assert success is False
assert sweeps == []
# ---------------------------------------------------------------------------
# clear_completed_local
# ---------------------------------------------------------------------------
def test_clear_completed_removes_terminal_tasks():
download_tasks['t1'] = {'status': 'completed'}
download_tasks['t2'] = {'status': 'failed'}
download_tasks['t3'] = {'status': 'downloading'} # still active
download_tasks['t4'] = {'status': 'cancelled'}
download_tasks['t5'] = {'status': 'not_found'}
download_tasks['t6'] = {'status': 'skipped'}
download_tasks['t7'] = {'status': 'already_owned'}
cleared = cancel.clear_completed_local()
assert cleared == 6
assert set(download_tasks.keys()) == {'t3'}
def test_clear_completed_keeps_searching_and_queued():
"""Active states ('searching', 'queued', 'downloading', 'pending') stay."""
download_tasks['t1'] = {'status': 'searching'}
download_tasks['t2'] = {'status': 'queued'}
download_tasks['t3'] = {'status': 'downloading'}
download_tasks['t4'] = {'status': 'pending'}
cleared = cancel.clear_completed_local()
assert cleared == 0
assert set(download_tasks.keys()) == {'t1', 't2', 't3', 't4'}
def test_clear_completed_drops_empty_batches():
download_tasks['t1'] = {'status': 'completed'}
download_batches['b1'] = {'queue': ['t1']} # all tasks will be cleared
download_batches['b2'] = {'queue': ['t2']} # t2 doesn't exist either
download_tasks['t3'] = {'status': 'downloading'}
download_batches['b3'] = {'queue': ['t3']} # t3 stays
cancel.clear_completed_local()
assert 'b1' not in download_batches
assert 'b2' not in download_batches
assert 'b3' in download_batches
assert download_batches['b3']['queue'] == ['t3']
def test_clear_completed_keeps_terminal_tasks_in_active_batch():
"""A completed/failed task in a batch that still has active work is KEPT, so
the Downloads page shows it for the whole run (the 5-min Clean Completed
automation must not yank terminal tasks out from under a live batch)."""
download_tasks['t1'] = {'status': 'completed'}
download_tasks['t2'] = {'status': 'downloading'} # batch still active
download_batches['b1'] = {'queue': ['t1', 't2']}
cleared = cancel.clear_completed_local()
assert cleared == 0 # nothing removed — batch is live
assert 'b1' in download_batches
assert download_batches['b1']['queue'] == ['t1', 't2'] # queue intact
def test_clear_completed_clears_terminal_tasks_once_batch_is_finished():
"""When every task in a batch is terminal, the batch is done — its tasks are
cleared and the empty batch dropped."""
download_tasks['t1'] = {'status': 'completed'}
download_tasks['t2'] = {'status': 'failed'}
download_batches['b1'] = {'queue': ['t1', 't2']}
cleared = cancel.clear_completed_local()
assert cleared == 2
assert 'b1' not in download_batches
def test_clear_completed_drops_batch_locks_for_deleted_batches():
import threading
download_tasks['t1'] = {'status': 'completed'}
download_batches['b1'] = {'queue': ['t1']}
batch_locks['b1'] = threading.Lock()
cancel.clear_completed_local()
assert 'b1' not in batch_locks
def test_clear_completed_keeps_batch_locks_for_surviving_batches():
import threading
download_tasks['t1'] = {'status': 'downloading'}
download_batches['b1'] = {'queue': ['t1']}
batch_locks['b1'] = threading.Lock()
cancel.clear_completed_local()
assert 'b1' in batch_locks
def test_clear_completed_returns_zero_on_empty_state():
assert cancel.clear_completed_local() == 0