227 lines
6.1 KiB
Go
227 lines
6.1 KiB
Go
package anime
|
|
|
|
import (
|
|
"database/sql"
|
|
"mal/integrations/jikan"
|
|
"mal/internal/db"
|
|
"mal/internal/domain"
|
|
"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 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 {
|
|
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
|
|
}
|