soulsync/tests/search/test_search_basic.py
Broque Thomas 7145368d42 Basic search: visual overhaul + per-source picker in hybrid mode
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.
2026-05-28 10:22:07 -07:00

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'