"""Tests for core/downloads/status.py — batch + unified status helpers.""" from __future__ import annotations import pytest from core.downloads import status as st from core.runtime_state import ( download_batches, download_tasks, ) @pytest.fixture(autouse=True) def reset_state(): download_tasks.clear() download_batches.clear() yield download_tasks.clear() download_batches.clear() # --------------------------------------------------------------------------- # Fakes # --------------------------------------------------------------------------- class _FakeConfig: def __init__(self, values=None): self._v = values or {} def get(self, key, default=None): return self._v.get(key, default) def _build_deps( *, config=None, docker_resolve=None, find_completed=None, make_key=None, submit_pp=None, cached_transfers=None, download_orchestrator=None, run_async=None, persistent_history=None, ): submitted = [] def _default_submit(task_id, batch_id): submitted.append((task_id, batch_id)) deps = st.StatusDeps( config_manager=config or _FakeConfig({'soulseek.download_timeout': 600}), docker_resolve_path=docker_resolve or (lambda p: p), find_completed_file=find_completed or (lambda *a, **kw: (None, None)), make_context_key=make_key or (lambda u, f: f"{u}::{f}"), submit_post_processing=submit_pp or _default_submit, get_cached_transfer_data=cached_transfers or (lambda: {}), download_orchestrator=download_orchestrator, run_async=run_async, get_persistent_download_history=persistent_history, ) return deps, submitted # --------------------------------------------------------------------------- # build_batch_status_data — phase routing # --------------------------------------------------------------------------- def test_unknown_phase_includes_basic_fields_only(): deps, _ = _build_deps() batch = {'phase': 'unknown', 'playlist_id': 'pl1', 'playlist_name': 'My PL'} out = st.build_batch_status_data('b1', batch, {}, deps) assert out['phase'] == 'unknown' assert out['playlist_id'] == 'pl1' assert out['playlist_name'] == 'My PL' assert 'tasks' not in out assert 'analysis_progress' not in out def test_analysis_phase_includes_analysis_progress_and_results(): deps, _ = _build_deps() batch = { 'phase': 'analysis', 'analysis_total': 10, 'analysis_processed': 4, 'analysis_results': [{'track_index': 0, 'found': True}], } out = st.build_batch_status_data('b1', batch, {}, deps) assert out['analysis_progress'] == {'total': 10, 'processed': 4} assert out['analysis_results'] == [{'track_index': 0, 'found': True}] def test_queued_phase_surfaces_analysis_progress_for_ui_count(): """A batch in ``queued`` state hasn't been picked up by the executor yet, so analysis_processed is 0. The UI still needs ``analysis_total`` so it can render "Queued — N tracks" instead of showing an empty card. Pre-fix the queued phase fell through to the default branch and the UI lost the track count entirely.""" deps, _ = _build_deps() batch = { 'phase': 'queued', 'analysis_total': 17, 'analysis_processed': 0, 'analysis_results': [], } out = st.build_batch_status_data('b1', batch, {}, deps) assert out['phase'] == 'queued' assert out['analysis_progress'] == {'total': 17, 'processed': 0} assert out['analysis_results'] == [] def test_album_downloading_phase_exposes_bundle_progress_without_task_safety_valve(): deps, _ = _build_deps(config=_FakeConfig({'soulseek.download_timeout': 1})) download_tasks['t1'] = { 'track_index': 0, 'status': 'queued', 'track_info': {}, 'status_change_time': 0, } batch = { 'phase': 'album_downloading', 'queue': ['t1'], 'album_bundle_state': 'downloading', 'album_bundle_source': 'torrent', 'album_bundle_release': 'GNX [FLAC]', 'album_bundle_progress': 0.42, 'album_bundle_speed': 2048, 'album_bundle_size': 4096, } out = st.build_batch_status_data('b1', batch, {}, deps) assert out['album_bundle'] == { 'state': 'downloading', 'source': 'torrent', 'release': 'GNX [FLAC]', 'progress': 0.42, 'progress_percent': 42, 'speed': 2048, 'size': 4096, } assert 'tasks' not in out assert download_tasks['t1']['status'] == 'queued' def test_complete_phase_includes_wishlist_summary_when_present(): deps, _ = _build_deps() batch = { 'phase': 'complete', 'queue': [], 'wishlist_summary': {'added': 3, 'failed': 0}, } out = st.build_batch_status_data('b1', batch, {}, deps) assert out['wishlist_summary'] == {'added': 3, 'failed': 0} def test_complete_phase_omits_wishlist_summary_when_missing(): deps, _ = _build_deps() batch = {'phase': 'complete', 'queue': []} out = st.build_batch_status_data('b1', batch, {}, deps) assert 'wishlist_summary' not in out # --------------------------------------------------------------------------- # Task formatting # --------------------------------------------------------------------------- def test_downloading_phase_includes_active_count_and_max_concurrent(): deps, _ = _build_deps() batch = {'phase': 'downloading', 'queue': [], 'active_count': 2, 'max_concurrent': 5} out = st.build_batch_status_data('b1', batch, {}, deps) assert out['active_count'] == 2 assert out['max_concurrent'] == 5 def test_tasks_sorted_by_track_index(): deps, _ = _build_deps() download_tasks['t1'] = {'track_index': 2, 'status': 'queued', 'track_info': {}} download_tasks['t2'] = {'track_index': 0, 'status': 'queued', 'track_info': {}} download_tasks['t3'] = {'track_index': 1, 'status': 'queued', 'track_info': {}} batch = {'phase': 'downloading', 'queue': ['t1', 't2', 't3']} out = st.build_batch_status_data('b1', batch, {}, deps) assert [t['track_index'] for t in out['tasks']] == [0, 1, 2] def test_missing_task_in_queue_is_skipped(): deps, _ = _build_deps() download_tasks['t1'] = {'track_index': 0, 'status': 'queued', 'track_info': {}} batch = {'phase': 'downloading', 'queue': ['t1', 'absent']} out = st.build_batch_status_data('b1', batch, {}, deps) assert len(out['tasks']) == 1 def test_task_status_includes_v2_state_fields(): deps, _ = _build_deps() download_tasks['t1'] = { 'track_index': 0, 'status': 'cancelling', 'track_info': {}, 'cancel_requested': True, 'cancel_timestamp': 12345, 'ui_state': 'cancelling', 'playlist_id': 'pl1', 'error_message': 'oh no', 'cached_candidates': [{'x': 1}], 'quarantine_entry_id': '20260514_120000_song', } batch = {'phase': 'downloading', 'queue': ['t1']} out = st.build_batch_status_data('b1', batch, {}, deps) t = out['tasks'][0] assert t['cancel_requested'] is True assert t['cancel_timestamp'] == 12345 assert t['ui_state'] == 'cancelling' assert t['playlist_id'] == 'pl1' assert t['error_message'] == 'oh no' assert t['quarantine_entry_id'] == '20260514_120000_song' assert t['has_candidates'] is True # --------------------------------------------------------------------------- # Live transfer state mapping # --------------------------------------------------------------------------- def test_live_state_cancelled_maps_to_cancelled(): deps, _ = _build_deps() download_tasks['t1'] = { 'track_index': 0, 'status': 'downloading', 'track_info': {}, 'filename': 'song.flac', 'username': 'u1', } live = {'u1::song.flac': {'state': 'Cancelled', 'percentComplete': 50}} batch = {'phase': 'downloading', 'queue': ['t1']} out = st.build_batch_status_data('b1', batch, live, deps) assert out['tasks'][0]['status'] == 'cancelled' assert download_tasks['t1']['status'] == 'cancelled' # mutates source state def test_live_state_succeeded_with_full_bytes_marks_post_processing_and_submits(): deps, submitted = _build_deps() download_tasks['t1'] = { 'track_index': 0, 'status': 'downloading', 'track_info': {}, 'filename': 'song.flac', 'username': 'u1', } live = {'u1::song.flac': { 'state': 'Succeeded', 'size': 100, 'bytesTransferred': 100, 'percentComplete': 100, }} batch = {'phase': 'downloading', 'queue': ['t1']} out = st.build_batch_status_data('b1', batch, live, deps) assert out['tasks'][0]['status'] == 'post_processing' assert download_tasks['t1']['status'] == 'post_processing' assert submitted == [('t1', 'b1')] def test_live_state_succeeded_with_byte_mismatch_keeps_status(): deps, submitted = _build_deps() download_tasks['t1'] = { 'track_index': 0, 'status': 'downloading', 'track_info': {}, 'filename': 'song.flac', 'username': 'u1', } live = {'u1::song.flac': { 'state': 'Succeeded', 'size': 100, 'bytesTransferred': 50, }} batch = {'phase': 'downloading', 'queue': ['t1']} out = st.build_batch_status_data('b1', batch, live, deps) assert out['tasks'][0]['status'] == 'downloading' # not promoted to post_processing assert submitted == [] # not submitted def test_live_state_inprogress_maps_to_downloading(): deps, _ = _build_deps() download_tasks['t1'] = { 'track_index': 0, 'status': 'queued', 'track_info': {}, 'filename': 'song.flac', 'username': 'u1', } live = {'u1::song.flac': {'state': 'InProgress', 'percentComplete': 42}} batch = {'phase': 'downloading', 'queue': ['t1']} out = st.build_batch_status_data('b1', batch, live, deps) assert out['tasks'][0]['status'] == 'downloading' assert out['tasks'][0]['progress'] == 42 def test_live_state_errored_keeps_active_status_for_monitor(): deps, _ = _build_deps() download_tasks['t1'] = { 'track_index': 0, 'status': 'downloading', 'track_info': {}, 'filename': 'song.flac', 'username': 'u1', } live = {'u1::song.flac': {'state': 'Errored'}} batch = {'phase': 'downloading', 'queue': ['t1']} out = st.build_batch_status_data('b1', batch, live, deps) # Keeps current status so monitor handles retry assert out['tasks'][0]['status'] == 'downloading' assert download_tasks['t1']['status'] == 'downloading' # not marked failed def test_terminal_status_not_overridden_by_live_state(): deps, _ = _build_deps() download_tasks['t1'] = { 'track_index': 0, 'status': 'completed', 'track_info': {}, 'filename': 'song.flac', 'username': 'u1', } live = {'u1::song.flac': {'state': 'InProgress', 'percentComplete': 50}} batch = {'phase': 'downloading', 'queue': ['t1']} out = st.build_batch_status_data('b1', batch, live, deps) assert out['tasks'][0]['status'] == 'completed' assert out['tasks'][0]['progress'] == 100 def test_post_processing_status_progress_is_95(): deps, _ = _build_deps() download_tasks['t1'] = { 'track_index': 0, 'status': 'post_processing', 'track_info': {}, 'filename': 'song.flac', 'username': 'u1', } live = {} # no live entry batch = {'phase': 'downloading', 'queue': ['t1']} out = st.build_batch_status_data('b1', batch, live, deps) assert out['tasks'][0]['progress'] == 95 def test_auto_torrent_without_live_entry_uses_engine_success_fallback(): class _Record: state = 'Completed, Succeeded' progress = 100 class _Orchestrator: def get_download_status(self, download_id): assert download_id == 'dl1' return _Record() deps, submitted = _build_deps( download_orchestrator=_Orchestrator(), run_async=lambda value: value, ) download_tasks['t1'] = { 'track_index': 0, 'status': 'downloading', 'track_info': {}, 'filename': 'song.flac', 'username': 'torrent', 'download_id': 'dl1', } batch = {'phase': 'downloading', 'queue': ['t1']} out = st.build_batch_status_data('b1', batch, {}, deps) assert out['tasks'][0]['status'] == 'post_processing' assert download_tasks['t1']['status'] == 'post_processing' assert submitted == [('t1', 'b1')] def test_auto_torrent_prefers_engine_success_over_live_entry(): class _Record: state = 'Completed, Succeeded' progress = 100 class _Orchestrator: def get_download_status(self, download_id): assert download_id == 'dl1' return _Record() deps, submitted = _build_deps( download_orchestrator=_Orchestrator(), run_async=lambda value: value, ) download_tasks['t1'] = { 'track_index': 0, 'status': 'downloading', 'track_info': {}, 'filename': 'song.flac', 'username': 'torrent', 'download_id': 'dl1', } live = {'torrent::song.flac': { 'state': 'InProgress, Downloading', 'percentComplete': 100, }} batch = {'phase': 'downloading', 'queue': ['t1']} out = st.build_batch_status_data('b1', batch, live, deps) assert out['tasks'][0]['status'] == 'post_processing' assert download_tasks['t1']['status'] == 'post_processing' assert submitted == [('t1', 'b1')] def test_auto_torrent_engine_failure_does_not_bypass_monitor_retry(): class _Record: state = 'Completed, Errored' progress = 100 error = 'client failed' class _Orchestrator: def get_download_status(self, download_id): assert download_id == 'dl1' return _Record() deps, submitted = _build_deps( download_orchestrator=_Orchestrator(), run_async=lambda value: value, ) download_tasks['t1'] = { 'track_index': 0, 'status': 'downloading', 'track_info': {}, 'filename': 'song.flac', 'username': 'torrent', 'download_id': 'dl1', } batch = {'phase': 'downloading', 'queue': ['t1']} out = st.build_batch_status_data('b1', batch, {}, deps) assert out['tasks'][0]['status'] == 'downloading' assert download_tasks['t1']['status'] == 'downloading' assert submitted == [] def test_auto_torrent_engine_success_recovers_premature_failed_task(): class _Record: state = 'Completed, Succeeded' progress = 100 class _Orchestrator: def get_download_status(self, download_id): assert download_id == 'dl1' return _Record() deps, submitted = _build_deps( download_orchestrator=_Orchestrator(), run_async=lambda value: value, ) download_tasks['t1'] = { 'track_index': 0, 'status': 'failed', 'track_info': {}, 'filename': 'release-url||Release', 'username': 'torrent', 'download_id': 'dl1', 'error_message': 'premature failure', } batch = {'phase': 'downloading', 'queue': ['t1']} out = st.build_batch_status_data('b1', batch, {}, deps) assert out['tasks'][0]['status'] == 'post_processing' assert download_tasks['t1']['status'] == 'post_processing' assert submitted == [('t1', 'b1')] # --------------------------------------------------------------------------- # Safety valve (stuck task handling) # --------------------------------------------------------------------------- def test_safety_valve_stuck_searching_marks_not_found(): deps, _ = _build_deps(config=_FakeConfig({'soulseek.download_timeout': 1})) download_tasks['t1'] = { 'track_index': 0, 'status': 'searching', 'track_info': {}, 'status_change_time': 0, # ancient } batch = {'phase': 'downloading', 'queue': ['t1']} st.build_batch_status_data('b1', batch, {}, deps) assert download_tasks['t1']['status'] == 'not_found' assert 'Search stuck' in download_tasks['t1']['error_message'] def test_safety_valve_stuck_downloading_with_recovered_file_routes_to_post_processing(): deps, submitted = _build_deps( config=_FakeConfig({'soulseek.download_timeout': 1, 'soulseek.download_path': '/d', 'soulseek.transfer_path': '/t'}), find_completed=lambda *a, **kw: ('/found.flac', 'transfer'), ) download_tasks['t1'] = { 'track_index': 0, 'status': 'downloading', 'track_info': {}, 'filename': 'song.flac', 'status_change_time': 0, } batch = {'phase': 'downloading', 'queue': ['t1']} st.build_batch_status_data('b1', batch, {}, deps) assert download_tasks['t1']['status'] == 'post_processing' assert submitted == [('t1', 'b1')] def test_safety_valve_stuck_downloading_no_file_marks_failed(): deps, _ = _build_deps(config=_FakeConfig({'soulseek.download_timeout': 1})) download_tasks['t1'] = { 'track_index': 0, 'status': 'downloading', 'track_info': {}, 'filename': 'song.flac', 'status_change_time': 0, } batch = {'phase': 'downloading', 'queue': ['t1']} st.build_batch_status_data('b1', batch, {}, deps) assert download_tasks['t1']['status'] == 'failed' assert 'Task stuck' in download_tasks['t1']['error_message'] # --------------------------------------------------------------------------- # build_single_batch_status (route helper) # --------------------------------------------------------------------------- def test_single_batch_status_404_for_unknown_batch(): deps, _ = _build_deps() body, status = st.build_single_batch_status('absent', deps) assert status == 404 assert body['error'] == 'Batch not found' def test_single_batch_status_returns_payload_for_known_batch(): deps, _ = _build_deps() download_batches['b1'] = {'phase': 'unknown', 'playlist_id': 'pl1'} body, status = st.build_single_batch_status('b1', deps) assert status == 200 assert body['phase'] == 'unknown' assert body['playlist_id'] == 'pl1' # --------------------------------------------------------------------------- # build_batched_status (route helper) # --------------------------------------------------------------------------- def test_batched_status_filters_to_requested_ids(): deps, _ = _build_deps() download_batches['b1'] = {'phase': 'unknown'} download_batches['b2'] = {'phase': 'unknown'} download_batches['b3'] = {'phase': 'unknown'} out = st.build_batched_status(['b1', 'b3'], deps) assert set(out['batches'].keys()) == {'b1', 'b3'} def test_batched_status_no_filter_returns_all_batches(): deps, _ = _build_deps() download_batches['b1'] = {'phase': 'unknown'} download_batches['b2'] = {'phase': 'unknown'} out = st.build_batched_status([], deps) assert set(out['batches'].keys()) == {'b1', 'b2'} def test_unified_downloads_response_includes_album_bundle_summary(): deps, _ = _build_deps() download_batches['b1'] = { 'phase': 'album_downloading', 'playlist_id': 'pl1', 'playlist_name': 'GNX', 'queue': [], 'album_bundle_state': 'downloading', 'album_bundle_source': 'torrent', 'album_bundle_progress': 75, } out = st.build_unified_downloads_response(300, deps) assert out['batches'][0]['album_bundle'] == { 'state': 'downloading', 'source': 'torrent', 'progress': 75, 'progress_percent': 75, } def test_batched_status_metadata_present(): deps, _ = _build_deps() download_batches['b1'] = {'phase': 'unknown'} out = st.build_batched_status([], deps) assert out['metadata']['total_batches'] == 1 assert out['metadata']['requested_batch_ids'] == [] assert isinstance(out['metadata']['timestamp'], (int, float)) def test_batched_status_per_batch_failure_isolated(): deps, _ = _build_deps() # b1 valid, b2 raises download_batches['b1'] = {'phase': 'downloading', 'queue': []} class _BadBatch(dict): def get(self, k, default=None): if k == 'phase': raise RuntimeError("batch boom") return super().get(k, default) download_batches['b2'] = _BadBatch() out = st.build_batched_status([], deps) assert 'error' in out['batches']['b2'] assert out['batches']['b1'].get('phase') == 'downloading' def test_batched_status_debug_info_pre_existing_bug_never_populates(): """Documents a pre-existing bug: every batch payload includes `"error": batch.get('error')` (key always present, value usually None). The debug_info loop checks `if "error" not in batch_status:` which is therefore always False → debug_info stays empty in production. Lift preserves this exactly. A future PR can flip the check to `if batch_status.get('error') is None` to fix it. """ deps, _ = _build_deps() download_tasks['t1'] = {'track_index': 0, 'status': 'downloading', 'track_info': {}} download_tasks['t2'] = {'track_index': 1, 'status': 'downloading', 'track_info': {}} download_batches['b1'] = { 'phase': 'downloading', 'queue': ['t1', 't2'], 'active_count': 5, 'max_concurrent': 5, } out = st.build_batched_status([], deps) # Bug: stays empty because "error" key is always present in payload assert out['debug_info'] == {} # --------------------------------------------------------------------------- # build_unified_downloads_response # --------------------------------------------------------------------------- def test_unified_response_sorts_by_priority_then_recency(): deps, _ = _build_deps() download_tasks['old_complete'] = { 'track_index': 0, 'status': 'completed', 'track_info': {'name': 'A'}, 'status_change_time': 100, } download_tasks['new_active'] = { 'track_index': 1, 'status': 'downloading', 'track_info': {'name': 'B'}, 'status_change_time': 50, } out = st.build_unified_downloads_response(100, deps) # Active downloads come first regardless of timestamp assert out['downloads'][0]['title'] == 'B' def test_unified_response_artist_list_of_dicts_normalized(): deps, _ = _build_deps() download_tasks['t1'] = { 'track_index': 0, 'status': 'queued', 'track_info': {'name': 'X', 'artists': [{'name': 'Pink Floyd'}, {'name': 'Roger'}]}, } out = st.build_unified_downloads_response(100, deps) assert out['downloads'][0]['artist'] == 'Pink Floyd, Roger' def test_unified_response_album_dict_extracted_to_name(): deps, _ = _build_deps() download_tasks['t1'] = { 'track_index': 0, 'status': 'queued', 'track_info': {'name': 'X', 'album': {'name': 'DSOTM', 'images': [{'url': 'http://thumb.jpg'}]}}, } out = st.build_unified_downloads_response(100, deps) assert out['downloads'][0]['album'] == 'DSOTM' assert out['downloads'][0]['artwork'] == 'http://thumb.jpg' def test_unified_response_completed_task_progress_is_100(): deps, _ = _build_deps() download_tasks['t1'] = { 'track_index': 0, 'status': 'completed', 'track_info': {'name': 'X'}, } out = st.build_unified_downloads_response(100, deps) assert out['downloads'][0]['progress'] == 100 def test_unified_response_post_processing_progress_is_95(): deps, _ = _build_deps() download_tasks['t1'] = { 'track_index': 0, 'status': 'post_processing', 'track_info': {'name': 'X'}, } out = st.build_unified_downloads_response(100, deps) assert out['downloads'][0]['progress'] == 95 def test_unified_response_includes_batch_summaries(): deps, _ = _build_deps() download_tasks['t1'] = {'track_index': 0, 'status': 'completed', 'track_info': {}} download_tasks['t2'] = {'track_index': 1, 'status': 'failed', 'track_info': {}} download_tasks['t3'] = {'track_index': 2, 'status': 'downloading', 'track_info': {}} download_tasks['t4'] = {'track_index': 3, 'status': 'queued', 'track_info': {}} download_batches['b1'] = { 'phase': 'downloading', 'playlist_name': 'PL', 'queue': ['t1', 't2', 't3', 't4'], } out = st.build_unified_downloads_response(100, deps) bs = out['batches'][0] assert bs['total'] == 4 assert bs['completed'] == 1 assert bs['failed'] == 1 assert bs['active'] == 1 assert bs['queued'] == 1 def test_unified_response_respects_limit(): deps, _ = _build_deps() for i in range(20): download_tasks[f't{i}'] = { 'track_index': i, 'status': 'completed', 'track_info': {}, } out = st.build_unified_downloads_response(5, deps) assert len(out['downloads']) == 5 assert out['total'] == 20 # total still reflects all def test_unified_response_includes_persistent_download_history(): deps, _ = _build_deps( persistent_history=lambda limit: [ { 'id': 42, 'title': 'Persisted Track', 'artist_name': 'Deezer Artist', 'album_name': 'Persistent Album', 'thumb_url': 'http://cover.jpg', 'download_source': 'Deezer', 'quality': 'FLAC', 'created_at': '2026-05-24 12:34:56', } ] ) out = st.build_unified_downloads_response(100, deps) assert out['total'] == 1 item = out['downloads'][0] assert item['task_id'] == 'history-42' assert item['title'] == 'Persisted Track' assert item['artist'] == 'Deezer Artist' assert item['album'] == 'Persistent Album' assert item['artwork'] == 'http://cover.jpg' assert item['status'] == 'completed' assert item['progress'] == 100 assert item['batch_name'] == 'Deezer' assert item['is_persistent_history'] is True def test_unified_response_dedupes_history_against_live_task(): download_tasks['live'] = { 'track_index': 0, 'status': 'completed', 'track_info': { 'name': 'Same Track', 'artist': 'Same Artist', 'album': 'Same Album', }, } deps, _ = _build_deps( persistent_history=lambda limit: [ { 'id': 7, 'title': 'Same Track', 'artist_name': 'Same Artist', 'album_name': 'Same Album', 'created_at': '2026-05-24 12:34:56', } ] ) out = st.build_unified_downloads_response(100, deps) assert len(out['downloads']) == 1 assert out['downloads'][0]['task_id'] == 'live' assert out['downloads'][0]['is_persistent_history'] is False def test_unified_response_caps_persistent_history_tail(): requested_limits = [] def _history(limit): requested_limits.append(limit) return [ { 'id': i, 'title': f'Track {i}', 'artist_name': 'Artist', 'album_name': 'Album', 'created_at': '2026-05-24 12:34:56', } for i in range(limit + 10) ] deps, _ = _build_deps(persistent_history=_history) out = st.build_unified_downloads_response(300, deps) assert requested_limits == [50] assert len(out['downloads']) == 50 assert out['total'] == 50 # --------------------------------------------------------------------------- # #836 — a rejected slskd transfer must not hang the task at 'downloading' # forever. The monitor normally retries; when it can't make progress, a backstop # in the status formatter marks the task failed after a grace window so the # worker frees and the batch can complete. # --------------------------------------------------------------------------- def test_rejected_within_grace_keeps_downloading_for_monitor(): import time deps, _ = _build_deps() now = time.time() download_tasks['t1'] = { 'track_index': 0, 'status': 'downloading', 'track_info': {}, 'filename': 'song.flac', 'username': 'u1', 'status_change_time': now - 5, } live = {'u1::song.flac': {'state': 'Completed, Rejected', 'percentComplete': 0}} batch = {'phase': 'downloading', 'queue': ['t1']} out = st.build_batch_status_data('b1', batch, live, deps) # inside the grace window — give the monitor its chance, don't fail yet assert out['tasks'][0]['status'] == 'downloading' assert download_tasks['t1']['status'] == 'downloading' def test_rejected_beyond_grace_marks_failed_and_frees_worker(): import time completed = [] deps, _ = _build_deps() deps.on_download_completed = lambda b, t, s: completed.append((b, t, s)) now = time.time() download_tasks['t1'] = { 'track_index': 0, 'status': 'downloading', 'track_info': {}, 'filename': 'song.flac', 'username': 'u1', # error persisted past the grace, no monitor transition since 'status_change_time': now - 130, '_error_state_since': now - (st.ERROR_STATE_TERMINAL_GRACE_SECONDS + 30), } live = {'u1::song.flac': {'state': 'Completed, Rejected', 'errorMessage': 'peer rejected'}} batch = {'phase': 'downloading', 'queue': ['t1']} out = st.build_batch_status_data('b1', batch, live, deps) assert out['tasks'][0]['status'] == 'failed' assert download_tasks['t1']['status'] == 'failed' assert download_tasks['t1']['error_message'] == 'peer rejected' time.sleep(0.05) # completion callback runs on a daemon thread assert completed == [('b1', 't1', False)] # batch can now finish def test_manual_pick_rejected_fails_immediately_without_grace(): import time deps, _ = _build_deps() now = time.time() download_tasks['t1'] = { 'track_index': 0, 'status': 'downloading', 'track_info': {}, 'filename': 'song.flac', 'username': 'u1', '_user_manual_pick': True, 'status_change_time': now, } live = {'u1::song.flac': {'state': 'Completed, Rejected'}} batch = {'phase': 'downloading', 'queue': ['t1']} out = st.build_batch_status_data('b1', batch, live, deps) assert out['tasks'][0]['status'] == 'failed' # immediate, no 60s wait assert download_tasks['t1']['status'] == 'failed'