275 lines
9.1 KiB
Go
275 lines
9.1 KiB
Go
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)
|
||
|
|
}
|
||
|
|
}
|