package recommend import ( "encoding/json" "testing" ) func item(id, name, itemType string, genres []string, rating float64) Item { return Item{ID: id, Name: name, Type: itemType, Genres: genres, CommunityRating: rating} } func episode(id, name, seriesID, seriesName string, genres []string) Item { it := item(id, name, "Episode", genres, 0) it.SeriesID = seriesID it.SeriesName = seriesName return it } func TestBuildProfileWeightsRecentHistoryHigher(t *testing.T) { history := []Item{ item("1", "Newest", "Movie", []string{"Science Fiction"}, 8), item("2", "Older", "Movie", []string{"Comedy"}, 8), } profile := BuildProfile(history, nil) if profile.GenreWeights["Science Fiction"] <= profile.GenreWeights["Comedy"] { t.Fatalf("recent genre should outweigh older: %+v", profile.GenreWeights) } } func TestBuildProfileSeedsSeriesRatherThanEpisode(t *testing.T) { history := []Item{ episode("ep1", "Good News", "sev", "Severance", []string{"Drama"}), } profile := BuildProfile(history, nil) if len(profile.Seeds) != 1 { t.Fatalf("expected one seed, got %+v", profile.Seeds) } if profile.Seeds[0].ID != "sev" || profile.Seeds[0].Name != "Severance" { t.Fatalf("expected the series as seed, got %+v", profile.Seeds[0]) } // The series must count as seen, or we would recommend a show already in progress. if !profile.Seen["sev"] { t.Fatal("series id should be marked seen") } } func TestBuildProfileDeduplicatesSeeds(t *testing.T) { history := []Item{ episode("ep2", "Half Loop", "sev", "Severance", nil), episode("ep1", "Good News", "sev", "Severance", nil), item("m1", "Dune", "Movie", nil, 0), } profile := BuildProfile(history, nil) if len(profile.Seeds) != 2 { t.Fatalf("expected 2 distinct seeds, got %d: %+v", len(profile.Seeds), profile.Seeds) } } func TestFavoritesContributeLessThanAFreshPlay(t *testing.T) { fromHistory := BuildProfile([]Item{item("1", "A", "Movie", []string{"Horror"}, 0)}, nil) fromFavorite := BuildProfile(nil, []Item{item("2", "B", "Movie", []string{"Horror"}, 0)}) if fromFavorite.GenreWeights["Horror"] >= fromHistory.GenreWeights["Horror"] { t.Fatal("a favourite should weigh less than the most recent play") } } func TestTopGenresIsDeterministicOnTies(t *testing.T) { profile := Profile{GenreWeights: map[string]float64{"Western": 1, "Action": 1, "Drama": 2}} for range 20 { got := profile.TopGenres(3) want := []string{"Drama", "Action", "Western"} for i := range want { if got[i] != want[i] { t.Fatalf("unstable ordering: got %v, want %v", got, want) } } } } func TestScoreExcludesWhatTheUserAlreadySaw(t *testing.T) { profile := BuildProfile([]Item{item("seen", "Seen", "Movie", []string{"Drama"}, 0)}, nil) if score := profile.Score(item("seen", "Seen", "Movie", []string{"Drama"}, 8)); score >= 0 { t.Fatalf("watched item should be excluded, scored %v", score) } inProgress := item("new", "New", "Movie", []string{"Drama"}, 8) inProgress.UserData.PlaybackPositionTicks = 500 if score := profile.Score(inProgress); score >= 0 { t.Fatalf("in-progress item should be excluded, scored %v", score) } } func TestScoreExcludesEpisodesOfASeriesInProgress(t *testing.T) { profile := BuildProfile([]Item{episode("ep1", "Pilot", "sev", "Severance", []string{"Drama"})}, nil) candidate := episode("ep9", "Finale", "sev", "Severance", []string{"Drama"}) if score := profile.Score(candidate); score >= 0 { t.Fatalf("another episode of a watched series should be excluded, scored %v", score) } } func TestScoreDoesNotRewardGenreStuffing(t *testing.T) { profile := Profile{ GenreWeights: map[string]float64{"Drama": 1, "Action": 1, "Comedy": 1, "Horror": 1}, StudioWeights: map[string]float64{}, Seen: map[string]bool{}, } focused := item("a", "Focused", "Movie", []string{"Drama"}, 0) stuffed := item("b", "Stuffed", "Movie", []string{"Drama", "Action", "Comedy", "Horror"}, 0) // The stuffed title still scores higher — it genuinely matches more of the profile — // but the sqrt penalty must keep it from scoring 4x the focused one. if profile.Score(stuffed) >= 4*profile.Score(focused) { t.Fatalf("genre stuffing was not penalised: focused=%v stuffed=%v", profile.Score(focused), profile.Score(stuffed)) } } func TestRankOrdersByAffinityAndDropsDuplicates(t *testing.T) { profile := BuildProfile([]Item{item("h", "History", "Movie", []string{"Science Fiction"}, 0)}, nil) candidates := []Item{ item("c1", "Comedy Pick", "Movie", []string{"Comedy"}, 9), item("c2", "Sci-Fi Pick", "Movie", []string{"Science Fiction"}, 5), item("c2", "Sci-Fi Pick (dupe)", "Movie", []string{"Science Fiction"}, 5), item("h", "History", "Movie", []string{"Science Fiction"}, 10), } ranked := Rank(profile, candidates, 10) if len(ranked) != 2 { t.Fatalf("expected 2 results (dupe collapsed, watched dropped), got %d: %+v", len(ranked), ranked) } if ranked[0].ID != "c2" { t.Fatalf("genre affinity should beat a higher rating, got %q first", ranked[0].ID) } } func TestRankRespectsLimit(t *testing.T) { profile := Profile{GenreWeights: map[string]float64{"Drama": 1}, Seen: map[string]bool{}} candidates := make([]Item, 0, 30) for i := range 30 { candidates = append(candidates, item(string(rune('a'+i)), "Title", "Movie", []string{"Drama"}, 5)) } if got := len(Rank(profile, candidates, 8)); got != 8 { t.Fatalf("limit not applied: got %d", got) } } func TestDecodeKeepsRawPayload(t *testing.T) { raw := json.RawMessage(`{"Id":"1","Name":"Dune","Type":"Movie","Genres":["Science Fiction"],"ImageTags":{"Primary":"abc"}}`) items := Decode([]json.RawMessage{raw, json.RawMessage(`{"broken":`), json.RawMessage(`{"Name":"no id"}`)}) if len(items) != 1 { t.Fatalf("expected malformed and id-less items to be skipped, got %d", len(items)) } // The raw payload must survive untouched: it carries image tags the TV needs and // that this package never models. if string(items[0].Raw) != string(raw) { t.Fatalf("raw payload was altered: %s", items[0].Raw) } } func TestFilterUnseenPreservesEmbyOrdering(t *testing.T) { profile := BuildProfile([]Item{item("seen", "Seen", "Movie", nil, 0)}, nil) candidates := []Item{ item("seen", "Seen", "Movie", nil, 0), item("b", "Second", "Movie", nil, 0), item("a", "First", "Movie", nil, 0), } got := FilterUnseen(profile, candidates, 10) if len(got) != 2 || got[0].ID != "b" || got[1].ID != "a" { t.Fatalf("ordering not preserved: %+v", got) } }