48f288e2e5
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
105 lines
3.3 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|