package playlists import ( "testing" "github.com/jackc/pgx/v5/pgtype" ) // pickSeedArtistsFromRows is the pure helper that turns sqlc rows into // the seed list. The DB-level query is exercised in system_test.go's // integration test; this unit test pins the post-fetch logic. func TestPickSeedArtistsFromRows_PreservesOrder(t *testing.T) { mk := func(b byte, score int64) seedArtistRow { return seedArtistRow{ ArtistID: pgtype.UUID{Bytes: [16]byte{b}, Valid: true}, Score: score, } } // Caller (SQL) already orders + limits; helper preserves order. rows := []seedArtistRow{mk(1, 100), mk(2, 50), mk(3, 25)} got := pickSeedArtistsFromRows(rows) if len(got) != 3 { t.Fatalf("len: got %d, want 3", len(got)) } if got[0].Bytes[0] != 1 || got[1].Bytes[0] != 2 || got[2].Bytes[0] != 3 { t.Errorf("expected [1,2,3] preserving input order; got %v", got) } } func TestPickSeedArtistsFromRows_FewerThanThree(t *testing.T) { mk := func(b byte, score int64) seedArtistRow { return seedArtistRow{ArtistID: pgtype.UUID{Bytes: [16]byte{b}, Valid: true}, Score: score} } rows := []seedArtistRow{mk(1, 100)} got := pickSeedArtistsFromRows(rows) if len(got) != 1 { t.Errorf("len: got %d, want 1", len(got)) } } func TestPickSeedArtistsFromRows_Empty(t *testing.T) { got := pickSeedArtistsFromRows(nil) if len(got) != 0 { t.Errorf("empty input should yield empty output; got %v", got) } } func TestPickKindForSeedTier(t *testing.T) { // The seed query's fallback tiers map onto the rule-#131 ladder: // fresh 7-day engagement is the exact desire, everything past the // 30-day step-back collapses into the far tier (#1255). cases := []struct { tier int32 want string }{ {0, pickKindTier1}, {1, pickKindTier2}, {2, pickKindTier3}, {3, pickKindTier3}, } for _, c := range cases { if got := pickKindForSeedTier(c.tier); got != c.want { t.Errorf("pickKindForSeedTier(%d) = %q, want %q", c.tier, got, c.want) } } } // userIDHash is the per-user, per-day hash that drives the daily- // determinism RNGs. Same family as tieBreakHash, just keyed on user // ID instead of track ID. func TestUserIDHash_Deterministic(t *testing.T) { u := pgtype.UUID{Bytes: [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, Valid: true} a := userIDHash(u, "2026-05-04") b := userIDHash(u, "2026-05-04") if a != b { t.Fatalf("same inputs gave different hashes: %d vs %d", a, b) } } func TestUserIDHash_DifferentDateChangesHash(t *testing.T) { u := pgtype.UUID{Bytes: [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, Valid: true} a := userIDHash(u, "2026-05-04") b := userIDHash(u, "2026-05-05") if a == b { t.Errorf("different dates should change hash; both = %d", a) } } func TestUserIDHash_DifferentUserChangesHash(t *testing.T) { a := pgtype.UUID{Bytes: [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, Valid: true} b := pgtype.UUID{Bytes: [16]byte{16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1}, Valid: true} if userIDHash(a, "2026-05-04") == userIDHash(b, "2026-05-04") { t.Errorf("different users should change hash") } } // pickDailySeeds shuffles the candidate pool daily-deterministically // and takes up to n — For-You uses n=forYouSeedCount for its // multi-seed blend (#1269), Songs-like uses n=3 via the // pickSeedArtistsForDay wrapper. Verifies determinism within a day, // variation across days, and graceful degradation on small pools. func TestPickDailySeeds_DeterministicWithinDay(t *testing.T) { u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} pool := []pgtype.UUID{ {Bytes: [16]byte{10}, Valid: true}, {Bytes: [16]byte{20}, Valid: true}, {Bytes: [16]byte{30}, Valid: true}, {Bytes: [16]byte{40}, Valid: true}, {Bytes: [16]byte{50}, Valid: true}, } a := pickDailySeeds(pool, u, "2026-05-04", 3) b := pickDailySeeds(pool, u, "2026-05-04", 3) if len(a) != 3 || len(b) != 3 { t.Fatalf("lens = %d, %d; want 3, 3", len(a), len(b)) } for i := range a { if a[i] != b[i] { t.Fatalf("same day should pick same seeds; got %v then %v", a, b) } } } func TestPickDailySeeds_VariesAcrossDays(t *testing.T) { u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} pool := []pgtype.UUID{ {Bytes: [16]byte{10}, Valid: true}, {Bytes: [16]byte{20}, Valid: true}, {Bytes: [16]byte{30}, Valid: true}, {Bytes: [16]byte{40}, Valid: true}, {Bytes: [16]byte{50}, Valid: true}, } firstPicks := map[[16]byte]bool{} for i := 1; i <= 30; i++ { date := "2026-05-" + twoDigits(i) firstPicks[pickDailySeeds(pool, u, date, 3)[0].Bytes] = true } if len(firstPicks) < 2 { t.Errorf("expected >=2 distinct lead seeds across 30 days; got %d", len(firstPicks)) } } func TestPickDailySeeds_SmallPool(t *testing.T) { u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} only := pgtype.UUID{Bytes: [16]byte{99}, Valid: true} got := pickDailySeeds([]pgtype.UUID{only}, u, "2026-05-04", 3) if len(got) != 1 || got[0] != only { t.Errorf("single-entry pool should return just that entry; got %v", got) } } func TestPickDailySeeds_EmptyPool(t *testing.T) { u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} if got := pickDailySeeds(nil, u, "2026-05-04", 3); len(got) != 0 { t.Errorf("empty pool should return nothing; got %v", got) } } // pickSeedArtistsForDay takes the user's top-5 candidate artists and // returns 3 of them via daily-deterministic shuffle. Verifies the // picker is deterministic within a day, varies across days, and // degrades gracefully when fewer than 3 or 5 candidates exist. func TestPickSeedArtistsForDay_DeterministicWithinDay(t *testing.T) { u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} pool := []pgtype.UUID{ {Bytes: [16]byte{10}, Valid: true}, {Bytes: [16]byte{20}, Valid: true}, {Bytes: [16]byte{30}, Valid: true}, {Bytes: [16]byte{40}, Valid: true}, {Bytes: [16]byte{50}, Valid: true}, } a := pickSeedArtistsForDay(pool, u, "2026-05-04") b := pickSeedArtistsForDay(pool, u, "2026-05-04") if len(a) != 3 || len(b) != 3 { t.Fatalf("expected 3 seeds; got %d / %d", len(a), len(b)) } for i := range a { if a[i] != b[i] { t.Fatalf("same day should produce same order; differs at i=%d", i) } } } func TestPickSeedArtistsForDay_VariesAcrossDays(t *testing.T) { u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} pool := []pgtype.UUID{ {Bytes: [16]byte{10}, Valid: true}, {Bytes: [16]byte{20}, Valid: true}, {Bytes: [16]byte{30}, Valid: true}, {Bytes: [16]byte{40}, Valid: true}, {Bytes: [16]byte{50}, Valid: true}, } // Collect the trio (as a sorted byte tuple) across many dates. // With C(5,3) = 10 possible trios, 30 dates should yield >=2 distinct sets. seen := map[[3]byte]bool{} for i := 1; i <= 30; i++ { got := pickSeedArtistsForDay(pool, u, "2026-05-"+twoDigits(i)) if len(got) != 3 { t.Fatalf("expected 3 seeds; got %d", len(got)) } // Sort the three byte values for set-equivalence comparison. v := [3]byte{got[0].Bytes[0], got[1].Bytes[0], got[2].Bytes[0]} if v[0] > v[1] { v[0], v[1] = v[1], v[0] } if v[1] > v[2] { v[1], v[2] = v[2], v[1] } if v[0] > v[1] { v[0], v[1] = v[1], v[0] } seen[v] = true } if len(seen) < 2 { t.Errorf("expected >=2 distinct seed trios across 30 days; got %d", len(seen)) } } func TestPickSeedArtistsForDay_FewerThanFive(t *testing.T) { u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} pool := []pgtype.UUID{ {Bytes: [16]byte{10}, Valid: true}, {Bytes: [16]byte{20}, Valid: true}, {Bytes: [16]byte{30}, Valid: true}, } got := pickSeedArtistsForDay(pool, u, "2026-05-04") if len(got) != 3 { t.Errorf("with 3 candidates, expected all 3 returned; got %d", len(got)) } } func TestPickSeedArtistsForDay_FewerThanThree(t *testing.T) { u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} pool := []pgtype.UUID{ {Bytes: [16]byte{10}, Valid: true}, {Bytes: [16]byte{20}, Valid: true}, } got := pickSeedArtistsForDay(pool, u, "2026-05-04") if len(got) != 2 { t.Errorf("with 2 candidates, expected 2 returned; got %d", len(got)) } } func TestPickSeedArtistsForDay_Empty(t *testing.T) { u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} got := pickSeedArtistsForDay(nil, u, "2026-05-04") if len(got) != 0 { t.Errorf("empty pool should return empty; got %d", len(got)) } } func twoDigits(n int) string { if n < 10 { return "0" + string(rune('0'+n)) } return string(rune('0'+n/10)) + string(rune('0'+n%10)) }