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) } } } func TestInterleaveBuckets_DedupsAcrossBuckets(t *testing.T) { // Single-user server scenario: crossUser bucket empty, dormant + // random share tracks. Without dedup, shared tracks land at // adjacent interleaved positions — the duplication users reported // on the Discover playlist in v2026.05.13.0. dormant := []discoverTrack{{ID: uuidN(1)}, {ID: uuidN(2)}, {ID: uuidN(3)}} crossUser := []discoverTrack{} // empty bucket, single-user server random := []discoverTrack{{ID: uuidN(1)}, {ID: uuidN(2)}, {ID: uuidN(4)}} got := interleaveBuckets(dormant, crossUser, random) // Tracks 1 and 2 appear in both dormant and random — must be // emitted once each (from dormant, the earlier bucket). Track 3 // is dormant-only, track 4 is random-only. if len(got) != 4 { t.Fatalf("len = %d, want 4 (3 unique from dormant + 1 unique from random)", len(got)) } wantOrder := []byte{1, 2, 3, 4} 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) } } } func TestInterleaveBuckets_PreservesBucketPickKind(t *testing.T) { // Bucket provenance (#1270) is stamped on discoverTrack before the // interleave and must survive it — including dedup, where a track in // two buckets keeps the stamp of the bucket it was taken from. dormant := []discoverTrack{ {ID: uuidN(1), PickKind: pickKindDormant}, {ID: uuidN(2), PickKind: pickKindDormant}, } random := []discoverTrack{ {ID: uuidN(1), PickKind: pickKindRandom}, // shared with dormant {ID: uuidN(3), PickKind: pickKindRandom}, } got := interleaveBuckets(dormant, nil, random) if len(got) != 3 { t.Fatalf("len = %d, want 3", len(got)) } want := map[byte]string{1: pickKindDormant, 2: pickKindDormant, 3: pickKindRandom} for _, tr := range got { if w := want[tr.ID.Bytes[15]]; tr.PickKind != w { t.Errorf("track %d pick_kind = %q, want %q", tr.ID.Bytes[15], tr.PickKind, w) } } }