feat(server/m7-352): system mix helpers (tieBreakHash, pickSeedArtists)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user