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.
219 lines
8.1 KiB
219 lines
8.1 KiB
"""Tests for core/search/basic.py — basic Soulseek file search."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from core.search import basic
|
|
|
|
|
|
class _SearchTrack:
|
|
def __init__(self, name, quality_score, **extra):
|
|
self.__dict__['name'] = name
|
|
self.__dict__['quality_score'] = quality_score
|
|
self.__dict__.update(extra)
|
|
|
|
|
|
class _SearchAlbum:
|
|
def __init__(self, name, quality_score, tracks=None, **extra):
|
|
self.__dict__['name'] = name
|
|
self.__dict__['quality_score'] = quality_score
|
|
self.__dict__['tracks'] = tracks or []
|
|
self.__dict__.update(extra)
|
|
|
|
|
|
class _FakeSoulseek:
|
|
def __init__(self, tracks=None, albums=None):
|
|
self._tracks = tracks or []
|
|
self._albums = albums or []
|
|
|
|
async def search(self, query):
|
|
return self._tracks, self._albums
|
|
|
|
|
|
def _run_async(coro):
|
|
"""Test-friendly run_async — drains a coroutine synchronously."""
|
|
import asyncio
|
|
return asyncio.get_event_loop().run_until_complete(coro) if not asyncio.get_event_loop().is_running() else None
|
|
|
|
|
|
def _sync_run_async(coro):
|
|
"""Threadless awaitable runner using a fresh loop."""
|
|
import asyncio
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
return loop.run_until_complete(coro)
|
|
finally:
|
|
loop.close()
|
|
|
|
|
|
def test_returns_empty_for_no_results():
|
|
client = _FakeSoulseek(tracks=[], albums=[])
|
|
result = basic.run_basic_soulseek_search('q', client, _sync_run_async)
|
|
assert result == []
|
|
|
|
|
|
def test_tracks_are_tagged_with_result_type():
|
|
track = _SearchTrack('T1', 0.5, username='u', filename='f.mp3', size=1, bitrate=320)
|
|
client = _FakeSoulseek(tracks=[track])
|
|
result = basic.run_basic_soulseek_search('q', client, _sync_run_async)
|
|
assert result[0]['result_type'] == 'track'
|
|
assert result[0]['name'] == 'T1'
|
|
|
|
|
|
def test_albums_get_tracks_serialized_and_tagged():
|
|
inner_track = _SearchTrack('inner', 0.5, filename='in.mp3')
|
|
album = _SearchAlbum('A1', 0.9, tracks=[inner_track], username='u')
|
|
client = _FakeSoulseek(albums=[album])
|
|
result = basic.run_basic_soulseek_search('q', client, _sync_run_async)
|
|
assert result[0]['result_type'] == 'album'
|
|
assert result[0]['name'] == 'A1'
|
|
assert isinstance(result[0]['tracks'], list)
|
|
assert result[0]['tracks'][0]['name'] == 'inner'
|
|
|
|
|
|
def test_results_sorted_by_quality_score_desc():
|
|
low = _SearchTrack('low', 0.1)
|
|
high = _SearchTrack('high', 0.9)
|
|
mid = _SearchTrack('mid', 0.5)
|
|
client = _FakeSoulseek(tracks=[low, mid, high])
|
|
result = basic.run_basic_soulseek_search('q', client, _sync_run_async)
|
|
assert [r['name'] for r in result] == ['high', 'mid', 'low']
|
|
|
|
|
|
def test_albums_and_tracks_intermingled_by_quality():
|
|
track = _SearchTrack('mid_t', 0.5)
|
|
album = _SearchAlbum('top_a', 0.9, tracks=[])
|
|
client = _FakeSoulseek(tracks=[track], albums=[album])
|
|
result = basic.run_basic_soulseek_search('q', client, _sync_run_async)
|
|
assert result[0]['name'] == 'top_a'
|
|
assert result[1]['name'] == 'mid_t'
|
|
|
|
|
|
def test_missing_quality_score_treated_as_zero():
|
|
no_score = _SearchTrack('n', None)
|
|
no_score.__dict__.pop('quality_score', None)
|
|
has_score = _SearchTrack('h', 0.5)
|
|
client = _FakeSoulseek(tracks=[no_score, has_score])
|
|
result = basic.run_basic_soulseek_search('q', client, _sync_run_async)
|
|
# has_score (0.5) ranks above no_score (treated as 0)
|
|
assert result[0]['name'] == 'h'
|
|
|
|
|
|
# ── Source-targeted search (new in basic-search redesign) ──────────────
|
|
|
|
|
|
class _FakeOrchestratorMulti:
|
|
"""Orchestrator stand-in with per-source clients.
|
|
|
|
``search()`` is the default orchestrator path (single-source mode or
|
|
first hybrid source). ``client(name)`` returns the per-source client
|
|
so a source-targeted basic search can call ``.search()`` directly on
|
|
the specific source rather than going through the chain.
|
|
"""
|
|
|
|
def __init__(self, default_results, per_source_results, fail_unknown=False):
|
|
self._default = default_results
|
|
self._sources = per_source_results
|
|
self._fail_unknown = fail_unknown
|
|
self.default_search_calls = 0
|
|
self.per_source_calls = {}
|
|
|
|
async def search(self, query, timeout=None, progress_callback=None, exclude_sources=None):
|
|
self.default_search_calls += 1
|
|
return self._default
|
|
|
|
def client(self, name):
|
|
if name not in self._sources:
|
|
if self._fail_unknown:
|
|
return None
|
|
return None
|
|
plugin = _FakeSoulseek(tracks=self._sources[name][0], albums=self._sources[name][1])
|
|
# Record which sources got asked for.
|
|
self.per_source_calls.setdefault(name, 0)
|
|
self.per_source_calls[name] += 1
|
|
return plugin
|
|
|
|
|
|
def test_source_param_routes_to_specific_client():
|
|
"""``source='tidal'`` calls the Tidal client directly, NOT the
|
|
orchestrator's chain. Ensures the per-source basic search bypasses
|
|
the hybrid-first selection so users can target any active source."""
|
|
tidal_track = _SearchTrack('From Tidal', 0.9, username='tidal')
|
|
soul_track = _SearchTrack('From Soulseek', 0.5, username='peer')
|
|
|
|
orch = _FakeOrchestratorMulti(
|
|
default_results=([soul_track], []),
|
|
per_source_results={
|
|
'tidal': ([tidal_track], []),
|
|
'soulseek': ([soul_track], []),
|
|
},
|
|
)
|
|
result = basic.run_basic_search('q', orch, _sync_run_async, source='tidal')
|
|
|
|
# Tidal result returned, NOT soulseek result.
|
|
assert len(result) == 1
|
|
assert result[0]['name'] == 'From Tidal'
|
|
# Orchestrator default chain NOT consulted.
|
|
assert orch.default_search_calls == 0
|
|
# Tidal client was called exactly once.
|
|
assert orch.per_source_calls.get('tidal') == 1
|
|
|
|
|
|
def test_no_source_param_falls_through_to_orchestrator_default():
|
|
"""When ``source`` is omitted, behaviour is identical to pre-redesign:
|
|
orchestrator.search() is called and routes to the configured source
|
|
(single-source mode) or first hybrid source. Preserves the existing
|
|
contract for callers that don't opt in to per-source targeting."""
|
|
track = _SearchTrack('Default', 0.7)
|
|
orch = _FakeOrchestratorMulti(
|
|
default_results=([track], []),
|
|
per_source_results={'tidal': ([], [])},
|
|
)
|
|
result = basic.run_basic_search('q', orch, _sync_run_async)
|
|
|
|
assert result[0]['name'] == 'Default'
|
|
assert orch.default_search_calls == 1
|
|
assert orch.per_source_calls == {}
|
|
|
|
|
|
def test_unknown_source_falls_back_to_orchestrator():
|
|
"""Bogus source name (e.g. user-edited config with a typo) falls
|
|
through to the orchestrator default rather than returning an empty
|
|
list silently. Logged as a warning so misconfiguration is visible."""
|
|
track = _SearchTrack('Default', 0.7)
|
|
orch = _FakeOrchestratorMulti(
|
|
default_results=([track], []),
|
|
per_source_results={'tidal': ([], [])},
|
|
)
|
|
result = basic.run_basic_search('q', orch, _sync_run_async, source='nonexistent')
|
|
|
|
assert result[0]['name'] == 'Default'
|
|
assert orch.default_search_calls == 1
|
|
|
|
|
|
def test_backwards_compat_alias_still_works():
|
|
"""``run_basic_soulseek_search`` is the legacy name; any external
|
|
caller that hasn't been updated must keep working. Aliased to
|
|
``run_basic_search`` so both call the same code path."""
|
|
track = _SearchTrack('Compat', 0.5)
|
|
client = _FakeSoulseek(tracks=[track])
|
|
result = basic.run_basic_soulseek_search('q', client, _sync_run_async)
|
|
assert result[0]['name'] == 'Compat'
|
|
assert basic.run_basic_soulseek_search is basic.run_basic_search
|
|
|
|
|
|
def test_source_targeted_search_serialises_albums_with_tracks():
|
|
"""Source-targeted path goes through the same normaliser as the
|
|
default path, so albums returned via a specific source still get
|
|
their tracks serialised + ``result_type='album'`` tagged."""
|
|
inner = _SearchTrack('inner', 0.5, filename='in.mp3')
|
|
album = _SearchAlbum('TidalAlbum', 0.9, tracks=[inner], username='tidal')
|
|
orch = _FakeOrchestratorMulti(
|
|
default_results=([], []),
|
|
per_source_results={'tidal': ([], [album])},
|
|
)
|
|
|
|
result = basic.run_basic_search('q', orch, _sync_run_async, source='tidal')
|
|
|
|
assert result[0]['result_type'] == 'album'
|
|
assert result[0]['tracks'][0]['name'] == 'inner'
|