Files
memby/server/internal/recommend/engine_test.go
T
2026-07-27 21:06:51 +12:00

317 lines
9.6 KiB
Go

package recommend
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/url"
"strings"
"sync"
"testing"
"github.com/ponzischeme89/memby/server/internal/emby"
)
// fakeSource records the queries the engine makes and replays canned answers.
type fakeSource struct {
mu sync.Mutex
itemsByFilter map[string][]json.RawMessage
similar map[string][]json.RawMessage
itemsErr error
similarErr error
genreQueries []string
similarSeeds []string
}
type fakeCuratedLibrary struct {
byGenre map[string][]json.RawMessage
}
func (f *fakeCuratedLibrary) LibraryCandidates(
_ context.Context,
_ []string,
_ int,
) ([]json.RawMessage, error) {
return nil, nil
}
func (f *fakeCuratedLibrary) CuratedCandidates(
_ context.Context,
_ []string,
genres, studios []string,
_ int,
) ([]json.RawMessage, error) {
key := strings.Join(genres, "|")
if len(studios) > 0 {
key = "studio:" + studios[0]
}
return f.byGenre[key], nil
}
func (f *fakeSource) Items(_ context.Context, _ emby.Credentials, params url.Values) (*emby.ItemsResult, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.itemsErr != nil {
return nil, f.itemsErr
}
if genres := params.Get("Genres"); genres != "" {
f.genreQueries = append(f.genreQueries, genres)
}
key := params.Get("Filters")
return &emby.ItemsResult{Items: f.itemsByFilter[key]}, nil
}
func (f *fakeSource) Similar(_ context.Context, _ emby.Credentials, itemID string, _ url.Values) (*emby.ItemsResult, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.similarSeeds = append(f.similarSeeds, itemID)
if f.similarErr != nil {
return nil, f.similarErr
}
return &emby.ItemsResult{Items: f.similar[itemID]}, nil
}
func raw(id, name, itemType string, genres ...string) json.RawMessage {
quoted := make([]string, 0, len(genres))
for _, g := range genres {
quoted = append(quoted, `"`+g+`"`)
}
return json.RawMessage(`{"Id":"` + id + `","Name":"` + name + `","Type":"` + itemType +
`","Genres":[` + strings.Join(quoted, ",") + `],"CommunityRating":7.5}`)
}
func testEngine(source Source) *Engine {
engine := NewEngine(source, slog.New(slog.NewTextHandler(io.Discard, nil)))
engine.MinRowItems = 2
return engine
}
func TestBuildRowsProducesSimilarAndHistoryRows(t *testing.T) {
source := &fakeSource{
itemsByFilter: map[string][]json.RawMessage{
"IsResumable": {raw("ep1", "Good News", "Episode", "Drama")},
"IsPlayed": {raw("m1", "Dune", "Movie", "Science Fiction")},
"IsFavorite": {raw("m2", "Arrival", "Movie", "Science Fiction")},
"IsUnplayed": {
raw("c1", "Blade Runner", "Movie", "Science Fiction"),
raw("c2", "Solaris", "Movie", "Science Fiction"),
raw("c3", "Barbie", "Movie", "Comedy"),
},
},
similar: map[string][]json.RawMessage{
"ep1": {raw("s1", "Devs", "Series", "Drama"), raw("s2", "Mr Robot", "Series", "Drama")},
"m1": {raw("s3", "Foundation", "Series", "Science Fiction"), raw("s4", "Arrival II", "Movie", "Science Fiction")},
},
}
rows, err := testEngine(source).BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
if err != nil {
t.Fatalf("BuildRows: %v", err)
}
if len(rows) != 3 {
t.Fatalf("expected 2 similar rows + 1 history row, got %d: %+v", len(rows), rowTitles(rows))
}
if rows[0].Kind != "similar" || !strings.HasPrefix(rows[0].Title, "Because you watched ") {
t.Fatalf("unexpected first row: %+v", rows[0])
}
last := rows[len(rows)-1]
if last.Kind != "recommended" || last.Title != "Recommended from your watching history" {
t.Fatalf("unexpected history row: %+v", last)
}
if last.ID != "recommended" {
t.Fatalf("history row id should be stable, got %q", last.ID)
}
}
func TestBuildRowsQueriesTheProfilesTopGenres(t *testing.T) {
source := &fakeSource{
itemsByFilter: map[string][]json.RawMessage{
"IsPlayed": {
raw("m1", "Dune", "Movie", "Science Fiction"),
raw("m2", "Alien", "Movie", "Science Fiction", "Horror"),
},
"IsUnplayed": {raw("c1", "Solaris", "Movie", "Science Fiction")},
},
}
if _, err := testEngine(source).BuildRows(context.Background(), emby.Credentials{UserID: "u1"}); err != nil {
t.Fatalf("BuildRows: %v", err)
}
if len(source.genreQueries) != 1 {
t.Fatalf("expected a single OR'd genre query, got %v", source.genreQueries)
}
// Emby reads "|" as OR, so one query covers every top genre.
if !strings.HasPrefix(source.genreQueries[0], "Science Fiction") {
t.Fatalf("heaviest genre should lead the query, got %q", source.genreQueries[0])
}
}
func TestBuildRowsExcludesAlreadyWatchedFromSimilarRow(t *testing.T) {
source := &fakeSource{
itemsByFilter: map[string][]json.RawMessage{
"IsPlayed": {raw("m1", "Dune", "Movie", "Science Fiction")},
},
similar: map[string][]json.RawMessage{
// Emby suggests something the user already finished; it must not appear.
"m1": {raw("m1", "Dune", "Movie", "Science Fiction"), raw("s1", "Foundation", "Series", "Science Fiction")},
},
}
engine := testEngine(source)
engine.MinRowItems = 1
rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
if err != nil {
t.Fatalf("BuildRows: %v", err)
}
for _, row := range rows {
for _, item := range row.Items {
if strings.Contains(string(item), `"Id":"m1"`) {
t.Fatalf("row %q contained an already-watched item", row.ID)
}
}
}
}
func TestBuildRowsDropsRowsShorterThanTheMinimum(t *testing.T) {
source := &fakeSource{
itemsByFilter: map[string][]json.RawMessage{
"IsPlayed": {raw("m1", "Dune", "Movie", "Science Fiction")},
"IsUnplayed": {raw("c1", "Solaris", "Movie", "Science Fiction")},
},
similar: map[string][]json.RawMessage{
"m1": {raw("s1", "Foundation", "Series", "Science Fiction")},
},
}
engine := testEngine(source)
engine.MinRowItems = 5
rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
if err != nil {
t.Fatalf("BuildRows: %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected short rows to be dropped, got %v", rowTitles(rows))
}
}
func TestBuildRowsReturnsNothingForAUserWithNoHistory(t *testing.T) {
source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{}}
rows, err := testEngine(source).BuildRows(context.Background(), emby.Credentials{UserID: "new"})
if err != nil {
t.Fatalf("BuildRows: %v", err)
}
if len(rows) != 0 {
t.Fatalf("a new user should get no rows, got %v", rowTitles(rows))
}
if len(source.similarSeeds) != 0 {
t.Fatal("no seeds means no similarity lookups should be attempted")
}
}
func TestCuratedShowRowsAndItemsAreOrderedByViewingAffinity(t *testing.T) {
source := &fakeSource{
itemsByFilter: map[string][]json.RawMessage{
"IsPlayed": {
raw("history", "Funny History", "Episode", "Comedy"),
},
},
similar: map[string][]json.RawMessage{},
}
engine := testEngine(source)
engine.Library = &fakeCuratedLibrary{byGenre: map[string][]json.RawMessage{
"Comedy": {
raw("comedy-low", "Lower Rated Match", "Series", "Comedy"),
raw("comedy-high", "Higher Rated Match", "Series", "Comedy"),
},
"Drama": {
raw("drama-1", "Drama One", "Series", "Drama"),
raw("drama-2", "Drama Two", "Series", "Drama"),
},
}}
engine.CuratedRows = []CuratedRow{
{ID: "drama", Title: "Drama TV Shows", Kind: "shows", ItemTypes: []string{"Series"}, Genres: []string{"Drama"}},
{ID: "comedy", Title: "Comedy TV Shows", Kind: "shows", ItemTypes: []string{"Series"}, Genres: []string{"Comedy"}},
}
rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
if err != nil {
t.Fatal(err)
}
if len(rows) != 2 {
t.Fatalf("expected two curated rows, got %v", rowTitles(rows))
}
if rows[0].ID != "comedy" || rows[1].ID != "drama" {
t.Fatalf("user's comedy affinity should order shelves, got %v", rowTitles(rows))
}
if rows[0].Kind != "shows" {
t.Fatalf("curated TV shelf kind = %q", rows[0].Kind)
}
}
func TestCuratedRowsFallBackToRatingForANewUser(t *testing.T) {
source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{}}
engine := testEngine(source)
engine.MinRowItems = 1
engine.Library = &fakeCuratedLibrary{byGenre: map[string][]json.RawMessage{
"Drama": {raw("drama", "Strong Drama", "Series", "Drama")},
}}
engine.CuratedRows = []CuratedRow{{
ID: "drama", Title: "Drama TV Shows", Kind: "shows",
ItemTypes: []string{"Series"}, Genres: []string{"Drama"},
}}
rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "new"})
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].ID != "drama" {
t.Fatalf("new profiles should receive quality-ranked curated rows: %+v", rows)
}
}
// A failing similarity lookup is one dead row, not a dead home screen.
func TestBuildRowsSurvivesASimilarLookupFailure(t *testing.T) {
source := &fakeSource{
itemsByFilter: map[string][]json.RawMessage{
"IsPlayed": {raw("m1", "Dune", "Movie", "Science Fiction")},
"IsUnplayed": {
raw("c1", "Solaris", "Movie", "Science Fiction"),
raw("c2", "Blade Runner", "Movie", "Science Fiction"),
},
},
similarErr: errors.New("emby is unwell"),
}
rows, err := testEngine(source).BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
if err != nil {
t.Fatalf("BuildRows should not fail: %v", err)
}
if len(rows) != 1 || rows[0].Kind != "recommended" {
t.Fatalf("expected the history row to survive, got %v", rowTitles(rows))
}
}
func TestBuildRowsFailsWhenHistoryCannotBeRead(t *testing.T) {
source := &fakeSource{itemsErr: errors.New("emby down")}
if _, err := testEngine(source).BuildRows(context.Background(), emby.Credentials{UserID: "u1"}); err == nil {
t.Fatal("expected an error when the history queries fail")
}
}
func rowTitles(rows []Row) []string {
out := make([]string, 0, len(rows))
for _, row := range rows {
out = append(out, row.Title)
}
return out
}