diff --git a/internal/playlists/seed_selection_test.go b/internal/playlists/seed_selection_test.go new file mode 100644 index 00000000..6dc28136 --- /dev/null +++ b/internal/playlists/seed_selection_test.go @@ -0,0 +1,47 @@ +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) + } +} diff --git a/internal/playlists/system.go b/internal/playlists/system.go new file mode 100644 index 00000000..93549218 --- /dev/null +++ b/internal/playlists/system.go @@ -0,0 +1,67 @@ +// Package playlists' system.go implements the system-generated mix +// builder (M7 #352 slice 2). The cron loop and lazy fallback both call +// BuildSystemPlaylists; the helpers in this file (pickSeedArtists, +// pickRepresentativeTrack, tieBreakHash) are the pure parts. +package playlists + +import ( + "hash/fnv" + "sort" + + "github.com/jackc/pgx/v5/pgtype" +) + +// seedArtistRow mirrors the sqlc-generated PickSeedArtistsRow shape. +// Defined locally so unit tests don't need a real DB. +type seedArtistRow struct { + ArtistID pgtype.UUID + Score int64 +} + +// pickSeedArtistsFromRows projects sqlc rows into the seed list. The +// SQL already orders by score DESC + artist_id and LIMIT 3, so this is +// just a column projection — but pulling it into a function keeps the +// call-site readable and makes the post-fetch path testable without +// a database. +func pickSeedArtistsFromRows(rows []seedArtistRow) []pgtype.UUID { + out := make([]pgtype.UUID, 0, len(rows)) + for _, r := range rows { + out = append(out, r.ArtistID) + } + return out +} + +// tieBreakHash returns a deterministic 64-bit hash of (track_id, date_str). +// Used to break score ties in mix candidate ranking. Same inputs always +// produce the same output; different inputs almost always differ. +// +// Uses FNV-1a 64-bit (stdlib hash/fnv) — fast, deterministic, no extra +// dependencies. The exact hash isn't load-bearing; any deterministic +// 64-bit hash works. +func tieBreakHash(trackID pgtype.UUID, dateStr string) uint64 { + h := fnv.New64a() + if trackID.Valid { + _, _ = h.Write(trackID.Bytes[:]) + } + _, _ = h.Write([]byte(dateStr)) + return h.Sum64() +} + +// rankedCandidate is a (track_id, score) pair used during in-memory +// sorting before insert into playlist_tracks. T5 fills these from +// recommendation.Candidate scores. +type rankedCandidate struct { + TrackID pgtype.UUID + Score float64 +} + +// stableSortByScoreThenHash sorts candidates in-place by score DESC, +// breaking ties with tieBreakHash(track_id, dateStr). +func stableSortByScoreThenHash(cands []rankedCandidate, dateStr string) { + sort.SliceStable(cands, func(i, j int) bool { + if cands[i].Score != cands[j].Score { + return cands[i].Score > cands[j].Score + } + return tieBreakHash(cands[i].TrackID, dateStr) < tieBreakHash(cands[j].TrackID, dateStr) + }) +} diff --git a/internal/playlists/tiebreak_test.go b/internal/playlists/tiebreak_test.go new file mode 100644 index 00000000..db538ec2 --- /dev/null +++ b/internal/playlists/tiebreak_test.go @@ -0,0 +1,33 @@ +package playlists + +import ( + "testing" + + "github.com/jackc/pgx/v5/pgtype" +) + +func TestTieBreakHash_Deterministic(t *testing.T) { + id := pgtype.UUID{Bytes: [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, Valid: true} + a := tieBreakHash(id, "2026-05-04") + b := tieBreakHash(id, "2026-05-04") + if a != b { + t.Fatalf("same inputs gave different hashes: %d vs %d", a, b) + } +} + +func TestTieBreakHash_DifferentDateChangesHash(t *testing.T) { + id := pgtype.UUID{Bytes: [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, Valid: true} + may4 := tieBreakHash(id, "2026-05-04") + may5 := tieBreakHash(id, "2026-05-05") + if may4 == may5 { + t.Errorf("different dates should change hash; got %d for both", may4) + } +} + +func TestTieBreakHash_DifferentTrackChangesHash(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 tieBreakHash(a, "2026-05-04") == tieBreakHash(b, "2026-05-04") { + t.Errorf("different track ids should change hash for the same date") + } +}