153 lines
4.8 KiB
Python
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,
|
|
)
|