Files
minstrel/internal/playlists/seed_selection_test.go
T
bvandeusen c1d143cf4a
test-go / test (push) Successful in 33s
test-web / test (push) Successful in 43s
android / Build + lint + test (push) Failing after 1m29s
test-go / integration (push) Successful in 4m42s
feat(home): Songs-like → dedicated row + wider spread (#1491)
Promote the best-performing surface ("Songs like {artist}", ~8% skip /
~86% completion) out of the shared Playlists carousel into its own Home
row on both Android and web, and widen the daily build from 3 to 6 mixes
so the dedicated row shows a wider spread.

Server (internal/playlists):
- PickSeedArtists candidate pool 5 → 12; pickSeedArtistsForDay now takes
  songsLikeSeedCount (6) instead of a hardcoded 3. Graceful degradation
  and daily rotation preserved.

Android (HomeScreen.kt):
- New songsLikeSection + buildSongsLikeRow; PlaylistsRow takes a title so
  it renders both the "Playlists" and "Songs like…" rows. buildOnlineRow
  / orderedRealPlaylists no longer reserve the 3 songs-like slots.
  Offline shows cached mixes (available-first), hides the row when none.

Web (+page.svelte):
- Dedicated "Songs like…" row from songsLikeRow; dropped the 3-slot cap
  and removed songs-like from the Playlists carousel.

Tests: seed_selection_test.go, BuildPlaylistsRowTest.kt, page.test.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 20:41:08 -04:00

259 lines
8.4 KiB
Go

package playlists
import (
"sort"
"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=songsLikeSeedCount 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-12 candidate artists and
// returns songsLikeSeedCount of them via daily-deterministic shuffle.
// Verifies the picker is deterministic within a day, varies across days,
// and degrades gracefully when fewer than songsLikeSeedCount candidates
// exist. bigSeedPool is a pool comfortably larger than songsLikeSeedCount
// so the "returns exactly the count" cases have room.
func bigSeedPool(n int) []pgtype.UUID {
pool := make([]pgtype.UUID, 0, n)
for i := 0; i < n; i++ {
pool = append(pool, pgtype.UUID{Bytes: [16]byte{byte(10 * (i + 1))}, Valid: true})
}
return pool
}
// seedSetKey sorts the picked seeds' lead bytes into a stable string so
// two picks with the same members (any order) compare equal.
func seedSetKey(seeds []pgtype.UUID) string {
bs := make([]byte, 0, len(seeds))
for _, s := range seeds {
bs = append(bs, s.Bytes[0])
}
sort.Slice(bs, func(i, j int) bool { return bs[i] < bs[j] })
return string(bs)
}
func TestPickSeedArtistsForDay_DeterministicWithinDay(t *testing.T) {
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
pool := bigSeedPool(10)
a := pickSeedArtistsForDay(pool, u, "2026-05-04")
b := pickSeedArtistsForDay(pool, u, "2026-05-04")
if len(a) != songsLikeSeedCount || len(b) != songsLikeSeedCount {
t.Fatalf("expected %d seeds; got %d / %d", songsLikeSeedCount, 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 := bigSeedPool(10)
// Collect the seed set (order-independent) across many dates. With
// C(10, songsLikeSeedCount) combinations, 30 dates yield >=2 distinct sets.
seen := map[string]bool{}
for i := 1; i <= 30; i++ {
got := pickSeedArtistsForDay(pool, u, "2026-05-"+twoDigits(i))
if len(got) != songsLikeSeedCount {
t.Fatalf("expected %d seeds; got %d", songsLikeSeedCount, len(got))
}
seen[seedSetKey(got)] = true
}
if len(seen) < 2 {
t.Errorf("expected >=2 distinct seed sets across 30 days; got %d", len(seen))
}
}
func TestPickSeedArtistsForDay_FewerThanCount(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))
}