Two things in this commit. Functional download / matched-download behaviour is untouched — same JS handlers, same routes for the download actions, same album-expand interaction. VISUAL REDESIGN - Glass search-bar card with accent radial wash + focus ring + pill primary search button - Source chip row above the search bar (see below) - Always-visible compact filter pill row (Type / Format / Sort) — pills carry both ``bs-filter-pill`` (new visual) and ``filter-btn`` (legacy class for ``resetFilters`` + ``applyFiltersAndSort`` in wishlist-tools.js to keep working) - Accent-tinted status pill matching the dashboard / auto-sync look - Album result cards: glass card with accent left-edge stripe, 52px brand-tinted cover icon, chevron expand indicator, pill action buttons (Download / Matched Album), accent glow on hover - Track result cards: glass row with accent stripe, 44px icon, pill action buttons (Stream / Download / Matched Download) - Multi-disc separators inside expanded album track lists styled with the accent treatment - Responsive: action button columns stack vertically below 900px New CSS lives in a self-contained ``webui/static/basic-search-v2.css`` sheet linked from index.html. Selectors are scoped to ``#basic-search-section`` for any class that already exists in style.css (``.album-result-card``, ``.album-icon``, ``.track-*``, etc.); the new ``bs-*`` prefixed classes for the search bar / filters / source row / status are unscoped because they only exist in the new markup. ``!important`` is used on the card-level rules to defeat the original unscoped ``.album-result-card`` etc. rules in style.css that would otherwise leak heavyweight padding / box-shadow / 56px icon styles into the new design. Also removed ``overflow: hidden`` from the original ``.album-result-card`` and ``.track-result-card`` rules in style.css — those two classes only render in ``downloads.js`` basic search results (verified via grep, two render sites only), so the removal can't impact any other UI. SOURCE PICKER (hybrid mode) - New ``GET /api/search/sources`` endpoint returns the list of active sources from the orchestrator's chain (or the single active source in single-source mode). - Frontend renders a chip row above the search bar. Click a chip to target that source for the next search; the chip's brand accent fills. - In single-source mode the lone chip is rendered as a dashed- border label so the user always knows what they're searching but can't accidentally try to switch to sources that aren't configured. - ``/api/search`` accepts an optional ``source`` body param. When set, ``core/search/basic.py:run_basic_search`` resolves the client directly via ``orchestrator.client(source)`` and calls its ``.search()`` instead of going through the hybrid chain. - Backwards compatible: omitting ``source`` falls through to the original ``orchestrator.search()`` call exactly as before. Unknown source names also fall back to the default — typo protection. TESTS (5 new + 6 pre-existing = 11 total in test_search_basic.py) - source param routes to specific client, NOT orchestrator chain - no source param preserves original orchestrator-default behaviour - unknown source name falls back to orchestrator default - ``run_basic_soulseek_search`` backwards-compat alias preserved - source-targeted path serialises albums + tracks correctly 101 search-suite tests pass.
218 lines
8.1 KiB
Python
218 lines
8.1 KiB
Python
"""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'
|