0.2.40 & server 0.1.30
This commit is contained in:
@@ -2,6 +2,8 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -403,6 +405,138 @@ func TestInjectHeroFieldsPreservesUnknownFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// heroPool is a merit-ordered pool, the shape rotateHeroCandidates is handed: 0 is the
|
||||
// best-scoring candidate and the numbers descend from there.
|
||||
func heroPool(size int) []heroCandidate {
|
||||
pool := make([]heroCandidate, 0, size)
|
||||
for index := 0; index < size; index++ {
|
||||
id := "title-" + strconv.Itoa(index)
|
||||
pool = append(pool, heroMovieCandidate(id, heroDaysAgo(index), 0.9, true))
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
// The property that makes this a rotation rather than a shuffle: whatever slot is drawn,
|
||||
// the first card came from the best band and the second from the one below it. A
|
||||
// well-reviewed new release can never be pushed down the row by variation.
|
||||
func TestHeroRotationDrawsOneFromEachMeritBand(t *testing.T) {
|
||||
pool := heroPool(heroPoolLimit)
|
||||
band := heroPoolLimit / heroRowLimit
|
||||
for hour := 0; hour < 24; hour++ {
|
||||
now := time.Date(2026, 8, 7, hour, 0, 0, 0, time.UTC)
|
||||
seed := heroVariationSeed("viewer", heroRotationSlot(now, time.UTC))
|
||||
drawn := rotateHeroCandidates(pool, seed, heroRowLimit)
|
||||
if len(drawn) != heroRowLimit {
|
||||
t.Fatalf("hour %d: expected %d cards, got %v", hour, heroRowLimit, heroIDs(drawn))
|
||||
}
|
||||
for slot, candidate := range drawn {
|
||||
index, err := strconv.Atoi(strings.TrimPrefix(candidate.ID, "title-"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if index/band != slot {
|
||||
t.Fatalf("hour %d: slot %d drew %s, out of band %d",
|
||||
hour, slot, candidate.ID, index/band)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The complaint this feature answers: the same two titles led the launcher for five days.
|
||||
// The lead card has to actually move, both across a day and across days.
|
||||
func TestHeroRotationChangesThroughTheDayAndAcrossDays(t *testing.T) {
|
||||
pool := heroPool(heroPoolLimit)
|
||||
leads := map[string]bool{}
|
||||
for day := 7; day < 12; day++ {
|
||||
for hour := 0; hour < 24; hour += 24 / heroRotationsPerDay {
|
||||
now := time.Date(2026, 8, day, hour, 0, 0, 0, time.UTC)
|
||||
seed := heroVariationSeed("viewer", heroRotationSlot(now, time.UTC))
|
||||
leads[rotateHeroCandidates(pool, seed, heroRowLimit)[0].ID] = true
|
||||
}
|
||||
}
|
||||
// The top band holds three titles; over five days of four slots each, a draw that
|
||||
// never moved off one of them would be a rotation in name only.
|
||||
if len(leads) < 2 {
|
||||
t.Fatalf("expected the lead card to move, saw only %v", leads)
|
||||
}
|
||||
}
|
||||
|
||||
// The home response is cached for a minute and rebuilt constantly behind it. A hero that
|
||||
// re-drew per request would change under somebody walking along the row.
|
||||
func TestHeroRotationIsStableWithinASlot(t *testing.T) {
|
||||
pool := heroPool(heroPoolLimit)
|
||||
location := time.UTC
|
||||
first := heroRotationSlot(time.Date(2026, 8, 7, 19, 0, 0, 0, location), location)
|
||||
second := heroRotationSlot(time.Date(2026, 8, 7, 20, 30, 0, 0, location), location)
|
||||
if first != second {
|
||||
t.Fatalf("expected one evening slot, got %q and %q", first, second)
|
||||
}
|
||||
seed := heroVariationSeed("viewer", first)
|
||||
want := heroIDs(rotateHeroCandidates(pool, seed, heroRowLimit))
|
||||
for attempt := 0; attempt < 5; attempt++ {
|
||||
got := heroIDs(rotateHeroCandidates(pool, seed, heroRowLimit))
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("draw %d differed: %v vs %v", attempt, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Two people signed into the same house get their own draw — there is no reason for the
|
||||
// same card to lead both televisions at the same moment.
|
||||
func TestHeroRotationVariesByViewer(t *testing.T) {
|
||||
pool := heroPool(heroPoolLimit)
|
||||
slot := heroRotationSlot(heroNow, time.UTC)
|
||||
same := 0
|
||||
for _, viewer := range []string{"a", "b", "c", "d"} {
|
||||
if rotateHeroCandidates(pool, heroVariationSeed(viewer, slot), heroRowLimit)[0].ID ==
|
||||
rotateHeroCandidates(pool, heroVariationSeed("a", slot), heroRowLimit)[0].ID {
|
||||
same++
|
||||
}
|
||||
}
|
||||
if same == 4 {
|
||||
t.Fatal("every viewer drew the same lead card")
|
||||
}
|
||||
}
|
||||
|
||||
// A pool with nothing spare to rotate between must go out in merit order, not be reordered
|
||||
// for the sake of it — this is the household whose library has barely eight hero-worthy
|
||||
// titles in it.
|
||||
func TestHeroRotationKeepsMeritOrderWithNothingToRotate(t *testing.T) {
|
||||
pool := heroPool(heroRowLimit)
|
||||
drawn := rotateHeroCandidates(pool, "seed", heroRowLimit)
|
||||
for index, candidate := range drawn {
|
||||
if candidate.ID != pool[index].ID {
|
||||
t.Fatalf("expected merit order, got %v", heroIDs(drawn))
|
||||
}
|
||||
}
|
||||
if rotateHeroCandidates(nil, "seed", heroRowLimit) != nil {
|
||||
t.Fatal("expected no cards from no candidates")
|
||||
}
|
||||
if rotateHeroCandidates(pool, "seed", 0) != nil {
|
||||
t.Fatal("expected no cards for no slots")
|
||||
}
|
||||
}
|
||||
|
||||
// The slot is the household's local part of the day, not UTC's — "the evening" has to mean
|
||||
// the viewer's evening, for the reason the direct path's daily rotation is local too.
|
||||
func TestHeroRotationSlotIsLocal(t *testing.T) {
|
||||
auckland := time.FixedZone("NZST", 12*60*60)
|
||||
// Midday in Auckland is the previous day in UTC, and must not be read as the small
|
||||
// hours of it.
|
||||
now := time.Date(2026, 8, 7, 12, 0, 0, 0, auckland)
|
||||
if got, want := heroRotationSlot(now, auckland), "2026-08-07#2"; got != want {
|
||||
t.Fatalf("expected %q, got %q", want, got)
|
||||
}
|
||||
if got, want := heroRotationSlot(now, time.UTC), "2026-08-07#0"; got != want {
|
||||
t.Fatalf("expected %q, got %q", want, got)
|
||||
}
|
||||
// The date is in the slot, or the four slots repeat themselves every day.
|
||||
tomorrow := heroRotationSlot(now.AddDate(0, 0, 1), auckland)
|
||||
if tomorrow == heroRotationSlot(now, auckland) {
|
||||
t.Fatalf("expected the day to be part of the slot, got %q", tomorrow)
|
||||
}
|
||||
}
|
||||
|
||||
func heroIDs(candidates []heroCandidate) []string {
|
||||
ids := make([]string, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
|
||||
Reference in New Issue
Block a user