184 lines
6.3 KiB
Go
184 lines
6.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 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)
|
||
|
|
}
|
||
|
|
}
|