mirror of https://github.com/Nezreka/SoulSync.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
366 lines
14 KiB
366 lines
14 KiB
"""Tests for ``core/downloads/album_bundle_dispatch.py``.
|
|
|
|
Pins the gate predicate, the resolution + run flow, and the
|
|
fail / fall-through return contract. Mocks the config, plugin
|
|
resolver, and state access so the dispatcher is testable without
|
|
standing up runtime_state or a real plugin.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from core.downloads.album_bundle_dispatch import (
|
|
BatchStateAccess,
|
|
is_eligible,
|
|
try_dispatch,
|
|
)
|
|
|
|
|
|
class _FakeState:
|
|
"""In-memory ``BatchStateAccess`` for tests — records every
|
|
update so assertions can check the sequence of fields set."""
|
|
|
|
def __init__(self) -> None:
|
|
self.fields: dict = {}
|
|
self.update_calls: list = []
|
|
self.failed_with: str = ''
|
|
|
|
def update_fields(self, batch_id: str, fields: dict) -> None:
|
|
self.update_calls.append((batch_id, dict(fields)))
|
|
self.fields.update(fields)
|
|
|
|
def mark_failed(self, batch_id: str, error: str) -> None:
|
|
self.failed_with = error
|
|
self.fields['phase'] = 'failed'
|
|
self.fields['error'] = error
|
|
self.fields['album_bundle_state'] = 'failed'
|
|
|
|
|
|
def _config(values: dict):
|
|
"""Build a config_get callable from a flat dict."""
|
|
def _get(key, default=None):
|
|
return values.get(key, default)
|
|
return _get
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# is_eligible pure predicate
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_is_eligible_requires_album_flag() -> None:
|
|
assert is_eligible(mode='torrent', is_album=False,
|
|
album_name='X', artist_name='Y') is False
|
|
|
|
|
|
def test_is_eligible_requires_album_bundle_mode() -> None:
|
|
for mode in ('youtube', 'tidal', 'qobuz', 'hifi',
|
|
'deezer_dl', 'amazon', 'lidarr', 'soundcloud', 'hybrid'):
|
|
assert is_eligible(mode=mode, is_album=True,
|
|
album_name='X', artist_name='Y') is False
|
|
|
|
|
|
def test_is_eligible_accepts_torrent_usenet_and_soulseek() -> None:
|
|
assert is_eligible(mode='torrent', is_album=True,
|
|
album_name='X', artist_name='Y') is True
|
|
assert is_eligible(mode='usenet', is_album=True,
|
|
album_name='X', artist_name='Y') is True
|
|
assert is_eligible(mode='soulseek', is_album=True,
|
|
album_name='X', artist_name='Y') is True
|
|
|
|
|
|
def test_is_eligible_requires_non_empty_names() -> None:
|
|
assert is_eligible(mode='torrent', is_album=True,
|
|
album_name='', artist_name='Y') is False
|
|
assert is_eligible(mode='torrent', is_album=True,
|
|
album_name='X', artist_name='') is False
|
|
assert is_eligible(mode='torrent', is_album=True,
|
|
album_name=' ', artist_name='Y') is False
|
|
|
|
|
|
def test_is_eligible_case_insensitive_mode() -> None:
|
|
assert is_eligible(mode='TORRENT', is_album=True,
|
|
album_name='X', artist_name='Y') is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# try_dispatch — gate evaluation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_dispatch_returns_false_when_not_album() -> None:
|
|
state = _FakeState()
|
|
plugin = MagicMock()
|
|
result = try_dispatch(
|
|
batch_id='b1', is_album=False,
|
|
album_context={'name': 'X'}, artist_context={'name': 'Y'},
|
|
config_get=_config({'download_source.mode': 'torrent'}),
|
|
plugin_resolver=lambda _name: plugin, state=state,
|
|
)
|
|
assert result is False
|
|
assert state.update_calls == []
|
|
plugin.download_album_to_staging.assert_not_called()
|
|
|
|
|
|
def test_dispatch_returns_false_for_non_album_bundle_modes() -> None:
|
|
state = _FakeState()
|
|
plugin = MagicMock()
|
|
result = try_dispatch(
|
|
batch_id='b1', is_album=True,
|
|
album_context={'name': 'X'}, artist_context={'name': 'Y'},
|
|
config_get=_config({'download_source.mode': 'youtube'}),
|
|
plugin_resolver=lambda _name: plugin, state=state,
|
|
)
|
|
assert result is False
|
|
assert state.update_calls == []
|
|
|
|
|
|
def test_dispatch_returns_false_when_plugin_missing() -> None:
|
|
"""No plugin available → fall through to per-track flow with a
|
|
warning. The state SHOULD NOT have been touched."""
|
|
state = _FakeState()
|
|
result = try_dispatch(
|
|
batch_id='b1', is_album=True,
|
|
album_context={'name': 'X'}, artist_context={'name': 'Y'},
|
|
config_get=_config({'download_source.mode': 'torrent'}),
|
|
plugin_resolver=lambda _name: None, state=state,
|
|
)
|
|
assert result is False
|
|
assert state.update_calls == []
|
|
|
|
|
|
def test_dispatch_returns_false_when_plugin_lacks_method() -> None:
|
|
state = _FakeState()
|
|
# Plugin that doesn't implement download_album_to_staging.
|
|
class _LegacyPlugin:
|
|
pass
|
|
result = try_dispatch(
|
|
batch_id='b1', is_album=True,
|
|
album_context={'name': 'X'}, artist_context={'name': 'Y'},
|
|
config_get=_config({'download_source.mode': 'torrent'}),
|
|
plugin_resolver=lambda _name: _LegacyPlugin(), state=state,
|
|
)
|
|
assert result is False
|
|
assert state.update_calls == []
|
|
|
|
|
|
def test_dispatch_returns_false_when_resolver_raises() -> None:
|
|
"""Plugin resolution can fail (registry not initialised); we log
|
|
and fall through rather than crashing the master worker."""
|
|
state = _FakeState()
|
|
def _boom(_name):
|
|
raise RuntimeError("registry not initialised")
|
|
result = try_dispatch(
|
|
batch_id='b1', is_album=True,
|
|
album_context={'name': 'X'}, artist_context={'name': 'Y'},
|
|
config_get=_config({'download_source.mode': 'torrent'}),
|
|
plugin_resolver=_boom, state=state,
|
|
)
|
|
assert result is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# try_dispatch — success / failure paths
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_dispatch_success_returns_false_so_per_track_can_run() -> None:
|
|
"""Success → master worker should CONTINUE to per-track flow so
|
|
each task can hit try_staging_match and find its file."""
|
|
state = _FakeState()
|
|
plugin = MagicMock()
|
|
plugin.download_album_to_staging.return_value = {
|
|
'success': True, 'files': ['/tmp/a.flac', '/tmp/b.flac'], 'error': None,
|
|
}
|
|
result = try_dispatch(
|
|
batch_id='b1', is_album=True,
|
|
album_context={'name': 'GNX'}, artist_context={'name': 'Kendrick Lamar'},
|
|
config_get=_config({
|
|
'download_source.mode': 'torrent',
|
|
'import.staging_path': '/staging/path',
|
|
}),
|
|
plugin_resolver=lambda _name: plugin, state=state,
|
|
)
|
|
assert result is False
|
|
# Plugin was called with the right args.
|
|
args = plugin.download_album_to_staging.call_args
|
|
assert args.args[0] == 'GNX'
|
|
assert args.args[1] == 'Kendrick Lamar'
|
|
assert args.args[2].replace('\\', '/').endswith('storage/album_bundle_staging/b1')
|
|
# Phase transitioned through searching → analysis.
|
|
assert state.fields['phase'] == 'analysis'
|
|
assert state.fields['album_bundle_state'] == 'staged'
|
|
assert state.fields['album_bundle_source'] == 'torrent'
|
|
assert state.fields['album_bundle_private_staging'] is True
|
|
assert state.fields['album_bundle_staging_path'].replace('\\', '/').endswith('storage/album_bundle_staging/b1')
|
|
assert state.failed_with == ''
|
|
|
|
|
|
def test_dispatch_uses_configured_private_album_bundle_staging_root() -> None:
|
|
state = _FakeState()
|
|
plugin = MagicMock()
|
|
plugin.download_album_to_staging.return_value = {'success': True, 'files': ['/tmp/a.flac']}
|
|
|
|
try_dispatch(
|
|
batch_id='batch:with/slash', is_album=True,
|
|
album_context={'name': 'GNX'}, artist_context={'name': 'Kendrick Lamar'},
|
|
config_get=_config({
|
|
'download_source.mode': 'torrent',
|
|
'download_source.album_bundle_staging_path': '/private/staging',
|
|
}),
|
|
plugin_resolver=lambda _name: plugin, state=state,
|
|
)
|
|
|
|
staging_arg = plugin.download_album_to_staging.call_args.args[2].replace('\\', '/')
|
|
assert staging_arg == '/private/staging/batch_with_slash'
|
|
assert state.fields['album_bundle_staging_path'].replace('\\', '/') == staging_arg
|
|
|
|
|
|
def test_dispatch_failure_returns_true_so_master_stops() -> None:
|
|
state = _FakeState()
|
|
plugin = MagicMock()
|
|
plugin.download_album_to_staging.return_value = {
|
|
'success': False, 'files': [], 'error': 'No torrent results found',
|
|
}
|
|
result = try_dispatch(
|
|
batch_id='b1', is_album=True,
|
|
album_context={'name': 'GNX'}, artist_context={'name': 'Kendrick Lamar'},
|
|
config_get=_config({'download_source.mode': 'torrent'}),
|
|
plugin_resolver=lambda _name: plugin, state=state,
|
|
)
|
|
assert result is True
|
|
assert state.failed_with == 'No torrent results found'
|
|
assert state.fields['phase'] == 'failed'
|
|
|
|
|
|
def test_dispatch_fallback_failure_returns_false_for_per_track_flow() -> None:
|
|
state = _FakeState()
|
|
plugin = MagicMock()
|
|
plugin.download_album_to_staging.return_value = {
|
|
'success': False,
|
|
'files': [],
|
|
'error': 'No complete Soulseek album folders found',
|
|
'fallback': True,
|
|
}
|
|
result = try_dispatch(
|
|
batch_id='b1', is_album=True,
|
|
album_context={'name': 'Album'}, artist_context={'name': 'Artist'},
|
|
config_get=_config({'download_source.mode': 'soulseek'}),
|
|
plugin_resolver=lambda _name: plugin, state=state,
|
|
)
|
|
assert result is False
|
|
assert state.failed_with == ''
|
|
assert state.fields['phase'] == 'analysis'
|
|
assert state.fields['album_bundle_state'] == 'fallback'
|
|
assert state.fields['album_bundle_error'] == 'No complete Soulseek album folders found'
|
|
|
|
|
|
def test_dispatch_plugin_exception_treated_as_failure() -> None:
|
|
"""A bug / network error in the plugin must not propagate into
|
|
the master worker — caught + treated as a normal failure so
|
|
the batch reports the error cleanly."""
|
|
state = _FakeState()
|
|
plugin = MagicMock()
|
|
plugin.download_album_to_staging.side_effect = RuntimeError("network down")
|
|
result = try_dispatch(
|
|
batch_id='b1', is_album=True,
|
|
album_context={'name': 'GNX'}, artist_context={'name': 'Kendrick Lamar'},
|
|
config_get=_config({'download_source.mode': 'torrent'}),
|
|
plugin_resolver=lambda _name: plugin, state=state,
|
|
)
|
|
assert result is True
|
|
assert 'network down' in state.failed_with
|
|
|
|
|
|
def test_dispatch_strips_whitespace_from_names() -> None:
|
|
"""Trailing whitespace in batch context shouldn't fail the
|
|
eligibility predicate AND should be cleaned before passing to
|
|
the plugin."""
|
|
state = _FakeState()
|
|
plugin = MagicMock()
|
|
plugin.download_album_to_staging.return_value = {'success': True, 'files': ['/x']}
|
|
try_dispatch(
|
|
batch_id='b1', is_album=True,
|
|
album_context={'name': ' GNX '}, artist_context={'name': ' Kendrick '},
|
|
config_get=_config({'download_source.mode': 'torrent'}),
|
|
plugin_resolver=lambda _name: plugin, state=state,
|
|
)
|
|
args = plugin.download_album_to_staging.call_args
|
|
assert args.args[0] == 'GNX'
|
|
assert args.args[1] == 'Kendrick'
|
|
|
|
|
|
def test_dispatch_source_override_uses_first_hybrid_source() -> None:
|
|
state = _FakeState()
|
|
plugin = MagicMock()
|
|
plugin.download_album_to_staging.return_value = {'success': True, 'files': ['/x']}
|
|
seen = []
|
|
|
|
try_dispatch(
|
|
batch_id='b1', is_album=True,
|
|
album_context={'name': 'GNX'}, artist_context={'name': 'Kendrick Lamar'},
|
|
config_get=_config({'download_source.mode': 'hybrid'}),
|
|
plugin_resolver=lambda name: seen.append(name) or plugin,
|
|
state=state,
|
|
source_override='soulseek',
|
|
)
|
|
|
|
assert seen == ['soulseek']
|
|
assert state.fields['album_bundle_source'] == 'soulseek'
|
|
|
|
|
|
def test_dispatch_progress_callback_mirrors_payload_to_state() -> None:
|
|
"""The progress callback the plugin gets must mirror its
|
|
payload onto the batch state under ``album_bundle_*`` keys so
|
|
the Downloads page can render progress while the torrent
|
|
download runs."""
|
|
state = _FakeState()
|
|
captured_emit = {}
|
|
|
|
def _capture(album, artist, staging, emit):
|
|
captured_emit['fn'] = emit
|
|
emit({'state': 'searching', 'release': 'GNX [FLAC]'})
|
|
emit({'state': 'downloading', 'progress': 0.42, 'speed': 1024 * 1024})
|
|
emit({'state': 'staged', 'count': 12})
|
|
return {'success': True, 'files': []}
|
|
|
|
plugin = MagicMock()
|
|
plugin.download_album_to_staging.side_effect = _capture
|
|
try_dispatch(
|
|
batch_id='b1', is_album=True,
|
|
album_context={'name': 'GNX'}, artist_context={'name': 'Kendrick Lamar'},
|
|
config_get=_config({'download_source.mode': 'torrent'}),
|
|
plugin_resolver=lambda _name: plugin, state=state,
|
|
)
|
|
# State should have seen each of the three lifecycle emissions.
|
|
states_seen = [fields.get('album_bundle_state')
|
|
for _, fields in state.update_calls
|
|
if 'album_bundle_state' in fields]
|
|
assert 'searching' in states_seen
|
|
assert 'downloading' in states_seen
|
|
assert 'staged' in states_seen
|
|
# Numeric progress + release name made it through.
|
|
assert state.fields['album_bundle_release'] == 'GNX [FLAC]'
|
|
assert state.fields['album_bundle_progress'] == 0.42
|
|
assert state.fields['album_bundle_count'] == 12
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Protocol conformance — runtime impl must satisfy the contract
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_runtime_state_impl_matches_protocol() -> None:
|
|
"""Sanity check that the concrete BatchStateAccess impl in
|
|
master.py implements both methods. We don't import master.py
|
|
here (would pull in heavy deps); duck-check on the _FakeState
|
|
instead since it's a sibling impl of the same Protocol."""
|
|
state: BatchStateAccess = _FakeState()
|
|
state.update_fields('b1', {'x': 1})
|
|
state.mark_failed('b1', 'oops')
|
|
assert state.fields['x'] == 1
|
|
assert state.fields['error'] == 'oops'
|