2026-07-29 15:26:40 +12:00
|
|
|
package foryou
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"testing"
|
2026-08-02 22:10:19 +12:00
|
|
|
"time"
|
2026-07-29 15:26:40 +12:00
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
"github.com/ponzischeme89/memby/server/internal/recommend"
|
2026-07-29 15:26:40 +12:00
|
|
|
"github.com/ponzischeme89/memby/server/internal/store"
|
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/tracearr"
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 15:26:40 +12:00
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 22:33:56 +12:00
|
|
|
func TestImportDueMeasuresElapsedTimeRatherThanProcessUptime(t *testing.T) {
|
|
|
|
|
now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
|
|
|
|
|
at := func(d time.Duration) *time.Time {
|
|
|
|
|
value := now.Add(-d)
|
|
|
|
|
return &value
|
|
|
|
|
}
|
|
|
|
|
const incremental = 5 * time.Minute
|
|
|
|
|
const full = 24 * time.Hour
|
|
|
|
|
|
|
|
|
|
for _, testCase := range []struct {
|
|
|
|
|
name string
|
|
|
|
|
state store.TracearrImportState
|
|
|
|
|
wantFull bool
|
|
|
|
|
wantDue bool
|
|
|
|
|
wantSkip bool
|
|
|
|
|
}{
|
|
|
|
|
{
|
|
|
|
|
name: "never imported runs a full pass",
|
|
|
|
|
state: store.TracearrImportState{}, wantFull: true, wantDue: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
// The restart case: without persisted stamps every boot re-imported.
|
|
|
|
|
name: "recent import is not repeated after a restart",
|
|
|
|
|
state: store.TracearrImportState{
|
|
|
|
|
LastIncrementalAt: at(30 * time.Second), LastFullAt: at(2 * time.Hour),
|
|
|
|
|
},
|
|
|
|
|
wantSkip: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "elapsed incremental interval is due",
|
|
|
|
|
state: store.TracearrImportState{
|
|
|
|
|
LastIncrementalAt: at(6 * time.Minute), LastFullAt: at(2 * time.Hour),
|
|
|
|
|
},
|
|
|
|
|
wantDue: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
// A gateway restarted daily never reconciled: the full ticker restarted too.
|
|
|
|
|
name: "elapsed full interval wins over the incremental one",
|
|
|
|
|
state: store.TracearrImportState{
|
|
|
|
|
LastIncrementalAt: at(6 * time.Minute), LastFullAt: at(25 * time.Hour),
|
|
|
|
|
},
|
|
|
|
|
wantFull: true, wantDue: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "a stamp in the future does not strand the importer",
|
|
|
|
|
state: store.TracearrImportState{
|
|
|
|
|
LastIncrementalAt: at(-time.Hour), LastFullAt: at(-time.Hour),
|
|
|
|
|
},
|
|
|
|
|
wantFull: true, wantDue: true,
|
|
|
|
|
},
|
|
|
|
|
} {
|
|
|
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
|
|
|
gotFull, gotDue := importDue(testCase.state, now, incremental, full)
|
|
|
|
|
if testCase.wantSkip {
|
|
|
|
|
if gotDue {
|
|
|
|
|
t.Fatal("import ran when none was owed")
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if gotDue != testCase.wantDue || gotFull != testCase.wantFull {
|
|
|
|
|
t.Fatalf("full=%v due=%v, want full=%v due=%v",
|
|
|
|
|
gotFull, gotDue, testCase.wantFull, testCase.wantDue)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestImportDueRespectsDisabledIntervals(t *testing.T) {
|
|
|
|
|
now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
|
|
|
|
|
if _, due := importDue(store.TracearrImportState{}, now, 0, 0); due {
|
|
|
|
|
t.Fatal("import was due with scheduling turned off")
|
|
|
|
|
}
|
|
|
|
|
stale := now.Add(-48 * time.Hour)
|
|
|
|
|
state := store.TracearrImportState{LastIncrementalAt: &stale, LastFullAt: &stale}
|
|
|
|
|
full, due := importDue(state, now, 5*time.Minute, 0)
|
|
|
|
|
if !due || full {
|
|
|
|
|
t.Fatalf("full=%v due=%v, want an incremental pass with full reconciliation off",
|
|
|
|
|
full, due)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-02 22:10:19 +12:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 15:26:40 +12:00
|
|
|
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) {
|
2026-08-02 22:10:19 +12:00
|
|
|
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)
|
2026-07-29 15:26:40 +12:00
|
|
|
|
|
|
|
|
if len(rows) != 1 || rows[0].ID != "for-you:pick-up" ||
|
2026-08-02 22:10:19 +12:00
|
|
|
rows[0].Title != "Pick this show up again" || len(rows[0].Items) != 1 {
|
2026-07-29 15:26:40 +12:00
|
|
|
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:])
|
|
|
|
|
}
|