Files
minstrel/internal/playlists/system_mixes_test.go
T
bvandeusen 48f288e2e5
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 4m39s
feat(mixes): tiered rebuilds for New for you + First listens (rule #131)
Both mixes move from a single hard eligibility rule to the tiered
ladder, with their tier stamped onto playlist_tracks.pick_kind via the
#1270 provenance pipeline.

New for you (#1267) — consume on play, degrade by stepping back:
- "Consumed" = any track attempted >=30s; played albums leave the mix
  at the next build instead of crowding it until the calendar window
  expires.
- Tier 1: unconsumed albums added <30d by direct-affinity artists.
  Tier 2: unconsumed affinity albums from the wider 30-90d window —
  added while you weren't looking. Tier 3: any unconsumed album added
  <90d, newest first.

First listens (#1268) — track-level "attempted" threshold:
- A 2-second accidental brush no longer disqualifies a whole album;
  "attempted" is duration_played_ms >= 30000 per track.
- Tier 1: albums with zero attempted tracks. Tier 2: barely-attempted
  albums (<=25% of tracks reached 30s), minus the attempted tracks
  themselves. The artist-affinity ordering signal also moves to the
  >=30s definition so skip-only contact doesn't read as trust.

Producer plumbing: fetch adapters map the tier column onto pick kinds,
finishMix propagates PickKind into the persisted candidates, and
rotateForDay now rotates within contiguous same-pick-kind blocks so
daily rotation can't hoist tier-3 filler above tier-1's exact fits
(untiered pools are one block — original behavior).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-03 08:54:20 -04:00

105 lines
3.3 KiB
Go

package playlists
import (
"testing"
"github.com/jackc/pgx/v5/pgtype"
)
func mixTrack(b byte, kind string) discoverTrack {
return discoverTrack{ID: pgtype.UUID{Bytes: [16]byte{b}, Valid: true}, PickKind: kind}
}
func idsOf(pool []discoverTrack) []byte {
out := make([]byte, len(pool))
for i, t := range pool {
out[i] = t.ID.Bytes[0]
}
return out
}
func TestRotateForDay_RotatesWithinTierBlocks(t *testing.T) {
// Tiered pools arrive tier-ordered; rotation must vary the daily
// slice WITHIN each tier without hoisting tier-3 filler above
// tier-1's exact fits (#1267).
pool := []discoverTrack{
mixTrack(1, pickKindTier1), mixTrack(2, pickKindTier1), mixTrack(3, pickKindTier1),
mixTrack(4, pickKindTier2), mixTrack(5, pickKindTier2),
mixTrack(6, pickKindTier3),
}
u := pgtype.UUID{Bytes: [16]byte{42}, Valid: true}
got := rotateForDay(pool, u, "2026-07-03")
if len(got) != len(pool) {
t.Fatalf("len = %d, want %d", len(got), len(pool))
}
wantKinds := []string{
pickKindTier1, pickKindTier1, pickKindTier1,
pickKindTier2, pickKindTier2, pickKindTier3,
}
for i, k := range wantKinds {
if got[i].PickKind != k {
t.Fatalf("pos %d kind = %q, want %q (tier order broken: %v)",
i, got[i].PickKind, k, idsOf(got))
}
}
// Each block is a rotation of its input: contiguity check for the
// 3-element tier-1 block (successor relation preserved cyclically).
wantOrder := map[byte]byte{1: 2, 2: 3, 3: 1}
for i := 0; i < 2; i++ {
if got[i+1].ID.Bytes[0] != wantOrder[got[i].ID.Bytes[0]] {
t.Errorf("tier1 block is not a rotation: %v", idsOf(got[:3]))
break
}
}
// Determinism within a day.
again := rotateForDay(pool, u, "2026-07-03")
for i := range got {
if got[i].ID != again[i].ID {
t.Fatal("rotation must be deterministic for the same (user, day)")
}
}
}
func TestRotateForDay_UntieredPoolIsWholeRotation(t *testing.T) {
// Untiered pools (all PickKind "") are one block — the original
// whole-pool rotation semantics.
pool := []discoverTrack{mixTrack(1, ""), mixTrack(2, ""), mixTrack(3, ""), mixTrack(4, "")}
got := rotateForDay(pool, pgtype.UUID{Bytes: [16]byte{7}, Valid: true}, "2026-07-03")
if len(got) != 4 {
t.Fatalf("len = %d, want 4", len(got))
}
wantNext := map[byte]byte{1: 2, 2: 3, 3: 4, 4: 1}
for i := 0; i < 3; i++ {
cur, next := got[i].ID.Bytes[0], got[i+1].ID.Bytes[0]
if wantNext[cur] != next {
t.Errorf("not a rotation of the input: %v", idsOf(got))
break
}
}
}
func TestFinishMix_PropagatesPickKind(t *testing.T) {
// Tier stamps ride discoverTrack through diversify/truncate into
// the rankedCandidates that insertSystemPlaylist persists (#1267).
pool := []discoverTrack{mixTrack(1, pickKindTier1), mixTrack(2, pickKindTier2)}
got := finishMix(pool, false)
if len(got) != 2 {
t.Fatalf("len = %d, want 2", len(got))
}
if got[0].PickKind != pickKindTier1 || got[1].PickKind != pickKindTier2 {
t.Errorf("pick kinds = %q, %q — want tier1, tier2", got[0].PickKind, got[1].PickKind)
}
}
func TestPickKindForMixTier(t *testing.T) {
cases := []struct {
tier int32
want string
}{{1, pickKindTier1}, {2, pickKindTier2}, {3, pickKindTier3}, {9, pickKindTier3}}
for _, c := range cases {
if got := pickKindForMixTier(c.tier); got != c.want {
t.Errorf("pickKindForMixTier(%d) = %q, want %q", c.tier, got, c.want)
}
}
}