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 }