package recommendation import ( "fmt" "testing" "github.com/jackc/pgx/v5/pgtype" ) // selectSuggestions is deliberately pure (no DB, no clock), so the rotation and // diversity rules from slice #2373 are covered here in the fast lane rather // than behind the integration gate. func seedID(n byte) pgtype.UUID { var u pgtype.UUID u.Bytes[0] = n u.Valid = true return u } // candidate builds a pool entry attributed to the given dominant seed. func candidate(mbid string, score float64, dominant byte) ArtistSuggestion { return ArtistSuggestion{ MBID: mbid, Name: mbid, Score: score, Attribution: []SeedContribution{ {ArtistID: seedID(dominant), Contribution: score}, }, } } // poolOf returns n candidates in descending score order, spread across // `seeds` distinct dominant seeds. func poolOf(n int, seeds int) []ArtistSuggestion { out := make([]ArtistSuggestion, 0, n) for i := 0; i < n; i++ { out = append(out, candidate(fmt.Sprintf("mbid-%02d", i), 1.0-float64(i)*0.01, byte(i%seeds))) } return out } func mbids(in []ArtistSuggestion) []string { out := make([]string, 0, len(in)) for _, s := range in { out = append(out, s.MBID) } return out } func TestSelectSuggestions_ReturnsPoolUnchangedWhenNotOverfetched(t *testing.T) { pool := poolOf(5, 5) got := selectSuggestions(pool, 12, "2026-08-01") if len(got) != 5 { t.Fatalf("len = %d, want 5 (nothing to rotate)", len(got)) } if got[0].MBID != "mbid-00" { t.Errorf("first = %q, want mbid-00", got[0].MBID) } } func TestSelectSuggestions_FillsTheRequestedLimit(t *testing.T) { got := selectSuggestions(poolOf(48, 8), 12, "2026-08-01") if len(got) != 12 { t.Fatalf("len = %d, want 12", len(got)) } } // The reported symptom: the deck must change day to day without the user // requesting anything. func TestSelectSuggestions_RotatesAcrossDays(t *testing.T) { pool := poolOf(48, 8) day1 := mbids(selectSuggestions(pool, 12, "2026-08-01")) day2 := mbids(selectSuggestions(pool, 12, "2026-08-02")) if fmt.Sprint(day1) == fmt.Sprint(day2) { t.Fatalf("deck identical across days: %v", day1) } // ...but stable WITHIN a day, or the surface would reshuffle on every // pull-to-refresh, which reads as broken rather than fresh. again := mbids(selectSuggestions(pool, 12, "2026-08-01")) if fmt.Sprint(day1) != fmt.Sprint(again) { t.Errorf("same day differed:\n %v\n %v", day1, again) } } // The strongest matches should never rotate out of sight. func TestSelectSuggestions_HeadIsStableTopScorers(t *testing.T) { pool := poolOf(48, 8) for _, day := range []string{"2026-08-01", "2026-08-02", "2026-09-15"} { got := selectSuggestions(pool, 12, day) if got[0].MBID != "mbid-00" { t.Errorf("day %s: first = %q, want mbid-00 (top score leads)", day, got[0].MBID) } } } // #2367 mechanism 4: all twelve slots could be neighbours of one artist. // // The pool is deliberately SKEWED — one seed owns the entire top of the score // ranking — because that is the only shape where a cap can bite. With scores // spread evenly across seeds the top-N is already diverse and the cap is // untestable (an earlier version of this test asserted against an even pool and // could not fail). func TestSelectSuggestions_CapsOneDominantSeed(t *testing.T) { const dominantRun = 20 pool := make([]ArtistSuggestion, 0, 48) for i := 0; i < dominantRun; i++ { // seed 0 owns the 20 best scores pool = append(pool, candidate(fmt.Sprintf("dom-%02d", i), 1.0-float64(i)*0.01, 0)) } for i := 0; i < 28; i++ { // seeds 1..9 hold everything below pool = append(pool, candidate(fmt.Sprintf("oth-%02d", i), 0.80-float64(i)*0.01, byte(1+i%9))) } got := selectSuggestions(pool, 12, "2026-08-01") if len(got) != 12 { t.Fatalf("len = %d, want 12", len(got)) } perSeed := map[pgtype.UUID]int{} for _, s := range got { perSeed[s.Attribution[0].ArtistID]++ } // Uncapped, the top 12 by score would be 12 of seed 0's neighbours. if n := perSeed[seedID(0)]; n > maxPerSeedFor(12) { t.Errorf("dominant seed owns %d of 12 slots, want <= %d: %v", n, maxPerSeedFor(12), mbids(got)) } if len(perSeed) < 4 { t.Errorf("deck spans only %d seeds, want >= 4: %v", len(perSeed), mbids(got)) } } // Diversity must not starve the deck (rule #131): a pool hanging off a single // seed should still fill, not collapse to maxPerSeed entries. func TestSelectSuggestions_SingleSeedPoolStillFills(t *testing.T) { got := selectSuggestions(poolOf(30, 1), 12, "2026-08-01") if len(got) != 12 { t.Fatalf("len = %d, want 12 (cap must not starve the deck)", len(got)) } if got[0].MBID != "mbid-00" { t.Errorf("first = %q, want mbid-00", got[0].MBID) } } // A candidate with no attribution has no seed to blame, so it must never be // held back by the diversity cap. func TestPartitionPerSeed_UnattributedNeverCapped(t *testing.T) { pool := []ArtistSuggestion{ candidate("a", 0.9, 1), candidate("b", 0.8, 1), {MBID: "orphan", Score: 0.1}, } preferred, overflow := partitionPerSeed(pool, 1) if len(preferred) != 2 || preferred[1].MBID != "orphan" { t.Errorf("preferred = %v, want [a orphan]", mbids(preferred)) } if len(overflow) != 1 || overflow[0].MBID != "b" { t.Errorf("overflow = %v, want [b]", mbids(overflow)) } } func TestPoolSizeFor_OverfetchesButIsBounded(t *testing.T) { if got := poolSizeFor(12); got != 48 { t.Errorf("poolSizeFor(12) = %d, want 48", got) } if got := poolSizeFor(50); got != suggestionPoolMax { t.Errorf("poolSizeFor(50) = %d, want the %d ceiling", got, suggestionPoolMax) } }