package recommendation import ( "context" "math" "testing" "time" "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) func uuidN(n byte) pgtype.UUID { return pgtype.UUID{Bytes: [16]byte{15: n}, Valid: true} } func strPtr(s string) *string { return &s } func TestTasteProfile_Match(t *testing.T) { loved := uuidN(1) disliked := uuidN(2) unknown := uuidN(3) p := TasteProfile{ artists: map[pgtype.UUID]float64{loved: 8.0, disliked: -8.0}, tags: map[string]float64{"Jazz": 6.0, "Noise": -6.0}, } if m := p.Match(loved, strPtr("Jazz")); m <= 0.5 { t.Errorf("loved artist + loved tag = %.3f, want strongly positive", m) } if m := p.Match(disliked, strPtr("Noise")); m >= -0.5 { t.Errorf("disliked artist + disliked tag = %.3f, want strongly negative", m) } if m := p.Match(unknown, nil); m != 0 { t.Errorf("unknown artist, no genre = %.3f, want 0", m) } // Artist dominates (0.7 share): loved artist with an unknown tag is still // clearly positive. if m := p.Match(loved, strPtr("Unheard")); m <= 0 { t.Errorf("loved artist + unknown tag = %.3f, want positive", m) } // Output stays within [-1, 1] even with saturated inputs. for _, a := range []pgtype.UUID{loved, disliked, unknown} { m := p.Match(a, strPtr("Jazz")) if m < -1 || m > 1 { t.Errorf("Match out of [-1,1]: %.3f", m) } } } func TestTasteProfile_EmptyIsNeutral(t *testing.T) { var p TasteProfile // zero value: nil maps if m := p.Match(uuidN(1), strPtr("Jazz")); m != 0 { t.Errorf("empty profile Match = %.3f, want 0 (cold start neutral)", m) } } func TestScore_TasteTermAddsAndSubtracts(t *testing.T) { now := time.Now() zeroJitter := func() float64 { return 0.5 } // (0.5*2-1)=0 with any magnitude w := ScoringWeights{TasteWeight: 2.0} // all other weights 0 pos := Score(ScoringInputs{TasteMatchScore: 1.0}, w, now, zeroJitter) if !almostEq(pos, 2.0) { t.Errorf("positive taste: Score = %.3f, want 2.0", pos) } neg := Score(ScoringInputs{TasteMatchScore: -1.0}, w, now, zeroJitter) if !almostEq(neg, -2.0) { t.Errorf("negative taste: Score = %.3f, want -2.0 (demotes)", neg) } off := Score(ScoringInputs{TasteMatchScore: 1.0}, ScoringWeights{}, now, zeroJitter) if !almostEq(off, 0.0) { t.Errorf("TasteWeight 0: Score = %.3f, want 0 (no effect)", off) } } func almostEq(a, b float64) bool { return math.Abs(a-b) < 1e-9 } // TestLoadTasteProfile_RoundTrip seeds taste_profile rows and verifies the // reader hydrates them into a profile that scores a matching track positively. func TestLoadTasteProfile_RoundTrip(t *testing.T) { pool := newPool(t) ctx := context.Background() u := seedUser(t, pool, "taste-rt") art := seedArtist(t, pool, "Loved Artist", "") if _, err := pool.Exec(ctx, `INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, $3)`, u.ID, art.ID, 8.0); err != nil { t.Fatalf("seed taste artist: %v", err) } if _, err := pool.Exec(ctx, `INSERT INTO taste_profile_tags (user_id, tag, weight) VALUES ($1, $2, $3)`, u.ID, "Jazz", 6.0); err != nil { t.Fatalf("seed taste tag: %v", err) } p, err := LoadTasteProfile(ctx, dbq.New(pool), u.ID) if err != nil { t.Fatalf("load: %v", err) } if m := p.Match(art.ID, strPtr("Jazz")); m <= 0.5 { t.Errorf("round-trip Match = %.3f, want strongly positive", m) } if m := p.Match(uuidN(9), nil); m != 0 { t.Errorf("absent artist Match = %.3f, want 0", m) } }