Files
memby/server/internal/recommend/profile_test.go
T
2026-07-29 15:26:27 +12:00

211 lines
7.3 KiB
Go

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 TestBuildProfileDoesNotCountEveryEpisodeAsAnotherTasteVote(t *testing.T) {
history := []Item{
episode("ep2", "Second", "series", "Series", []string{"Drama"}),
episode("ep1", "First", "series", "Series", []string{"Drama"}),
item("movie", "Movie", "Movie", []string{"Comedy"}, 0),
}
profile := BuildProfile(history, nil)
if profile.GenreWeights["Drama"] != 1 {
t.Fatalf("repeated series weight = %v, want the newest occurrence only",
profile.GenreWeights["Drama"])
}
if !profile.Seen["ep1"] || !profile.Seen["ep2"] || !profile.Seen["series"] {
t.Fatalf("episode/series exclusions were lost: %+v", profile.Seen)
}
}
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 TestCollectionAffinityIsCaseInsensitive(t *testing.T) {
profile := Profile{
GenreWeights: map[string]float64{"Comedy": 2},
StudioWeights: map[string]float64{"Apple TV+": 0.5},
}
if got := profile.CollectionAffinity([]string{"comedy"}, []string{"apple tv+"}); got != 2.5 {
t.Fatalf("CollectionAffinity = %v, want 2.5", 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)
}
}