The similar_artists and coplay_artists arms ordered by artist score, so the closest related artist's catalogue filled the whole LIMIT. On the operator's library the 30-row similar_artists arm held exactly one artist for all 17 seeds measured (#3879), though each seed had 7-33 similar artists in the library. Both arms now rank tracks within each artist and take every artist's first track, best artist first, before anyone's second. Both also skip missing tracks: the outer select already dropped them, but only after they had taken places in the arm's LIMIT. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
498 lines
17 KiB
Go
498 lines
17 KiB
Go
package recommendation
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"reflect"
|
||
"sort"
|
||
"strings"
|
||
"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(), "test-seed",
|
||
)
|
||
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(), "test-seed",
|
||
)
|
||
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(), "test-seed",
|
||
)
|
||
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(), "test-seed",
|
||
)
|
||
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(), "test-seed",
|
||
)
|
||
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(), "test-seed",
|
||
)
|
||
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(), "test-seed",
|
||
)
|
||
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(), "test-seed",
|
||
)
|
||
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(), "test-seed",
|
||
)
|
||
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, "test-seed",
|
||
)
|
||
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(), "test-seed",
|
||
)
|
||
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))
|
||
}
|
||
}
|
||
|
||
// The randomised arms must draw REPRODUCIBLY for a given seed (#3889).
|
||
//
|
||
// Four arms used to end in a bare `ORDER BY random()`. That returned a stable
|
||
// set only while the arm's LIMIT exceeded the rows eligible for it — at that
|
||
// point it returned all of them and the order stopped mattering, because the
|
||
// caller sorts by track id before scoring. Below that threshold it returned a
|
||
// random SUBSET, so two calls drew different candidates.
|
||
//
|
||
// It therefore held by ACCIDENT, and only for libraries smaller than the
|
||
// limits. Any real library is larger, so same-day rebuilds had been drawing
|
||
// different mixes since the arm was written — invisible, because a mix that
|
||
// changes after a refresh looks like a feature.
|
||
//
|
||
// Limits deliberately smaller than the fixture, because that is the only
|
||
// regime where the bug existed at all: with limits above the eligible count
|
||
// the old code passes this too.
|
||
func TestLoadCandidatesFromSimilarity_SameSeedDrawsTheSameSet(t *testing.T) {
|
||
f := newFixture(t, 12)
|
||
seed := f.tracks[0]
|
||
|
||
tight := CandidateSourceLimits{
|
||
LBSimilar: 2, SimilarArtist: 2, TagOverlap: 2,
|
||
LikesOverlap: 2, RandomFill: 3, TasteOverlap: 2, UserCoplay: 2,
|
||
}
|
||
ids := func(cs []Candidate) []string {
|
||
out := make([]string, 0, len(cs))
|
||
for _, c := range cs {
|
||
out = append(out, fmt.Sprintf("%x", c.Track.ID.Bytes))
|
||
}
|
||
sort.Strings(out) // membership, not order — order is settled downstream
|
||
return out
|
||
}
|
||
|
||
first, err := LoadCandidatesFromSimilarity(
|
||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, tight, "day-one",
|
||
)
|
||
if err != nil {
|
||
t.Fatalf("load: %v", err)
|
||
}
|
||
if len(first) == 0 {
|
||
t.Fatal("no candidates, so this test asserts nothing")
|
||
}
|
||
|
||
for i := 0; i < 3; i++ {
|
||
again, err := LoadCandidatesFromSimilarity(
|
||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, tight, "day-one",
|
||
)
|
||
if err != nil {
|
||
t.Fatalf("load %d: %v", i, err)
|
||
}
|
||
if !reflect.DeepEqual(ids(first), ids(again)) {
|
||
t.Fatalf("same seed drew a different set on call %d:\n first %v\n again %v",
|
||
i, ids(first), ids(again))
|
||
}
|
||
}
|
||
}
|
||
|
||
// ...and a different seed is free to draw differently, or the ordering would
|
||
// be fixed rather than seeded and every day would serve the same mix.
|
||
//
|
||
// Asserted as "not pinned to one answer" rather than "always differs": with a
|
||
// small fixture two seeds can legitimately collide, so requiring a difference
|
||
// on any single pair would be flaky. Several seeds producing exactly one
|
||
// distinct set is the real regression — that is what a constant ORDER BY
|
||
// looks like.
|
||
func TestLoadCandidatesFromSimilarity_DifferentSeedsCanDrawDifferently(t *testing.T) {
|
||
f := newFixture(t, 12)
|
||
seed := f.tracks[0]
|
||
tight := CandidateSourceLimits{
|
||
LBSimilar: 2, SimilarArtist: 2, TagOverlap: 2,
|
||
LikesOverlap: 2, RandomFill: 3, TasteOverlap: 2, UserCoplay: 2,
|
||
}
|
||
|
||
seen := map[string]bool{}
|
||
for _, orderSeed := range []string{"a", "b", "c", "d", "e", "f"} {
|
||
cs, err := LoadCandidatesFromSimilarity(
|
||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, tight, orderSeed,
|
||
)
|
||
if err != nil {
|
||
t.Fatalf("load %q: %v", orderSeed, err)
|
||
}
|
||
ids := make([]string, 0, len(cs))
|
||
for _, c := range cs {
|
||
ids = append(ids, fmt.Sprintf("%x", c.Track.ID.Bytes))
|
||
}
|
||
sort.Strings(ids)
|
||
seen[strings.Join(ids, ",")] = true
|
||
}
|
||
if len(seen) < 2 {
|
||
t.Errorf("six different seeds produced %d distinct set(s); the ordering is not "+
|
||
"varying with the seed at all", len(seen))
|
||
}
|
||
}
|
||
|
||
// #5297: an artist-level arm takes a track from each related artist before a
|
||
// second from any. Ordered by artist score alone, the closest artist's
|
||
// catalogue filled the whole LIMIT; on the operator's library the
|
||
// similar_artists arm held one artist for every seed measured (#3879).
|
||
func TestLoadCandidatesFromSimilarity_ArtistArmsRoundRobin(t *testing.T) {
|
||
for _, source := range []string{"listenbrainz", "user_cooccurrence"} {
|
||
t.Run(source, func(t *testing.T) {
|
||
f := newFixture(t, 1)
|
||
ctx := context.Background()
|
||
seed := f.tracks[0]
|
||
artistOf := map[[16]byte]string{}
|
||
for i, score := range []float64{0.9, 0.8, 0.7} {
|
||
name := fmt.Sprintf("Related %d", i)
|
||
ar, err := f.q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: name, SortName: name})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
al, err := f.q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: name, SortTitle: name, ArtistID: ar.ID})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// More tracks per artist than the arm's limit, so the closest
|
||
// artist alone could fill it.
|
||
for j := 0; j < 5; j++ {
|
||
tr, err := f.q.UpsertTrack(ctx, dbq.UpsertTrackParams{
|
||
Title: fmt.Sprintf("%s #%d", name, j), AlbumID: al.ID, ArtistID: ar.ID,
|
||
FilePath: fmt.Sprintf("/tmp/related-%d-%d.flac", i, j), DurationMs: 180_000,
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
artistOf[tr.ID.Bytes] = name
|
||
}
|
||
if _, err := f.pool.Exec(ctx,
|
||
`INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source) VALUES ($1, $2, $3, $4)`,
|
||
seed.ArtistID, ar.ID, score, source); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
limits := CandidateSourceLimits{}
|
||
if source == "listenbrainz" {
|
||
limits.SimilarArtist = 3
|
||
} else {
|
||
limits.UserCoplay = 3
|
||
}
|
||
got, err := LoadCandidatesFromSimilarity(
|
||
ctx, f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, limits, "test-seed",
|
||
)
|
||
if err != nil {
|
||
t.Fatalf("load: %v", err)
|
||
}
|
||
artists := map[string]int{}
|
||
for _, c := range got {
|
||
if name, ok := artistOf[c.Track.ID.Bytes]; ok {
|
||
artists[name]++
|
||
}
|
||
}
|
||
if len(got) != 3 || len(artists) != 3 {
|
||
t.Errorf("arm of 3 drew %d candidates from %d related artists (%v), want one from each of 3",
|
||
len(got), len(artists), artists)
|
||
}
|
||
})
|
||
}
|
||
}
|