package recommendation import ( "context" "testing" "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) // helperLBSimilarity inserts a track_similarity row. func helperLBSimilarity(t *testing.T, f fixture, a, b pgtype.UUID, score float64) { t.Helper() if _, err := f.pool.Exec(context.Background(), `INSERT INTO track_similarity (track_a_id, track_b_id, score, source) VALUES ($1, $2, $3, 'listenbrainz')`, a, b, score); err != nil { t.Fatalf("insert track_similarity: %v", err) } } // helperArtistSimilarity inserts an artist_similarity row. func helperArtistSimilarity(t *testing.T, f fixture, a, b pgtype.UUID, score float64) { t.Helper() if _, err := f.pool.Exec(context.Background(), `INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source) VALUES ($1, $2, $3, 'listenbrainz')`, a, b, score); err != nil { t.Fatalf("insert artist_similarity: %v", err) } } // helperSetTrackGenre updates a track's genre column. Used to retrofit // genres onto the fixture's auto-created tracks (fixture creates tracks // with NULL genre). func helperSetTrackGenre(t *testing.T, f fixture, trackID pgtype.UUID, genre string) { t.Helper() if _, err := f.pool.Exec(context.Background(), `UPDATE tracks SET genre = $1 WHERE id = $2`, genre, trackID); err != nil { t.Fatalf("set genre: %v", err) } } func defaultLimits() CandidateSourceLimits { return DefaultCandidateSourceLimits() } func TestLoadCandidatesFromSimilarity_LBSimilarSourceContributes(t *testing.T) { f := newFixture(t, 5) seed := f.tracks[0] target := f.tracks[1] helperLBSimilarity(t, f, seed.ID, target.ID, 0.85) got, err := LoadCandidatesFromSimilarity( context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), ) if err != nil { t.Fatalf("load: %v", err) } var found *Candidate for i := range got { if got[i].Track.ID == target.ID { found = &got[i] break } } if found == nil { t.Fatal("LB-similar target missing from candidates") } if found.Inputs.SimilarityScore < 0.84 || found.Inputs.SimilarityScore > 0.86 { t.Errorf("LB-similar SimilarityScore = %v, want ~0.85", found.Inputs.SimilarityScore) } } func TestLoadCandidatesFromSimilarity_SimilarArtistTracksContribute(t *testing.T) { f := newFixture(t, 1) // creates 1 artist + 1 album + 1 track (the seed) seed := f.tracks[0] // Add a SECOND artist + track in that artist; relate the two artists via artist_similarity. otherArtist, _ := f.q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "OtherArtist", SortName: "OtherArtist"}) otherAlbum, _ := f.q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "OtherAlbum", SortTitle: "OtherAlbum", ArtistID: otherArtist.ID}) otherTrack, _ := f.q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ Title: "OtherTrack", AlbumID: otherAlbum.ID, ArtistID: otherArtist.ID, FilePath: "/tmp/other.flac", DurationMs: 180_000, }) helperArtistSimilarity(t, f, seed.ArtistID, otherArtist.ID, 0.8) got, err := LoadCandidatesFromSimilarity( context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), ) if err != nil { t.Fatalf("load: %v", err) } for _, c := range got { if c.Track.ID == otherTrack.ID { // 0.8 × 0.5 = 0.4 if c.Inputs.SimilarityScore < 0.39 || c.Inputs.SimilarityScore > 0.41 { t.Errorf("similar-artist SimilarityScore = %v, want ~0.4 (0.8 × 0.5)", c.Inputs.SimilarityScore) } return } } t.Error("similar-artist track missing from candidates") } func TestLoadCandidatesFromSimilarity_TagOverlapContributes(t *testing.T) { f := newFixture(t, 2) seed := f.tracks[0] target := f.tracks[1] helperSetTrackGenre(t, f, seed.ID, "Rock; Pop") helperSetTrackGenre(t, f, target.ID, "Rock") got, err := LoadCandidatesFromSimilarity( context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), ) if err != nil { t.Fatalf("load: %v", err) } for _, c := range got { if c.Track.ID == target.ID { // Seed has 2 tags; target shares 1 → jaccard 1/2 = 0.5. if c.Inputs.SimilarityScore < 0.49 || c.Inputs.SimilarityScore > 0.51 { t.Errorf("tag-overlap SimilarityScore = %v, want ~0.5", c.Inputs.SimilarityScore) } return } } t.Error("tag-overlap target missing from candidates") } func TestLoadCandidatesFromSimilarity_LikesOverlapContributes(t *testing.T) { f := newFixture(t, 2) seed := f.tracks[0] liked := f.tracks[1] helperSetTrackGenre(t, f, seed.ID, "Rock") helperSetTrackGenre(t, f, liked.ID, "Rock") if _, err := f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: liked.ID}); err != nil { t.Fatalf("like: %v", err) } got, err := LoadCandidatesFromSimilarity( context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), ) if err != nil { t.Fatalf("load: %v", err) } for _, c := range got { if c.Track.ID == liked.ID { // Both tracks tagged "Rock" → jaccard 1/1 = 1.0 from tag-overlap. // likes-overlap = 0.6. Max wins = 1.0. if c.Inputs.SimilarityScore < 0.59 { t.Errorf("likes-overlap candidate SimilarityScore = %v, want ≥ 0.6", c.Inputs.SimilarityScore) } return } } t.Error("liked track with shared tag missing from candidates") } func TestLoadCandidatesFromSimilarity_RandomFillReturnsTracks(t *testing.T) { f := newFixture(t, 10) // 10 tracks; no similarity data seed := f.tracks[0] got, err := LoadCandidatesFromSimilarity( context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), ) if err != nil { t.Fatalf("load: %v", err) } if len(got) == 0 { t.Error("random fill returned 0 candidates; expected at least some") } for _, c := range got { if c.Inputs.SimilarityScore != 0 { t.Errorf("random-fill track %s has SimilarityScore = %v, want 0", c.Track.Title, c.Inputs.SimilarityScore) } } } func TestLoadCandidatesFromSimilarity_ExcludeListRespected(t *testing.T) { f := newFixture(t, 5) seed := f.tracks[0] excluded := f.tracks[1].ID got, err := LoadCandidatesFromSimilarity( context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, []pgtype.UUID{excluded}, defaultLimits(), ) if err != nil { t.Fatalf("load: %v", err) } for _, c := range got { if c.Track.ID == excluded { t.Error("excluded track appeared in candidates") } } } func TestLoadCandidatesFromSimilarity_SeedAlwaysExcluded(t *testing.T) { f := newFixture(t, 5) seed := f.tracks[0] got, err := LoadCandidatesFromSimilarity( context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), ) if err != nil { t.Fatalf("load: %v", err) } for _, c := range got { if c.Track.ID == seed.ID { t.Error("seed track appeared in candidates") } } } func TestLoadCandidatesFromSimilarity_RecentlyPlayedExcluded(t *testing.T) { f := newFixture(t, 5) seed := f.tracks[0] recent := f.tracks[1].ID var sessionID pgtype.UUID if err := f.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`, f.user).Scan(&sessionID); err != nil { t.Fatalf("session: %v", err) } if _, err := f.pool.Exec(context.Background(), `INSERT INTO play_events (user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped) VALUES ($1, $2, $3, now() - interval '30 minutes', now() - interval '20 minutes', 200000, 0.9, false)`, f.user, recent, sessionID); err != nil { t.Fatalf("play_event: %v", err) } got, err := LoadCandidatesFromSimilarity( context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), ) if err != nil { t.Fatalf("load: %v", err) } for _, c := range got { if c.Track.ID == recent { t.Error("recently-played track appeared in candidates") } } } func TestLoadCandidatesFromSimilarity_DedupTakesMaxScore(t *testing.T) { f := newFixture(t, 2) seed := f.tracks[0] target := f.tracks[1] helperSetTrackGenre(t, f, seed.ID, "Rock") helperSetTrackGenre(t, f, target.ID, "Rock") // jaccard 1/1 = 1.0 from tag-overlap helperLBSimilarity(t, f, seed.ID, target.ID, 0.5) // weaker LB signal got, err := LoadCandidatesFromSimilarity( context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), ) if err != nil { t.Fatalf("load: %v", err) } count := 0 for _, c := range got { if c.Track.ID == target.ID { count++ // tag-overlap (1.0) wins over LB (0.5) per max() — expect ≥ 0.99 if c.Inputs.SimilarityScore < 0.99 { t.Errorf("dedup max SimilarityScore = %v, want ≥ 0.99 (tag-overlap should win)", c.Inputs.SimilarityScore) } } } if count != 1 { t.Errorf("target appeared %d times, want 1 (dedup failed)", count) } } // TestLoadCandidatesFromSimilarity_TasteOverlapArm (#796 phase 2b): a track by // a positively-weighted taste-profile artist enters the pool via taste_overlap // even with every other arm disabled; a negatively-weighted artist's track does // not (the WHERE weight > 0 filter). func TestLoadCandidatesFromSimilarity_TasteOverlapArm(t *testing.T) { f := newFixture(t, 2) // seed + 1 other, both by the fixture artist seed := f.tracks[0] target := f.tracks[1] ctx := context.Background() // Fixture artist gets a positive taste weight. if _, err := f.pool.Exec(ctx, `INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, 5.0)`, f.user, seed.ArtistID); err != nil { t.Fatalf("insert taste (positive): %v", err) } // A second artist with a NEGATIVE weight — its track must be excluded. negArtist, _ := f.q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "NegArtist", SortName: "NegArtist"}) negAlbum, _ := f.q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: "NegAlbum", SortTitle: "NegAlbum", ArtistID: negArtist.ID}) negTrack, _ := f.q.UpsertTrack(ctx, dbq.UpsertTrackParams{ Title: "NegTrack", AlbumID: negAlbum.ID, ArtistID: negArtist.ID, FilePath: "/tmp/neg.flac", DurationMs: 180_000, }) if _, err := f.pool.Exec(ctx, `INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, -3.0)`, f.user, negArtist.ID); err != nil { t.Fatalf("insert taste (negative): %v", err) } // Only the taste_overlap arm is enabled. limits := CandidateSourceLimits{TasteOverlap: 10} got, err := LoadCandidatesFromSimilarity( ctx, f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, limits, ) if err != nil { t.Fatalf("load: %v", err) } var sawTarget, sawNeg bool for _, c := range got { switch c.Track.ID { case target.ID: sawTarget = true case negTrack.ID: sawNeg = true } } if !sawTarget { t.Error("positive-taste-artist track missing (taste_overlap arm didn't contribute)") } if sawNeg { t.Error("negative-taste-artist track present (weight > 0 filter failed)") } } func TestLoadCandidatesFromSimilarity_EmptyLibrary_NoError(t *testing.T) { f := newFixture(t, 1) // just the seed seed := f.tracks[0] got, err := LoadCandidatesFromSimilarity( context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), ) if err != nil { t.Fatalf("load: %v", err) } // Only the seed exists; it's excluded → 0 candidates. if len(got) != 0 { t.Errorf("got %d candidates from seed-only library, want 0", len(got)) } }