package credits import ( "testing" "time" ) // Candidate generation is the half of this subsystem that decides how much work exists at // all, so these tests are mostly about what it refuses to queue. A predictor that is // enthusiastic is indistinguishable from a library scanner. var now = time.Date(2026, 8, 15, 21, 0, 0, 0, time.UTC) // blueBloods is the worked example from the brief: a long-running procedural, so a rule that // over-reaches here queues hundreds of episodes. func blueBloods() EpisodeIndex { episodes := []SeriesEpisode{} for season := 1; season <= 8; season++ { for episode := 1; episode <= 22; episode++ { episodes = append(episodes, SeriesEpisode{ ItemID: itemID(season, episode), SeriesID: "bb", Season: season, Episode: episode, }) } } // One special, which must never be predicted as "next". episodes = append(episodes, SeriesEpisode{ ItemID: "bb-s00e01", SeriesID: "bb", Season: 0, Episode: 1, }) return NewEpisodeIndex(episodes) } func itemID(season, episode int) string { return "bb-s" + twoDigit(season) + "e" + twoDigit(episode) } func twoDigit(value int) string { if value < 10 { return "0" + string(rune('0'+value)) } return string(rune('0'+value/10)) + string(rune('0'+value%10)) } func watch(user string, season, episode int, ago time.Duration, completed bool) Watch { return Watch{ UserKey: user, SeriesID: "bb", Season: season, Episode: episode, WatchedAt: now.Add(-ago), Completed: completed, } } func TestActiveSeriesProducesCandidates(t *testing.T) { watches := []Watch{ watch("paul", 6, 5, 72*time.Hour, true), watch("paul", 6, 6, 48*time.Hour, true), watch("paul", 6, 7, 20*time.Minute, true), } candidates := BuildCandidates( Activities(watches, now, DefaultConfig()), blueBloods(), now, DefaultConfig(), ) if len(candidates) == 0 { t.Fatal("an actively watched series produced no candidates") } // The brief's own example: having just finished S06E07, the next episodes are what is // worth preparing — and nothing before them. if candidates[0].ItemID != itemID(6, 8) { t.Fatalf("highest-priority candidate = %q, want S06E08", candidates[0].ItemID) } for _, candidate := range candidates { if candidate.Season == 6 && candidate.Episode <= 7 { t.Fatalf("queued an already-watched episode: S%02dE%02d", candidate.Season, candidate.Episode) } } } // The rule the brief calls out as critical: watching a show is not a reason to scan the show. func TestDoesNotQueueTheWholeSeries(t *testing.T) { watches := []Watch{watch("paul", 6, 7, time.Hour, true)} candidates := BuildCandidates( Activities(watches, now, DefaultConfig()), blueBloods(), now, DefaultConfig(), ) if len(candidates) > DefaultConfig().MaxPrefetchEpisodes { t.Fatalf("queued %d episodes for one viewer; the ceiling is %d", len(candidates), DefaultConfig().MaxPrefetchEpisodes) } } func TestOldHistoryProducesNothing(t *testing.T) { watches := []Watch{ watch("paul", 6, 7, 180*24*time.Hour, true), watch("paul", 6, 6, 181*24*time.Hour, true), } candidates := BuildCandidates( Activities(watches, now, DefaultConfig()), blueBloods(), now, DefaultConfig(), ) if len(candidates) != 0 { t.Fatalf("six-month-old history produced %d candidates; want none", len(candidates)) } } // Decay is the mechanism that lets a household change its mind without anything noticing. func TestCandidateExpiryDecaysPriority(t *testing.T) { recent := BuildCandidates( Activities([]Watch{watch("paul", 6, 7, time.Hour, true)}, now, DefaultConfig()), blueBloods(), now, DefaultConfig(), ) stale := BuildCandidates( Activities([]Watch{watch("paul", 6, 7, 5*24*time.Hour, true)}, now, DefaultConfig()), blueBloods(), now, DefaultConfig(), ) if len(recent) == 0 || len(stale) == 0 { t.Fatal("expected candidates in both windows") } if stale[0].Priority >= recent[0].Priority { t.Fatalf("five-day-old demand (%d) did not rank below one-hour-old demand (%d)", stale[0].Priority, recent[0].Priority) } } func TestInProgressEpisodeIsItselfACandidate(t *testing.T) { watches := []Watch{watch("paul", 6, 7, 10*time.Minute, false)} candidates := BuildCandidates( Activities(watches, now, DefaultConfig()), blueBloods(), now, DefaultConfig(), ) if len(candidates) == 0 || candidates[0].ItemID != itemID(6, 7) { t.Fatalf("a part-watched episode was not the top candidate; got %+v", candidates) } } func TestBingeExpandsAndSlowViewingContractsLookAhead(t *testing.T) { // Four episodes in one evening. binge := []Watch{ watch("paul", 6, 4, 4*time.Hour, true), watch("paul", 6, 5, 3*time.Hour, true), watch("paul", 6, 6, 2*time.Hour, true), watch("paul", 6, 7, time.Hour, true), } // One episode every few days. slow := []Watch{ watch("dave", 6, 6, 6*24*time.Hour, true), watch("dave", 6, 7, 2*24*time.Hour, true), } bingeCount := len(BuildCandidates( Activities(binge, now, DefaultConfig()), blueBloods(), now, DefaultConfig(), )) slowCount := len(BuildCandidates( Activities(slow, now, DefaultConfig()), blueBloods(), now, DefaultConfig(), )) if bingeCount <= slowCount { t.Fatalf("binge look-ahead (%d) did not exceed slow look-ahead (%d)", bingeCount, slowCount) } if slowCount > 2 { t.Fatalf("slow viewer got %d candidates; one or two is the whole point", slowCount) } } func TestPrefetchDepthRespectsCeiling(t *testing.T) { cfg := DefaultConfig() if depth := prefetchDepth(50, cfg); depth > cfg.MaxPrefetchEpisodes { t.Fatalf("an implausible velocity produced depth %d, past the ceiling of %d", depth, cfg.MaxPrefetchEpisodes) } if depth := prefetchDepth(0.01, cfg); depth < 1 { t.Fatalf("depth %d; even the slowest viewer gets the next episode", depth) } } // Two viewers approaching the same episode is one scan that serves both, so it should be // done sooner — never twice. func TestMultipleUsersCollapseAndRaisePriority(t *testing.T) { shared := []Watch{ watch("paul", 6, 7, time.Hour, true), {UserKey: "dave", SeriesID: "bb", Season: 6, Episode: 7, WatchedAt: now.Add(-2 * time.Hour), Completed: true}, } solo := []Watch{watch("paul", 6, 7, time.Hour, true)} sharedCandidates := BuildCandidates( Activities(shared, now, DefaultConfig()), blueBloods(), now, DefaultConfig(), ) soloCandidates := BuildCandidates( Activities(solo, now, DefaultConfig()), blueBloods(), now, DefaultConfig(), ) seen := map[string]int{} for _, candidate := range sharedCandidates { seen[candidate.ItemID]++ } for id, count := range seen { if count > 1 { t.Fatalf("%s appeared %d times; duplicate candidates must collapse", id, count) } } if sharedCandidates[0].UserCount != 2 { t.Fatalf("UserCount = %d, want 2", sharedCandidates[0].UserCount) } if sharedCandidates[0].Priority <= soloCandidates[0].Priority { t.Fatalf("two interested viewers (%d) did not outrank one (%d)", sharedCandidates[0].Priority, soloCandidates[0].Priority) } if sharedCandidates[0].Reason != ReasonMultiUser { t.Fatalf("reason = %q, want %q", sharedCandidates[0].Reason, ReasonMultiUser) } } // Nothing the predictor produces may reach live playback's priority. Somebody watching now // outranks every guess about somebody who might watch later. func TestPredictionNeverOutranksLivePlayback(t *testing.T) { watches := []Watch{} for index := 0; index < 8; index++ { watches = append(watches, Watch{ UserKey: "viewer" + twoDigit(index), SeriesID: "bb", Season: 6, Episode: 7, WatchedAt: now.Add(-time.Minute), Completed: true, }) } candidates := BuildCandidates( Activities(watches, now, DefaultConfig()), blueBloods(), now, DefaultConfig(), ) for _, candidate := range candidates { if candidate.Priority >= PriorityLive { t.Fatalf("predicted candidate reached %d; live playback is %d", candidate.Priority, PriorityLive) } } } func TestLookAheadCrossesSeasonBoundary(t *testing.T) { // A season finale is exactly when somebody carries on. watches := []Watch{ watch("paul", 6, 20, 3*time.Hour, true), watch("paul", 6, 21, 2*time.Hour, true), watch("paul", 6, 22, time.Hour, true), } candidates := BuildCandidates( Activities(watches, now, DefaultConfig()), blueBloods(), now, DefaultConfig(), ) if len(candidates) == 0 || candidates[0].Season != 7 || candidates[0].Episode != 1 { t.Fatalf("after a finale the next candidate was %+v, want S07E01", candidates) } } func TestSpecialsAreNeverPredicted(t *testing.T) { watches := []Watch{watch("paul", 6, 22, time.Hour, true)} candidates := BuildCandidates( Activities(watches, now, DefaultConfig()), blueBloods(), now, DefaultConfig(), ) for _, candidate := range candidates { if candidate.Season == 0 { t.Fatal("queued a special; nobody follows a finale with a featurette") } } } // Rewatching an earlier episode does not un-watch the later ones. func TestProgressIsTheFurthestEpisodeNotTheLatestSession(t *testing.T) { watches := []Watch{ watch("paul", 6, 10, 24*time.Hour, true), watch("paul", 6, 2, time.Minute, true), } activities := Activities(watches, now, DefaultConfig()) if len(activities) != 1 { t.Fatalf("expected one activity, got %d", len(activities)) } if activities[0].Episode != 10 { t.Fatalf("progress = E%02d; a rewatch must not rewind it", activities[0].Episode) } }