"""Deterministic tests for bounded Spotify grounding.""" import asyncio import time from collections.abc import Awaitable, Callable from app.adapters.spotify.errors import ( SpotifyQuotaExhaustedError, SpotifyRateLimitedError, SpotifyUnavailableError, ) 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, _deadline(), ) 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, _deadline(), ) 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, _deadline(), ) first_call_count = len(catalog.search_queries) second = await grounder.ground( catalog, candidates, frozenset(), Familiarity.MIX, 1, _deadline(), ) 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, time.monotonic() + settings.request_deadline_seconds, ) assert [track.id for track in result.tracks] == ["fast"] assert result.metrics.did_reach_deadline asyncio.run(run()) def test_spotify_failure_is_counted_and_remaining_candidates_continue() -> None: async def run() -> None: async def search(query: str) -> list[Track]: if "Failing Song" in query: raise SpotifyUnavailableError(503) title = query.split('track:"', 1)[1].split('"', 1)[0] return [_track(title.lower().replace(" ", "-"), title, "Artist")] catalog = FakeCatalog(search) candidates = ( TrackCandidate("Failing Song", "Artist"), TrackCandidate("First Good Song", "Artist"), TrackCandidate("Second Good Song", "Artist"), ) result = await Grounder(_settings()).ground( catalog, candidates, frozenset(), Familiarity.MIX, 2, _deadline(), ) assert [track.title for track in result.tracks] == [ "First Good Song", "Second Good Song", ] assert result.metrics.failed_count == 1 assert result.metrics.attempted_count == 3 assert not result.metrics.did_exhaust_quota asyncio.run(run()) def test_plain_rate_limit_continues_but_quota_exhaustion_stops_fanout() -> None: async def run() -> None: async def plain_rate_limited_search(query: str) -> list[Track]: if "Rate Limited" in query: raise SpotifyRateLimitedError(6.0) return [_track("found", "Found Song", "Artist")] plain_catalog = FakeCatalog(plain_rate_limited_search) candidates = ( TrackCandidate("Rate Limited", "Artist"), TrackCandidate("Found Song", "Artist"), ) plain_result = await Grounder(_settings()).ground( plain_catalog, candidates, frozenset(), Familiarity.MIX, 1, _deadline(), ) async def quota_search(query: str) -> list[Track]: raise SpotifyQuotaExhaustedError(0.0, "QUOTA_EXCEEDED") quota_catalog = FakeCatalog(quota_search) quota_result = await Grounder(_settings()).ground( quota_catalog, candidates, frozenset(), Familiarity.MIX, 1, _deadline(), ) assert [track.id for track in plain_result.tracks] == ["found"] assert plain_result.metrics.failed_count == 1 assert not plain_result.metrics.did_exhaust_quota assert quota_result.tracks == () assert quota_result.metrics.did_exhaust_quota assert len(quota_catalog.search_queries) == 1 asyncio.run(run()) def test_grounding_concurrency_is_shared_across_requests() -> None: async def run() -> None: active_searches = 0 maximum_active_searches = 0 async def search(query: str) -> list[Track]: nonlocal active_searches, maximum_active_searches active_searches += 1 maximum_active_searches = max(maximum_active_searches, active_searches) await asyncio.sleep(0.01) active_searches -= 1 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"Song {index}", "Artist") for index in range(2)) await asyncio.gather( grounder.ground( catalog, candidates, frozenset(), Familiarity.MIX, 2, _deadline(), ), grounder.ground( catalog, candidates, frozenset(), Familiarity.MIX, 2, _deadline(), ), ) assert maximum_active_searches == 2 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 _deadline() -> float: return time.monotonic() + 1.0 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, )