package playlists import ( "testing" "time" "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" ) // fixedNow keeps recencyDecay stable across the pure roll-up tests. var fixedNow = time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC) // byteOf returns the per-test entity discriminator (makeCand sets it in // Bytes[15]) so assertions can name albums/artists by their small int. func byteOf(u pgtype.UUID) byte { return u.Bytes[15] } // countWithByte reports how many of ids carry the given Bytes[15] marker. func countWithByte(ids []pgtype.UUID, want byte) int { n := 0 for _, id := range ids { if byteOf(id) == want { n++ } } return n } func TestSumTopK(t *testing.T) { if got := sumTopK([]float64{1, 2, 3, 4}, 2); got != 7 { t.Errorf("sumTopK top-2 of 1..4 = %v, want 7", got) } if got := sumTopK([]float64{5}, 3); got != 5 { t.Errorf("sumTopK fewer-than-k = %v, want 5", got) } if got := sumTopK(nil, 3); got != 0 { t.Errorf("sumTopK empty = %v, want 0", got) } } func TestRollUpCandidates_MultiMatchAlbumRanksFirst(t *testing.T) { // Album 10 (artist 100): three solid matches. Album 20 (artist 200): // one slightly-stronger single match. Sum-of-top-3 should rank the // multi-match album ahead of the single-match one. Similarity gaps are // wide enough that the ±0.1 jitter can't reorder the result. cands := []recommendation.Candidate{ makeCand(1, 10, 100, 0.8), makeCand(2, 10, 100, 0.8), makeCand(3, 10, 100, 0.8), makeCand(4, 20, 200, 0.95), } albums, _ := rollUpCandidates(cands, testUserID, "2026-06-11", fixedNow) if len(albums) < 2 { t.Fatalf("want >=2 albums, got %d", len(albums)) } if byteOf(albums[0]) != 10 { t.Errorf("top album = %d, want 10 (multi-match beats single)", byteOf(albums[0])) } } func TestRollUpCandidates_PerArtistAlbumCap(t *testing.T) { // Artist 100 spans albums 10/11/12; the album row caps any one artist // at youMightLikeMaxAlbumsPerArtist (2). Artist 200's album 20 should // still appear. cands := []recommendation.Candidate{ makeCand(1, 10, 100, 0.9), makeCand(2, 11, 100, 0.85), makeCand(3, 12, 100, 0.8), makeCand(4, 20, 200, 0.7), } albums, _ := rollUpCandidates(cands, testUserID, "2026-06-11", fixedNow) from100 := countWithByte(albums, 10) + countWithByte(albums, 11) + countWithByte(albums, 12) if from100 > youMightLikeMaxAlbumsPerArtist { t.Errorf("artist 100 contributed %d albums, want <= %d", from100, youMightLikeMaxAlbumsPerArtist) } if countWithByte(albums, 20) != 1 { t.Errorf("album 20 (artist 200) should survive the cap; got %d", countWithByte(albums, 20)) } } func TestRollUpCandidates_ArtistRollupDistinct(t *testing.T) { // Three artists; the artist roll-up should surface all three distinct // artist IDs (no per-artist cap on the artist row). cands := []recommendation.Candidate{ makeCand(1, 10, 100, 0.9), makeCand(2, 11, 101, 0.6), makeCand(3, 12, 102, 0.3), } _, artists := rollUpCandidates(cands, testUserID, "2026-06-11", fixedNow) seen := map[byte]bool{} for _, a := range artists { seen[byteOf(a)] = true } for _, want := range []byte{100, 101, 102} { if !seen[want] { t.Errorf("artist %d missing from roll-up", want) } } } func TestCapAlbumsPerArtist_DropsBeyondCap(t *testing.T) { mk := func(albumN, artistN int) (pgtype.UUID, pgtype.UUID) { var al, ar pgtype.UUID al.Valid, ar.Valid = true, true al.Bytes[15], ar.Bytes[15] = byte(albumN), byte(artistN) return al, ar } a10, ar1 := mk(10, 1) a11, _ := mk(11, 1) a12, _ := mk(12, 1) a20, ar2 := mk(20, 2) albumArtist := map[pgtype.UUID]pgtype.UUID{a10: ar1, a11: ar1, a12: ar1, a20: ar2} in := []scoredEntity{{a10, 3}, {a11, 2}, {a12, 1}, {a20, 0.5}} got := capAlbumsPerArtist(in, albumArtist, 2) if len(got) != 3 { t.Fatalf("len = %d, want 3 (artist 1 capped at 2 + artist 2's one)", len(got)) } // Highest-scored two of artist 1 (albums 10, 11) kept; 12 dropped. if byteOf(got[2].id) != 20 { t.Errorf("third survivor = %d, want 20", byteOf(got[2].id)) } }