Files
minstrel/internal/playlists/discover_test.go
T
bvandeusen 5faa57634b
test-go / test (push) Successful in 31s
test-web / test (push) Successful in 38s
test-go / integration (push) Successful in 4m36s
feat(metrics): provenance as standard — pick_kind for all system mixes
The #1249 mechanism (stamp WHY a track is in the snapshot at build
time, freeze it onto the play at ingestion, break it down in metrics)
generalizes from a For You one-off to the standard for every system
mix (#1270):

- Migration 0039 widens both pick_kind CHECKs (drop + re-add in the
  same change) to taste/fresh + Discover's dormant/cross_user/random
  + tier1-3 for the rule-#131 eligibility ladders.
- GetForYouPickKindForTrack becomes GetSystemPickKindForTrack
  (user, variant, track); ingestion stamps any systemPlaylistSources
  play from its own variant's live snapshot, live + offline paths.
- Discover stamps its candidate bucket on discoverTrack before the
  interleave, making the 40/30/30 allocation measurable; dedup keeps
  the taking bucket's stamp.
- Metrics replace the for_you special-case with one pick-kind
  vocabulary — any family with attributed plays gets a breakdown,
  future stamping mixes need no metrics change.
- Web: breakdown sub-rows are now toggled per surface (collapsed by
  default) so eight stamping mixes don't swamp the card.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-02 23:26:52 -04:00

188 lines
6.0 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)
}
}
}
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)
}
}
}