f77a0699ec
Adds the third system playlist variant: 'discover'. Surfaces 100 tracks the operator has not played and not liked, biased toward artists they rarely play (< 10 plays) and complemented by tracks liked by other users plus a random unheard sample. Cold start collapses to all-random; single-user servers redistribute the cross-user-likes allocation equally across the other two buckets. The build runs as a fourth phase inside BuildSystemPlaylists alongside For You and the seed-artist mixes. Same daily-deterministic md5(track_id || dateStr) ordering as the other variants. Per-album (<=2) and per-artist (<=3) caps prevent collapse onto a single prolific artist. Buckets interleave round-robin so the playlist order doesn't front-load one bucket's flavour. Post-commit collage generation (already wired) gives Discover the same 4-cell cover treatment as the other system playlists — no new collage code needed. Tests cover the slot-redistribution table (all-available, cold-start, single-user, partial-dormant, fully-empty), the per-album/per-artist caps, the round-robin interleave, and a DB-backed cold-start integration test that asserts a Discover playlist lands with non-zero tracks. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
140 lines
4.1 KiB
Go
140 lines
4.1 KiB
Go
package playlists
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
// uuidN generates a deterministic pgtype.UUID from an integer for cap tests.
|
|
func uuidN(n int) pgtype.UUID {
|
|
var u pgtype.UUID
|
|
u.Valid = true
|
|
u.Bytes[15] = byte(n)
|
|
u.Bytes[14] = byte(n >> 8)
|
|
return u
|
|
}
|
|
|
|
func TestRedistributeSlots_AllAvailable(t *testing.T) {
|
|
got := redistributeSlots([]bucketRequest{
|
|
{want: 40, available: 100},
|
|
{want: 30, available: 100},
|
|
{want: 30, available: 100},
|
|
})
|
|
if got[0] != 40 || got[1] != 30 || got[2] != 30 {
|
|
t.Errorf("got %v, want [40, 30, 30]", got)
|
|
}
|
|
}
|
|
|
|
func TestRedistributeSlots_ColdStart(t *testing.T) {
|
|
// Both signal buckets empty; all 100 slots roll into random.
|
|
got := redistributeSlots([]bucketRequest{
|
|
{want: 40, available: 0},
|
|
{want: 30, available: 0},
|
|
{want: 30, available: 200},
|
|
})
|
|
if got[0] != 0 || got[1] != 0 {
|
|
t.Errorf("dormant/cross-user should be 0; got %v", got)
|
|
}
|
|
if got[2] != 100 {
|
|
t.Errorf("random = %d, want 100 (cold start fills from random)", got[2])
|
|
}
|
|
}
|
|
|
|
func TestRedistributeSlots_SingleUser(t *testing.T) {
|
|
// Cross-user empty; 30 slots split 15/15 between dormant + random.
|
|
got := redistributeSlots([]bucketRequest{
|
|
{want: 40, available: 100},
|
|
{want: 30, available: 0},
|
|
{want: 30, available: 100},
|
|
})
|
|
if got[1] != 0 {
|
|
t.Errorf("cross-user = %d, want 0", got[1])
|
|
}
|
|
// dormant + random = 40+15 + 30+15 = 100; order of distribution
|
|
// can put the +1 on either side if the deficit was odd, but 30
|
|
// is even so 15/15.
|
|
if got[0] != 55 || got[2] != 45 {
|
|
t.Errorf("got %v, want [55, 0, 45]", got)
|
|
}
|
|
}
|
|
|
|
func TestRedistributeSlots_PartialDormant(t *testing.T) {
|
|
// Dormant returns 25 of 40; deficit = 15, splits 7+8 across peers.
|
|
got := redistributeSlots([]bucketRequest{
|
|
{want: 40, available: 25},
|
|
{want: 30, available: 100},
|
|
{want: 30, available: 100},
|
|
})
|
|
if got[0] != 25 {
|
|
t.Errorf("dormant = %d, want 25 (capped at available)", got[0])
|
|
}
|
|
sum := got[0] + got[1] + got[2]
|
|
if sum != 100 {
|
|
t.Errorf("total = %d, want 100", sum)
|
|
}
|
|
// got[1] and got[2] should each be 30 + (7 or 8).
|
|
if got[1] < 37 || got[1] > 38 {
|
|
t.Errorf("cross-user = %d, want 37 or 38", got[1])
|
|
}
|
|
if got[2] < 37 || got[2] > 38 {
|
|
t.Errorf("random = %d, want 37 or 38", got[2])
|
|
}
|
|
}
|
|
|
|
func TestRedistributeSlots_TotalLibraryEmpty(t *testing.T) {
|
|
// All buckets empty (degenerate library).
|
|
got := redistributeSlots([]bucketRequest{
|
|
{want: 40, available: 0},
|
|
{want: 30, available: 0},
|
|
{want: 30, available: 0},
|
|
})
|
|
if got[0] != 0 || got[1] != 0 || got[2] != 0 {
|
|
t.Errorf("got %v, want [0, 0, 0]", got)
|
|
}
|
|
}
|
|
|
|
func TestCapByAlbumAndArtist_AlbumCap(t *testing.T) {
|
|
rows := []discoverTrack{
|
|
{ID: uuidN(1), AlbumID: uuidN(10), ArtistID: uuidN(100)},
|
|
{ID: uuidN(2), AlbumID: uuidN(10), ArtistID: uuidN(101)},
|
|
{ID: uuidN(3), AlbumID: uuidN(10), ArtistID: uuidN(102)}, // 3rd from album 10 → drop
|
|
{ID: uuidN(4), AlbumID: uuidN(11), ArtistID: uuidN(103)},
|
|
}
|
|
got := capByAlbumAndArtist(rows)
|
|
if len(got) != 3 {
|
|
t.Errorf("len = %d, want 3 (album cap drops the 3rd from album 10)", len(got))
|
|
}
|
|
}
|
|
|
|
func TestCapByAlbumAndArtist_ArtistCap(t *testing.T) {
|
|
rows := []discoverTrack{
|
|
{ID: uuidN(1), AlbumID: uuidN(10), ArtistID: uuidN(100)},
|
|
{ID: uuidN(2), AlbumID: uuidN(11), ArtistID: uuidN(100)},
|
|
{ID: uuidN(3), AlbumID: uuidN(12), ArtistID: uuidN(100)},
|
|
{ID: uuidN(4), AlbumID: uuidN(13), ArtistID: uuidN(100)}, // 4th by artist → drop
|
|
{ID: uuidN(5), AlbumID: uuidN(14), ArtistID: uuidN(101)},
|
|
}
|
|
got := capByAlbumAndArtist(rows)
|
|
if len(got) != 4 {
|
|
t.Errorf("len = %d, want 4 (artist cap drops the 4th by artist 100)", len(got))
|
|
}
|
|
}
|
|
|
|
func TestInterleaveBuckets_RoundRobin(t *testing.T) {
|
|
a := []discoverTrack{{ID: uuidN(1)}, {ID: uuidN(2)}}
|
|
b := []discoverTrack{{ID: uuidN(10)}, {ID: uuidN(20)}}
|
|
c := []discoverTrack{{ID: uuidN(100)}}
|
|
got := interleaveBuckets(a, b, c)
|
|
if len(got) != 5 {
|
|
t.Fatalf("len = %d, want 5", len(got))
|
|
}
|
|
// Expected order: a[0], b[0], c[0], a[1], b[1].
|
|
wantOrder := []byte{1, 10, 100, 2, 20}
|
|
for i, w := range wantOrder {
|
|
if got[i].ID.Bytes[15] != w {
|
|
t.Errorf("got[%d].ID = %d, want %d", i, got[i].ID.Bytes[15], w)
|
|
}
|
|
}
|
|
}
|