# -*- coding: utf-8 -*- """aria2 下载后端的单元测试(用假 api 驱动,不需要真实 aria2c 二进制)。 覆盖: - aria2c 缺失时 get_backend 静默降级且失败结果被记忆 - 正常完成路径的进度回调与自动清理 - 取消 / 体积超限 连带删除残片;任务失败 / 总超时 保留残片供续传 - 提交失败与 RPC 抖动统一上报为可重试异常 """ from __future__ import annotations import threading import pytest import music_downloader._aria2 as a2 from core.sync_http import RequestException from music_downloader import DownloadCanceled, DownloadException class _FakeDownload: """按预设步进产出状态,模拟 aria2 的 tellStatus 轮询。""" def __init__(self, steps): self.gid = 'g1' self._steps = list(steps) self._i = 0 self.completed_length = 0 self.total_length = 100 self.is_complete = False self.has_failed = False self.error_message = '' self.status = 'active' def update(self): idx = min(self._i, len(self._steps) - 1) done, complete, failed = self._steps[idx] self._i += 1 self.completed_length = done self.is_complete = complete self.has_failed = failed if failed: self.status = 'error' self.error_message = 'boom' class _FakeAPI: def __init__(self, download, fail_submit=False, fail_update=False): self.download = download self.removed: list[tuple[str, bool, bool]] = [] self.submitted: tuple[list[str], dict[str, str]] | None = None self._fail_submit = fail_submit self._fail_update = fail_update def add_uris(self, uris, options=None): if self._fail_submit: raise RuntimeError('rpc down') self.submitted = (list(uris), options or {}) return self.download def remove(self, downloads, force=False, files=False): self.removed.append((downloads[0].gid, force, files)) class _FailUpdateDownload(_FakeDownload): def update(self): raise RuntimeError('rpc jitter') class _FakeProc: def poll(self): return 0 def terminate(self): pass def kill(self): pass def wait(self, timeout=None): return 0 def _backend(monkeypatch, steps, **kw): monkeypatch.setattr(a2.time, 'sleep', lambda _s: None) dl = _FailUpdateDownload([]) if kw.get('fail_update') else _FakeDownload(steps) api = _FakeAPI(dl, fail_submit=kw.get('fail_submit', False)) return a2.Aria2Backend(api, _FakeProc(), 6800), api # ---------- 降级 ---------- def test_get_backend_degrades_without_binary(monkeypatch): monkeypatch.setattr(a2.shutil, 'which', lambda _n: None) monkeypatch.setattr(a2, '_backend', None) monkeypatch.setattr(a2, '_unavailable', False) assert a2.get_backend() is None assert a2.get_backend() is None # 失败结果被记忆,不反复拉起进程 def test_free_port_is_in_valid_range(): assert 0 < a2._free_port() < 65536 # ---------- 完成路径 ---------- def test_fetch_reports_progress_and_completes(monkeypatch, tmp_path): be, api = _backend(monkeypatch, [(50, False, False), (100, True, False)]) seen = [] be.fetch('http://x/y.mp3', tmp_path / 'y.part', progress_cb=lambda done, total: seen.append((done, total))) assert seen == [(50, 100), (100, 100)] assert api.removed == [] # 完成不需要中止 assert not (tmp_path / 'y.part.aria2').exists() # 控制文件已兜底清理 submitted = api.submitted assert submitted is not None options = submitted[1] assert options['continue'] == 'true' # 续传选项已下发 assert options['max-connection-per-server'] == str(a2._SPLIT) def test_progress_callback_exception_is_swallowed(monkeypatch, tmp_path): be, _api = _backend(monkeypatch, [(100, True, False)]) def boom(_d, _t): raise RuntimeError('cb') be.fetch('http://x/y.mp3', tmp_path / 'y.part', progress_cb=boom) # 不应抛出 # ---------- 取消 / 守卫 / 失败 ---------- def test_fetch_maps_cancel_and_drops_partial(monkeypatch, tmp_path): be, api = _backend(monkeypatch, [(10, False, False)]) ev = threading.Event() ev.set() with pytest.raises(DownloadCanceled): be.fetch('http://x/y.mp3', tmp_path / 'y.part', cancel_event=ev) assert api.removed == [('g1', True, True)] def test_fetch_guards_size_and_drops_partial(monkeypatch, tmp_path): be, api = _backend(monkeypatch, [(700, False, False)]) with pytest.raises(DownloadException): be.fetch('http://x/y.mp3', tmp_path / 'y.part', max_bytes=500) assert api.removed == [('g1', True, True)] def test_fetch_failure_is_retryable_and_keeps_partial(monkeypatch, tmp_path): be, api = _backend(monkeypatch, [(10, False, True)]) with pytest.raises(RequestException): be.fetch('http://x/y.mp3', tmp_path / 'y.part') assert api.removed == [] # 残片保留,供下轮续传 def test_fetch_timeout_is_retryable(monkeypatch, tmp_path): be, api = _backend(monkeypatch, [(10, False, False)]) # 永不完成 with pytest.raises(RequestException): be.fetch('http://x/y.mp3', tmp_path / 'y.part', timeout=-1) assert api.removed == [('g1', True, False)] # 超时中止但保留残片 def test_fetch_submit_failure_is_retryable(monkeypatch, tmp_path): be, _api = _backend(monkeypatch, [], fail_submit=True) with pytest.raises(RequestException): be.fetch('http://x/y.mp3', tmp_path / 'y.part') def test_fetch_update_failure_is_retryable(monkeypatch, tmp_path): be, _api = _backend(monkeypatch, [], fail_update=True) with pytest.raises(RequestException): be.fetch('http://x/y.mp3', tmp_path / 'y.part') def test_close_is_idempotent(monkeypatch): be, _api = _backend(monkeypatch, []) be.close() be.close()