test(api): end-to-end contextual ranking test for /api/radio
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -89,6 +89,72 @@ func seedTrack(t *testing.T, pool *pgxpool.Pool, albumID, artistID pgtype.UUID,
|
|||||||
return tr
|
return tr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seedTrackWithGenre is seedTrack but lets the caller set Genre. Used by
|
||||||
|
// tests that exercise the contextual recommendation path, since session
|
||||||
|
// vectors collect tags from tracks.genre.
|
||||||
|
func seedTrackWithGenre(t *testing.T, pool *pgxpool.Pool, albumID, artistID pgtype.UUID, title string, trackNumber int, durationMs int32, genre string) dbq.Track {
|
||||||
|
t.Helper()
|
||||||
|
var tn *int32
|
||||||
|
if trackNumber > 0 {
|
||||||
|
v := int32(trackNumber)
|
||||||
|
tn = &v
|
||||||
|
}
|
||||||
|
tr, err := dbq.New(pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{
|
||||||
|
Title: title,
|
||||||
|
AlbumID: albumID,
|
||||||
|
ArtistID: artistID,
|
||||||
|
TrackNumber: tn,
|
||||||
|
DiscNumber: nil,
|
||||||
|
DurationMs: durationMs,
|
||||||
|
FilePath: "/seed/" + title + ".flac",
|
||||||
|
FileSize: 1024,
|
||||||
|
FileFormat: "flac",
|
||||||
|
Bitrate: nil,
|
||||||
|
Mbid: nil,
|
||||||
|
Genre: &genre,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertTrack(%s): %v", title, err)
|
||||||
|
}
|
||||||
|
return tr
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertOpenSessionWithVector creates a play_session with ended_at NULL and
|
||||||
|
// inserts a play_event in it whose session_vector_at_play is the given JSON.
|
||||||
|
// Used to simulate "user is mid-listen" for the radio handler's current-vector
|
||||||
|
// lookup. The play_event references a placeholder track inserted on the fly
|
||||||
|
// so the FK is valid; the placeholder is not used for radio scoring.
|
||||||
|
func insertOpenSessionWithVector(t *testing.T, pool *pgxpool.Pool, userID, anyArtistID pgtype.UUID, vectorJSON []byte) {
|
||||||
|
t.Helper()
|
||||||
|
q := dbq.New(pool)
|
||||||
|
al, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
|
||||||
|
Title: "PlaceholderAlbum", SortTitle: "PlaceholderAlbum", ArtistID: anyArtistID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("placeholder album: %v", err)
|
||||||
|
}
|
||||||
|
ph, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
|
||||||
|
Title: "Placeholder", AlbumID: al.ID, ArtistID: anyArtistID,
|
||||||
|
FilePath: "/seed/placeholder.flac", DurationMs: 100_000, FileSize: 1024, FileFormat: "flac",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("placeholder track: %v", err)
|
||||||
|
}
|
||||||
|
var sessionID pgtype.UUID
|
||||||
|
if err := pool.QueryRow(context.Background(),
|
||||||
|
`INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id)
|
||||||
|
VALUES ($1, now() - interval '5 minutes', now(), 'test') RETURNING id`,
|
||||||
|
userID).Scan(&sessionID); err != nil {
|
||||||
|
t.Fatalf("insert session: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(context.Background(),
|
||||||
|
`INSERT INTO play_events (user_id, track_id, session_id, started_at, session_vector_at_play)
|
||||||
|
VALUES ($1, $2, $3, now() - interval '1 minute', $4)`,
|
||||||
|
userID, ph.ID, sessionID, vectorJSON); err != nil {
|
||||||
|
t.Fatalf("insert play_event: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// seedTrackWithFile creates a fresh temp directory, writes fileBody to
|
// seedTrackWithFile creates a fresh temp directory, writes fileBody to
|
||||||
// <dir>/<title>.<ext>, and inserts a Track row whose file_path points at it.
|
// <dir>/<title>.<ext>, and inserts a Track row whose file_path points at it.
|
||||||
// Returns the inserted Track and the directory (callers drop sidecar covers
|
// Returns the inserted Track and the directory (callers drop sidecar covers
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||||
)
|
)
|
||||||
|
|
||||||
func callRadio(h *handlers, user interface{}, query string) *httptest.ResponseRecorder {
|
func callRadio(h *handlers, user interface{}, query string) *httptest.ResponseRecorder {
|
||||||
@@ -128,3 +130,74 @@ func TestHandleRadio_LimitClampedToMax(t *testing.T) {
|
|||||||
t.Errorf("len = %d, want 6", len(resp.Tracks))
|
t.Errorf("len = %d, want 6", len(resp.Tracks))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleRadio_ContextualMatch_BoostsRankingOverControl(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
user := seedUser(t, pool, "alice", "x", false)
|
||||||
|
|
||||||
|
// Two artists in distinct genres, so we can construct a "rock vibe" session
|
||||||
|
// and a contextual match.
|
||||||
|
rockArtist := seedArtist(t, pool, "RockArtist")
|
||||||
|
rockAlbum := seedAlbum(t, pool, rockArtist.ID, "RockAlbum", 2020)
|
||||||
|
jazzArtist := seedArtist(t, pool, "JazzArtist")
|
||||||
|
jazzAlbum := seedAlbum(t, pool, jazzArtist.ID, "JazzAlbum", 2020)
|
||||||
|
|
||||||
|
// Seed track is unrelated to both (don't want it to dominate scoring).
|
||||||
|
popArtist := seedArtist(t, pool, "PopArtist")
|
||||||
|
popAlbum := seedAlbum(t, pool, popArtist.ID, "PopAlbum", 2020)
|
||||||
|
seed := seedTrack(t, pool, popAlbum.ID, popArtist.ID, "Seed", 1, 100_000)
|
||||||
|
|
||||||
|
// Target: a rock track. Control: a jazz track. Both will be scored.
|
||||||
|
target := seedTrackWithGenre(t, pool, rockAlbum.ID, rockArtist.ID, "Target", 1, 100_000, "rock")
|
||||||
|
control := seedTrackWithGenre(t, pool, jazzAlbum.ID, jazzArtist.ID, "Control", 1, 100_000, "jazz")
|
||||||
|
_ = control // present in DB; we look it up by title in the response
|
||||||
|
|
||||||
|
// Build the user's "rock vibe" current context: insert an open play_session
|
||||||
|
// with a play_event whose session_vector_at_play matches the rock vibe.
|
||||||
|
rockVec := recommendation.SessionVector{
|
||||||
|
Artists: []string{rockArtist.ID.String()},
|
||||||
|
Tags: map[string]int{"rock": 3},
|
||||||
|
}
|
||||||
|
rockVecJSON, err := json.Marshal(rockVec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal rockVec: %v", err)
|
||||||
|
}
|
||||||
|
insertOpenSessionWithVector(t, pool, user.ID, rockArtist.ID, rockVecJSON)
|
||||||
|
|
||||||
|
// Insert a contextual_like on the target track whose stored vector matches
|
||||||
|
// the rock vibe. Direct DB insert — we want full control over the vector
|
||||||
|
// for this test.
|
||||||
|
if _, err := pool.Exec(context.Background(),
|
||||||
|
`INSERT INTO contextual_likes (user_id, track_id, session_vector) VALUES ($1, $2, $3)`,
|
||||||
|
user.ID, target.ID, rockVecJSON); err != nil {
|
||||||
|
t.Fatalf("insert contextual_like: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request radio. The deterministic RNG (rng=0.5 → jitter contribution = 0)
|
||||||
|
// means rankings are reproducible for this test.
|
||||||
|
w := callRadio(h, user, "seed_track="+uuidToString(seed.ID))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var resp RadioResponse
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
targetIdx, controlIdx := -1, -1
|
||||||
|
for i, tr := range resp.Tracks {
|
||||||
|
if tr.Title == "Target" {
|
||||||
|
targetIdx = i
|
||||||
|
}
|
||||||
|
if tr.Title == "Control" {
|
||||||
|
controlIdx = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if targetIdx == -1 || controlIdx == -1 {
|
||||||
|
t.Fatalf("target=%d control=%d, expected both present (resp.Tracks=%v)", targetIdx, controlIdx, resp.Tracks)
|
||||||
|
}
|
||||||
|
if targetIdx >= controlIdx {
|
||||||
|
t.Errorf("target ranked at %d, control at %d: contextual match should put target above control", targetIdx, controlIdx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user