diff --git a/internal/anime/recommendations/scoring_profile_extra_test.go b/internal/anime/recommendations/scoring_profile_extra_test.go new file mode 100644 index 0000000..a815f71 --- /dev/null +++ b/internal/anime/recommendations/scoring_profile_extra_test.go @@ -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") + } +}