test: add recommendation scoring profile tests for weights, taste, and ranking

This commit is contained in:
2026-06-24 16:12:02 +02:00
committed by Milas Holsting
parent 9026f96b04
commit 9141fe4f09

View File

@@ -0,0 +1,136 @@
package recommendations
import (
"math"
"testing"
"time"
"mal/integrations/jikan"
)
func TestProfileSearchRankWeightHasFloor(t *testing.T) {
if got := profileSearchRankWeight(0); got != 1 {
t.Fatalf("rank 0 weight = %f, want 1", got)
}
if got := profileSearchRankWeight(100); got != 0.35 {
t.Fatalf("rank 100 weight = %f, want floor 0.35", got)
}
}
func TestRankedCandidateRetrievalScoreUsesLogForCollaborativeSignal(t *testing.T) {
low := rankedCandidateRetrievalScore(1, 0)
high := rankedCandidateRetrievalScore(100, 0)
if high <= low {
t.Fatalf("expected higher collaborative score to rank higher, low=%f high=%f", low, high)
}
linearGrowth := 100.0 - 1.0
actualGrowth := high - low
if actualGrowth >= linearGrowth {
t.Fatalf("expected log scaling, growth=%f linear=%f", actualGrowth, linearGrowth)
}
}
func TestHasTasteMetadata(t *testing.T) {
if hasTasteMetadata(jikan.Anime{}) {
t.Fatalf("empty anime should not have taste metadata")
}
if !hasTasteMetadata(jikan.Anime{Studios: []jikan.NamedEntity{{MalID: 1}}}) {
t.Fatalf("studio metadata should count as taste metadata")
}
}
func TestRecommendationCandidateScoreAdjustments(t *testing.T) {
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
profile := userTasteProfile{prefersAiring: true, prefersRecent: true}
preferred := recommendationCandidateScoreAdjustments(now, profile, jikan.Anime{
Score: 9,
Popularity: 10,
Airing: true,
Year: 2026,
Aired: jikan.Aired{From: now.Add(-24 * time.Hour).Format(time.RFC3339)},
})
penalized := recommendationCandidateScoreAdjustments(now, profile, jikan.Anime{
Score: 9,
Year: 2000,
Status: "Not yet aired",
})
if preferred <= penalized {
t.Fatalf("expected preferred candidate to outscore penalized candidate, preferred=%f penalized=%f", preferred, penalized)
}
if !isRecentCandidate(now, 2024) || isRecentCandidate(now, 2010) {
t.Fatalf("recent candidate boundary failed")
}
if !isClassicCandidate(now, 2010) || isClassicCandidate(now, 2020) {
t.Fatalf("classic candidate boundary failed")
}
if !isFreshRelease(now, now.Add(-freshReleaseWindow+time.Hour).Format(time.RFC3339)) {
t.Fatalf("expected fresh release inside window")
}
if isFreshRelease(now, "not a date") {
t.Fatalf("invalid release timestamp should not be fresh")
}
}
func TestWeightedEntityMatchCountsAndScoresMatches(t *testing.T) {
matches, score := weightedEntityMatch(map[int]float64{1: 2.5, 3: 1.0}, []jikan.NamedEntity{
{MalID: 1, Name: "Action"},
{MalID: 2, Name: "Drama"},
{MalID: 3, Name: "Sports"},
})
if matches != 2 || score != 3.5 {
t.Fatalf("weightedEntityMatch = matches:%d score:%f, want 2 and 3.5", matches, score)
}
}
func TestAddEntityWeightsSkipsInvalidIDsAndAccumulates(t *testing.T) {
target := map[int]float64{1: 1.0}
addEntityWeights(target, []jikan.NamedEntity{{MalID: 0}, {MalID: 1}, {MalID: 2}}, 0.5)
if target[1] != 1.5 || target[2] != 0.5 {
t.Fatalf("entity weights = %#v, want accumulated valid ids", target)
}
if _, ok := target[0]; ok {
t.Fatalf("invalid id should not be added: %#v", target)
}
}
func TestStrongestWeightedEntitiesSortsByWeightThenID(t *testing.T) {
got := strongestWeightedEntities(map[int]float64{3: 1, 2: 2, 1: 2, 4: -1}, 3)
want := []weightedEntity{{id: 1, weight: 2}, {id: 2, weight: 2}, {id: 3, weight: 1}}
if len(got) != len(want) {
t.Fatalf("len(got) = %d, want %d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("got[%d] = %+v, want %+v", i, got[i], want[i])
}
}
}
func TestScoreRecommendationCandidateIncludesMatchCounts(t *testing.T) {
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
profile := userTasteProfile{
genres: map[int]float64{1: 1, 2: 1},
themes: map[int]float64{10: 1},
studios: map[int]float64{20: 1},
demographics: map[int]float64{30: 1},
}
candidate := scoreRecommendationCandidate(now, profile, jikan.Anime{
Genres: []jikan.NamedEntity{{MalID: 1}, {MalID: 2}},
Themes: []jikan.NamedEntity{{MalID: 10}},
Studios: []jikan.NamedEntity{{MalID: 20}},
Demographics: []jikan.NamedEntity{{MalID: 30}},
}, 0, 0)
if candidate.genreMatches != 2 || candidate.themeMatches != 1 || candidate.studioMatches != 1 || candidate.demographicMatches != 1 {
t.Fatalf("match counts = genres:%d themes:%d studios:%d demos:%d", candidate.genreMatches, candidate.themeMatches, candidate.studioMatches, candidate.demographicMatches)
}
if math.Abs(candidate.score) < 0.001 {
t.Fatalf("expected non-zero score for metadata matches")
}
}