Files

275 lines
9.1 KiB
Go
Raw Permalink Normal View History

2026-08-15 09:23:26 +12:00
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)
}
}