Files
memby/server/internal/foryou/service_test.go
T

214 lines
6.3 KiB
Go

package foryou
import (
"bytes"
"encoding/json"
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/store"
"github.com/ponzischeme89/memby/server/internal/tracearr"
)
func TestRankPreparedForTimeMovesTypicalPosterAheadWithinRelevantPool(t *testing.T) {
location := time.UTC
contextProfile := recommend.NewContextAffinityProfile()
comedyHistory := recommend.Item{Genres: []string{"Comedy"}}
for i := 0; i < 5; i++ {
contextProfile.Add(
comedyHistory,
time.Date(2026, 6, 29+i*7, 20, 0, 0, 0, location),
1,
i,
location,
)
}
rawContext, err := json.Marshal(contextProfile)
if err != nil {
t.Fatal(err)
}
items := []store.PreparedForYouItem{
{
ItemID: "drama", BaseRank: 1, BaseScore: 5,
Payload: json.RawMessage(`{"Id":"drama","Genres":["Drama"]}`),
ContextAffinity: rawContext,
},
{
ItemID: "comedy", BaseRank: 2, BaseScore: 4.8,
Payload: json.RawMessage(`{"Id":"comedy","Genres":["Comedy"]}`),
RecommendationReason: "Matches your viewing",
ContextAffinity: rawContext,
},
}
ranked := rankPreparedForTime(
items,
time.Date(2026, 7, 27, 20, 0, 0, 0, location),
location,
)
if ranked[0].ItemID != "comedy" {
t.Fatalf("first contextual poster = %q, want comedy", ranked[0].ItemID)
}
if ranked[0].RecommendationReason !=
"Matches your viewing · fits what you watch around this time" {
t.Fatalf("context reason = %q", ranked[0].RecommendationReason)
}
}
func TestImportedSessionUsesStableKeyAndRecommendationFingerprint(t *testing.T) {
session := tracearr.Session{
ID: "session-1", MediaType: "movie", MediaTitle: "Arrival",
ProgressMs: 500, TotalDurationMs: 1000,
}
session.User.ID = "user-1"
session.User.Username = "Matt"
first, ok := importedSession(session, "configured-server")
if !ok {
t.Fatal("session was rejected")
}
if first.ServerID != "configured-server" || first.SessionID != "session-1" {
t.Fatalf("key = %q/%q", first.ServerID, first.SessionID)
}
second, _ := importedSession(session, "configured-server")
if !bytes.Equal(first.SourceFingerprint, second.SourceFingerprint) {
t.Fatal("unchanged session did not produce a stable fingerprint")
}
session.ProgressMs = 750
updated, _ := importedSession(session, "configured-server")
if bytes.Equal(first.SourceFingerprint, updated.SourceFingerprint) {
t.Fatal("updated progress was not detected by the fingerprint")
}
}
func TestTracearrSessionTerminalRequiresCompletedState(t *testing.T) {
active := store.TracearrSession{State: "playing"}
if tracearrSessionTerminal(active) {
t.Fatal("active session was terminal")
}
stopped := active
stopped.State = "stopped"
if !tracearrSessionTerminal(stopped) {
t.Fatal("stopped session was not terminal")
}
watched := active
watched.Watched = true
if !tracearrSessionTerminal(watched) {
t.Fatal("watched session was not terminal")
}
}
func TestNextDailyRebuildUsesConfiguredLocalHour(t *testing.T) {
location := time.FixedZone("NZST", 12*60*60)
now := time.Date(2026, 7, 31, 5, 30, 0, 0, location)
next := nextDailyRebuild(now, location, 4)
want := time.Date(2026, 8, 1, 4, 0, 0, 0, location)
if !next.Equal(want) {
t.Fatalf("next rebuild = %v, want %v", next, want)
}
}
func TestStoredResultCapsCandidatePoolAndPersistsAlgorithmVersion(t *testing.T) {
result := recommend.PreparedResult{
Candidates: make([]recommend.PreparedCandidate, maxPreparedCandidates+50),
}
for i := range result.Candidates {
result.Candidates[i].ItemID = itoaForTest(i + 1)
}
profile, candidates, err := storedResult("user", time.Now(), result)
if err != nil {
t.Fatal(err)
}
if len(candidates) != maxPreparedCandidates {
t.Fatalf("candidate count = %d, want %d", len(candidates), maxPreparedCandidates)
}
if profile.AlgorithmVersion != preparedAlgorithmVersion {
t.Fatalf("algorithm version = %q", profile.AlgorithmVersion)
}
}
func TestBuildPreparedRowsUsesMultipleSourcesAndDeduplicatesTitles(t *testing.T) {
items := make([]store.PreparedForYouItem, 0, 120)
for i := 0; i < 120; i++ {
sourceID, sourceTitle := "six-feet-under", "Six Feet Under"
genre := "Drama"
if i%2 == 1 {
sourceID, sourceTitle = "arrival", "Arrival"
genre = "Science Fiction"
}
id := "item-" + itoaForTest(i)
items = append(items, store.PreparedForYouItem{
ItemID: id, BaseRank: i + 1,
Payload: json.RawMessage(`{"Id":"` + id + `"}`),
CompatibilityScore: 0.8,
CompatibilityLabel: "Direct plays well on this TV",
RecommendationReason: "Because you finished " + sourceTitle,
ReasonGenre: genre, ReasonSourceItemID: sourceID,
ReasonSourceTitle: sourceTitle,
})
}
rows := buildPreparedRows(items, 0, 4)
if len(rows) < 4 {
t.Fatalf("expected several prepared rows, got %d", len(rows))
}
titles := map[string]bool{}
itemIDs := map[string]bool{}
for _, row := range rows {
titles[row.Title] = true
for _, raw := range row.Items {
var item struct {
ID string `json:"Id"`
}
if err := json.Unmarshal(raw, &item); err != nil {
t.Fatal(err)
}
if itemIDs[item.ID] {
t.Fatalf("item %q appeared in more than one row", item.ID)
}
itemIDs[item.ID] = true
}
}
if !titles["Because you finished Six Feet Under"] ||
!titles["Because you finished Arrival"] {
t.Fatalf("completed-title rows = %+v", titles)
}
}
func TestBuildPreparedRowsKeepsASingleGenuinePickup(t *testing.T) {
rows := buildPreparedRows([]store.PreparedForYouItem{
{
ItemID: "show-1", BaseRank: 1,
Payload: json.RawMessage(`{"Id":"show-1"}`),
ReasonKind: "pick-up",
RecommendationReason: "You left this in season 1 · pick it up again",
},
{
ItemID: "show-2", BaseRank: 2,
Payload: json.RawMessage(`{"Id":"show-2"}`),
ReasonKind: "pick-up",
RecommendationReason: "You made it through season 2 · season 3 is waiting",
},
}, 0, 4)
if len(rows) != 1 || rows[0].ID != "for-you:pick-up" ||
rows[0].Title != "Pick this show up again" || len(rows[0].Items) != 1 {
t.Fatalf("pickup rows = %+v", rows)
}
}
func itoaForTest(value int) string {
if value == 0 {
return "0"
}
var digits [20]byte
position := len(digits)
for value > 0 {
position--
digits[position] = byte('0' + value%10)
value /= 10
}
return string(digits[position:])
}