Files
mal/internal/anime/recommendations_test.go

169 lines
4.5 KiB
Go

package anime
import (
"database/sql"
"mal/integrations/jikan"
"mal/internal/db"
"testing"
"time"
)
func TestRecommendationEntryWeightPrioritizesCommittedRecentHistory(t *testing.T) {
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
completed := recommendationEntryWeight(now, db.GetUserWatchListRow{
Status: "completed",
UpdatedAt: now.Add(-24 * time.Hour),
CurrentEpisode: sql.NullInt64{Int64: 12, Valid: true},
})
planned := recommendationEntryWeight(now, db.GetUserWatchListRow{
Status: "plan_to_watch",
UpdatedAt: now.Add(-24 * time.Hour),
})
if completed <= planned {
t.Fatalf("expected completed history to outrank planned history, got completed=%f planned=%f", completed, planned)
}
}
func TestBuildRecommendationSeedsFiltersUnsupportedStatuses(t *testing.T) {
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
seeds := buildRecommendationSeeds(now, []db.GetUserWatchListRow{
{AnimeID: 1, Status: "dropped", UpdatedAt: now},
{AnimeID: 2, Status: "watching", UpdatedAt: now},
{AnimeID: 3, Status: "completed", UpdatedAt: now},
})
if len(seeds) != 2 {
t.Fatalf("expected 2 valid seeds, got %d", len(seeds))
}
if seeds[0].animeID != 2 || seeds[1].animeID != 3 {
t.Fatalf("unexpected seed ordering: %+v", seeds)
}
}
func TestScoreRecommendationCandidateRewardsProfileOverlap(t *testing.T) {
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
profile := userTasteProfile{
genres: map[int]float64{
1: 2.0,
},
themes: map[int]float64{},
studios: map[int]float64{},
demographics: map[int]float64{},
}
matching := scoreRecommendationCandidate(now, profile, jikan.Anime{
MalID: 10,
Genres: []jikan.NamedEntity{{MalID: 1, Name: "Action"}},
Popularity: 100,
Score: 8.0,
}, 5.0, 0)
nonMatching := scoreRecommendationCandidate(now, profile, jikan.Anime{
MalID: 11,
Genres: []jikan.NamedEntity{{MalID: 2, Name: "Drama"}},
Popularity: 100,
Score: 8.0,
}, 5.0, 0)
if matching.score <= nonMatching.score {
t.Fatalf("expected matching candidate to score higher, got matching=%f nonMatching=%f", matching.score, nonMatching.score)
}
}
func TestBuildTasteProfileUsesSeedWeights(t *testing.T) {
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
profile := buildTasteProfile(
now,
[]recommendationSeed{
{animeID: 1, weight: 2.0},
{animeID: 2, weight: 0.5},
},
[]jikan.Anime{
{
MalID: 1,
Airing: true,
Year: 2026,
Genres: []jikan.NamedEntity{{MalID: 1, Name: "Action"}},
Themes: []jikan.NamedEntity{{MalID: 10, Name: "Team Sports"}},
Studios: []jikan.NamedEntity{{MalID: 20, Name: "Production I.G"}},
Demographics: []jikan.NamedEntity{{MalID: 30, Name: "Shounen"}},
},
{
MalID: 2,
Year: 2001,
Genres: []jikan.NamedEntity{{MalID: 2, Name: "Drama"}},
Themes: []jikan.NamedEntity{{MalID: 11, Name: "School"}},
Studios: []jikan.NamedEntity{{MalID: 21, Name: "Madhouse"}},
Demographics: []jikan.NamedEntity{{MalID: 31, Name: "Seinen"}},
},
},
)
if profile.genres[1] <= profile.genres[2] {
t.Fatalf("expected stronger seed genre to carry more weight, got profile=%+v", profile.genres)
}
if !profile.prefersAiring {
t.Fatal("expected weighted profile to prefer airing anime")
}
if !profile.prefersRecent {
t.Fatal("expected weighted profile to prefer recent anime")
}
}
func TestBuildProfileSearchQueriesIncludesTasteSignals(t *testing.T) {
profile := userTasteProfile{
genres: map[int]float64{
1: 2.0,
2: 1.5,
3: 0.2,
},
themes: map[int]float64{
10: 1.4,
},
studios: map[int]float64{
20: 1.0,
},
demographics: map[int]float64{
30: 1.2,
},
}
queries := buildProfileSearchQueries(profile)
if !hasGenreSearchQuery(queries, 1) {
t.Fatalf("expected strongest genre query, got %+v", queries)
}
if !hasGenreSearchQuery(queries, 10) {
t.Fatalf("expected theme query, got %+v", queries)
}
if !hasGenreSearchQuery(queries, 30) {
t.Fatalf("expected demographic query, got %+v", queries)
}
if !hasStudioSearchQuery(queries, 20) {
t.Fatalf("expected studio query, got %+v", queries)
}
}
func hasGenreSearchQuery(queries []profileSearchQuery, genreID int) bool {
for _, query := range queries {
for _, id := range query.genreIDs {
if id == genreID {
return true
}
}
}
return false
}
func hasStudioSearchQuery(queries []profileSearchQuery, studioID int) bool {
for _, query := range queries {
if query.studioID == studioID {
return true
}
}
return false
}