package recommendation import ( "math/rand" "testing" "time" "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) func cand(id string, in ScoringInputs) Candidate { t := dbq.Track{Title: id} _ = t.ID.Scan("00000000-0000-0000-0000-" + id) // 12-char id padded return Candidate{Track: t, Inputs: in} } func TestShuffle_LikedRanksAboveUnliked(t *testing.T) { cs := []Candidate{ cand("000000000001", ScoringInputs{IsGeneralLiked: false}), cand("000000000002", ScoringInputs{IsGeneralLiked: true}), } out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10, DiversityCaps{}) if out[0].Track.Title != "000000000002" { t.Errorf("liked track did not rank first: %+v", out) } } func TestShuffle_HighSkipRanksLast(t *testing.T) { cs := []Candidate{ cand("000000000001", ScoringInputs{PlayCount: 10, SkipCount: 10}), // ratio 1.0 cand("000000000002", ScoringInputs{PlayCount: 10, SkipCount: 0}), // ratio 0 cand("000000000003", ScoringInputs{PlayCount: 10, SkipCount: 5}), // ratio 0.5 } out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10, DiversityCaps{}) if out[0].Track.Title != "000000000002" || out[2].Track.Title != "000000000001" { t.Errorf("skip-ratio ordering broken: %v", titles(out)) } } func TestShuffle_LimitTruncates(t *testing.T) { cs := make([]Candidate, 100) for i := range cs { cs[i] = cand("00000000000"+string(rune('a'+i%26)), ScoringInputs{}) } out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10, DiversityCaps{}) if len(out) != 10 { t.Errorf("len = %d, want 10", len(out)) } } func TestShuffle_JitterDoesNotFlipStructuralWinner(t *testing.T) { // Liked vs unliked: even with many random RNG seeds, liked NEVER ranks below unliked. r := rand.New(rand.NewSource(42)) for i := 0; i < 500; i++ { cs := []Candidate{ cand("000000000001", ScoringInputs{IsGeneralLiked: false}), cand("000000000002", ScoringInputs{IsGeneralLiked: true}), } out := Shuffle(cs, defaultWeights(), time.Now(), r.Float64, 10, DiversityCaps{}) if out[0].Track.Title != "000000000002" { t.Fatalf("iter %d: liked did not rank first; out=%v", i, titles(out)) } } } func TestShuffle_Empty_ReturnsEmpty(t *testing.T) { out := Shuffle(nil, defaultWeights(), time.Now(), fixedRNG(0.5), 10, DiversityCaps{}) if len(out) != 0 { t.Errorf("len = %d, want 0", len(out)) } } func titles(cs []Candidate) []string { out := make([]string, 0, len(cs)) for _, c := range cs { out = append(out, c.Track.Title) } return out } // pgtype-uuid placeholder: Candidate doesn't need a real UUID for these // pure tests; the Track.Title field is the human-readable handle. Track ID // is left as zero pgtype.UUID and never compared. var _ pgtype.UUID // One song on two releases is played once, in its better-scoring copy's place, // and a capped copy held back for pass two does not slip back in either. func TestShuffle_OneCopyPerSong(t *testing.T) { song := func(c Candidate, key string) Candidate { _ = c.Track.SongKey.Scan("00000000-0000-0000-0000-" + key) return c } album := pgtype.UUID{Bytes: [16]byte{9}, Valid: true} single := song(cand("000000000001", ScoringInputs{IsGeneralLiked: true}), "00000000000a") albumCut := song(cand("000000000002", ScoringInputs{}), "00000000000a") other := cand("000000000003", ScoringInputs{}) single.Track.AlbumID, albumCut.Track.AlbumID = album, pgtype.UUID{Bytes: [16]byte{8}, Valid: true} out := Shuffle([]Candidate{albumCut, single, other}, defaultWeights(), time.Now(), fixedRNG(0.5), 10, DiversityCaps{}) if got := titles(out); len(got) != 2 || got[0] != "000000000001" { t.Errorf("got %v, want the liked single once and the other song", got) } // The single is capped out of pass one; pass two takes one copy, not both. capped := cand("000000000004", ScoringInputs{IsGeneralLiked: true}) capped.Track.AlbumID = album out = Shuffle([]Candidate{capped, single, albumCut}, defaultWeights(), time.Now(), fixedRNG(0.5), 3, DiversityCaps{MaxPerAlbum: 1}) if len(out) != 2 { t.Errorf("got %v, want two tracks: one copy of the song", titles(out)) } } // A video rip held back from radio (M498 #5439) is never chosen, even to fill // the request. func TestShuffle_SkipsHeldBackRips(t *testing.T) { suspect := "suspect" fine := "fine" rip := cand("000000000001", ScoringInputs{IsGeneralLiked: true}) rip.Track.SourceVerdict = &suspect cleared := cand("000000000002", ScoringInputs{}) cleared.Track.SourceVerdict = &fine out := Shuffle([]Candidate{rip, cleared}, defaultWeights(), time.Now(), fixedRNG(0.5), 10, DiversityCaps{}) if got := titles(out); len(got) != 1 || got[0] != "000000000002" { t.Errorf("got %v, want only the track marked fine", got) } }