test: add recommendation scoring profile tests for weights, taste, and ranking
This commit is contained in:
136
internal/anime/recommendations/scoring_profile_extra_test.go
Normal file
136
internal/anime/recommendations/scoring_profile_extra_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user