Files
mal/internal/anime/recommendations/recommendations_test.go

238 lines
6.7 KiB
Go

package recommendations
import (
"database/sql"
"mal/integrations/jikan"
"mal/internal/db"
"mal/internal/domain"
"slices"
"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 TestRerankRecommendationCandidatesSpreadsRepeatedGenres(t *testing.T) {
const sportsGenreID = 30
candidates := []recommendationCandidate{
{anime: testRecommendationAnime(1, sportsGenreID), score: 10},
{anime: testRecommendationAnime(2, sportsGenreID), score: 9.9},
{anime: testRecommendationAnime(3, sportsGenreID), score: 9.8},
{anime: testRecommendationAnime(4, sportsGenreID), score: 9.7},
{anime: testRecommendationAnime(5, sportsGenreID), score: 9.6},
{anime: testRecommendationAnime(6, 1), score: 9.5},
{anime: testRecommendationAnime(7, 2), score: 9.4},
{anime: testRecommendationAnime(8, 3), score: 9.3},
}
reranked := rerankRecommendationCandidates(candidates, 8)
if len(reranked) < 5 {
t.Fatalf("expected enough reranked candidates, got %d", len(reranked))
}
for i := 0; i <= len(reranked)-5; i++ {
if allHaveGenre(reranked[i:i+5], sportsGenreID) {
t.Fatalf("expected reranker to avoid five sports anime in a row, got %+v", animeIDs(reranked))
}
}
}
func TestCandidateScoreLimitTracksRequestedResultSize(t *testing.T) {
if got := candidateScoreLimit(TopPickLimit); got != TopPickLimit+candidateFetchBuffer {
t.Fatalf("expected top-pick scoring to fetch a small oversample, got %d", got)
}
if got := candidateScoreLimit(TopPicksLimit); got != candidateFetchLimit {
t.Fatalf("expected full top-picks scoring to keep existing cap, got %d", got)
}
if got := candidateScoreLimit(0); got != 0 {
t.Fatalf("expected zero result limit to skip scoring, got %d", got)
}
}
func testRecommendationAnime(id int, genreID int) jikan.Anime {
return jikan.Anime{
MalID: id,
Genres: []jikan.NamedEntity{{MalID: genreID, Name: "Genre"}},
}
}
func allHaveGenre(animes []domain.Anime, genreID int) bool {
for _, anime := range animes {
hasGenre := false
for _, genre := range anime.Genres {
if genre.MalID == genreID {
hasGenre = true
break
}
}
if !hasGenre {
return false
}
}
return true
}
func animeIDs(animes []domain.Anime) []int {
ids := make([]int, 0, len(animes))
for _, anime := range animes {
ids = append(ids, anime.MalID)
}
return ids
}
func hasGenreSearchQuery(queries []profileSearchQuery, genreID int) bool {
for _, query := range queries {
if slices.Contains(query.genreIDs, genreID) {
return true
}
}
return false
}
func hasStudioSearchQuery(queries []profileSearchQuery, studioID int) bool {
for _, query := range queries {
if query.studioID == studioID {
return true
}
}
return false
}