discovery-by-llm/backend/tests/test_grounding.py
2026-08-10 13:09:08 +02:00

153 lines
4.8 KiB
Python

"""Deterministic tests for bounded Spotify grounding."""
import asyncio
from collections.abc import Awaitable, Callable
from app.config import Settings
from app.domain.models import Familiarity, Track, TrackCandidate
from app.pipeline.grounding import Grounder
from app.ports.protocols import TimeRange
SearchHandler = Callable[[str], Awaitable[list[Track]]]
class FakeCatalog:
"""Expose a programmable search surface for grounding tests."""
def __init__(self, search_handler: SearchHandler) -> None:
"""Store the search behavior and call trace."""
self.search_handler = search_handler
self.search_queries: list[str] = []
async def search_tracks(self, query: str, limit: int = 10) -> list[Track]:
"""Record and delegate one fake search."""
self.search_queries.append(query)
return await self.search_handler(query)
async def fetch_top_artists(self, time_range: TimeRange, limit: int) -> list[str]:
"""Return no top artists."""
return []
async def fetch_top_tracks(self, time_range: TimeRange, limit: int) -> list[Track]:
"""Return no top tracks."""
return []
async def fetch_saved_tracks(self, limit: int) -> list[Track]:
"""Return no saved tracks."""
return []
def test_early_stop_honors_pool_target() -> None:
async def run() -> None:
async def search(query: str) -> list[Track]:
title = query.split('track:"', 1)[1].split('"', 1)[0]
return [_track(title, title, "Artist")]
catalog = FakeCatalog(search)
grounder = Grounder(_settings(grounding_concurrency=2))
candidates = tuple(TrackCandidate(f"track-{index}", "Artist") for index in range(6))
result = await grounder.ground(catalog, candidates, frozenset(), Familiarity.MIX, 2)
assert len(result.tracks) == 2
assert len(catalog.search_queries) == 2
asyncio.run(run())
def test_miss_and_mismatch_are_counted_separately() -> None:
async def run() -> None:
async def search(query: str) -> list[Track]:
if "Missing" in query:
return []
return [_track("wrong", "Different Song", "Different Artist")]
catalog = FakeCatalog(search)
candidates = (
TrackCandidate("Missing", "Artist"),
TrackCandidate("Rejected", "Artist"),
)
result = await Grounder(_settings()).ground(
catalog,
candidates,
frozenset(),
Familiarity.MIX,
2,
)
assert result.metrics.miss_count == 1
assert result.metrics.mismatch_guard_count == 1
assert result.metrics.attempted_count == 2
asyncio.run(run())
def test_resolution_cache_hit_skips_catalog() -> None:
async def run() -> None:
async def search(query: str) -> list[Track]:
return [_track("cached", "Cached Song", "Artist")]
catalog = FakeCatalog(search)
grounder = Grounder(_settings())
candidates = (TrackCandidate("Cached Song", "Artist"),)
await grounder.ground(catalog, candidates, frozenset(), Familiarity.MIX, 1)
first_call_count = len(catalog.search_queries)
second = await grounder.ground(catalog, candidates, frozenset(), Familiarity.MIX, 1)
assert len(catalog.search_queries) == first_call_count
assert second.metrics.cache_hit_count == 1
asyncio.run(run())
def test_deadline_returns_resolved_partial_pool() -> None:
async def run() -> None:
never_finishes = asyncio.Event()
async def search(query: str) -> list[Track]:
if "Slow Song" in query:
await never_finishes.wait()
return [_track("fast", "Fast Song", "Artist")]
catalog = FakeCatalog(search)
settings = _settings(grounding_concurrency=2, request_deadline_seconds=0.02)
candidates = (
TrackCandidate("Fast Song", "Artist"),
TrackCandidate("Slow Song", "Artist"),
)
result = await Grounder(settings).ground(
catalog,
candidates,
frozenset(),
Familiarity.MIX,
2,
)
assert [track.id for track in result.tracks] == ["fast"]
assert result.metrics.did_reach_deadline
asyncio.run(run())
def _settings(**overrides: object) -> Settings:
values: dict[str, object] = {
"grounding_concurrency": 1,
"request_deadline_seconds": 1.0,
}
values.update(overrides)
return Settings.model_validate(values)
def _track(track_id: str, title: str, artist: str) -> Track:
return Track(
id=track_id,
uri=f"spotify:track:{track_id}",
title=title,
artists=(artist,),
album_name="Album",
album_art_url=None,
external_url=None,
)