package recommend import ( "context" "encoding/json" "errors" "io" "log/slog" "testing" "github.com/ponzischeme89/memby/server/internal/emby" ) func relatedSubject() Item { decoded := Decode([]json.RawMessage{ json.RawMessage(`{"Id":"subject","Name":"The Subject","Type":"Movie","Genres":["Thriller"]}`), }) return decoded[0] } func relatedEngine(source Source) *Engine { engine := NewEngine(source, slog.New(slog.NewTextHandler(io.Discard, nil))) engine.MinRowItems = 2 return engine } func relatedCatalogue(prefix string, count int) []json.RawMessage { out := make([]json.RawMessage, 0, count) for i := 0; i < count; i++ { id := prefix + string(rune('a'+i)) out = append(out, json.RawMessage( `{"Id":"`+id+`","Name":"`+id+`","Type":"Movie","Genres":["Thriller"]}`, )) } return out } func relatedIDs(items []Item) []string { out := make([]string, 0, len(items)) for _, item := range items { out = append(out, item.ID) } return out } // The taste profile costs the same Emby fan-out the home rows pay for, and it is the // half of this answer a detail page can do without. Losing it must cost the reasons, not // the carousel — before this the whole request 502'd and the page said it had failed. func TestRelatedSurvivesAProfileThatCannotBeBuilt(t *testing.T) { source := &fakeSource{ itemsErr: errors.New("emby is unwell"), similar: map[string][]json.RawMessage{"subject": relatedCatalogue("similar-", 4)}, } reasons, related, err := relatedEngine(source).RelatedTo( context.Background(), emby.Credentials{UserID: "u1"}, relatedSubject(), 12, ) if err != nil { t.Fatalf("expected a degraded answer, got error: %v", err) } if len(related) != 4 { t.Fatalf("expected Emby's similar list, got %v", relatedIDs(related)) } for _, reason := range reasons { if reason == "" { t.Fatal("a reason must never be blank") } } } // A viewer who navigated on is not a fault, and continuing to fan out on their behalf // spends Emby requests nobody is waiting for. func TestRelatedReportsAnAbandonedRequest(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() source := &fakeSource{itemsErr: context.Canceled} _, _, err := relatedEngine(source).RelatedTo( ctx, emby.Credentials{UserID: "u1"}, relatedSubject(), 12, ) if !errors.Is(err, context.Canceled) { t.Fatalf("expected the cancellation to be reported, got %v", err) } } // Emby erroring and Emby honestly knowing nothing similar look identical on a TV, so both // fall through to the household's own catalogue in the same genres. func TestRelatedFallsBackToTheImportedCatalogue(t *testing.T) { for name, source := range map[string]*fakeSource{ "similar lookup failed": {similarErr: errors.New("emby is unwell")}, "nothing similar known": {similar: map[string][]json.RawMessage{}}, } { t.Run(name, func(t *testing.T) { engine := relatedEngine(source) engine.Library = &fakeForYouLibrary{items: relatedCatalogue("library-", 3)} _, related, err := engine.RelatedTo( context.Background(), emby.Credentials{UserID: "u1"}, relatedSubject(), 12, ) if err != nil { t.Fatalf("the fallback must not fail: %v", err) } if len(related) != 3 { t.Fatalf("expected the genre pool, got %v", relatedIDs(related)) } }) } } // The catalogue always contains the title the page is about, and Emby's own similarity // list occasionally does. func TestRelatedNeverIncludesItsOwnSubject(t *testing.T) { subject := json.RawMessage( `{"Id":"subject","Name":"The Subject","Type":"Movie","Genres":["Thriller"]}`, ) source := &fakeSource{similar: map[string][]json.RawMessage{ "subject": append([]json.RawMessage{subject}, relatedCatalogue("similar-", 3)...), }} engine := relatedEngine(source) engine.Library = &fakeForYouLibrary{items: []json.RawMessage{subject}} _, related, err := engine.RelatedTo( context.Background(), emby.Credentials{UserID: "u1"}, relatedSubject(), 12, ) if err != nil { t.Fatalf("unexpected error: %v", err) } for _, item := range related { if item.ID == "subject" { t.Fatalf("the page's own title was returned to it: %v", relatedIDs(related)) } } } // With no imported catalogue there is nothing to fall back to, and an empty carousel is // still an answer the page can open with. func TestRelatedWithoutALibraryStillAnswers(t *testing.T) { source := &fakeSource{similarErr: errors.New("emby is unwell")} reasons, related, err := relatedEngine(source).RelatedTo( context.Background(), emby.Credentials{UserID: "u1"}, relatedSubject(), 12, ) if err != nil { t.Fatalf("expected an empty answer, got error: %v", err) } if len(related) != 0 { t.Fatalf("expected nothing, got %v", relatedIDs(related)) } if len(reasons) == 0 { t.Fatal("catalogue facts should still explain the title") } }