Files

169 lines
5.7 KiB
Go
Raw Permalink Normal View History

2026-08-17 13:13:10 +12:00
package recommend
import (
"encoding/json"
"testing"
)
func magicCandidate(id string, score float64, runtimeMinutes int) MagicCandidate {
return MagicCandidate{ItemID: id, Title: id, Score: score, RuntimeMinutes: runtimeMinutes}
}
// The pool is kept between presses, so it has to survive the round trip that keeping it
// means. A field that lost its tag would show up as a button that quietly stopped weighing
// anything rather than as an error.
func TestMagicCandidateSurvivesBeingKept(t *testing.T) {
pool := []MagicCandidate{{
ItemID: "1", Title: "A Film", Score: 2.5, RuntimeMinutes: 104,
Signals: []string{"unwatched"}, Reasons: []string{"Because you like Drama"},
}}
raw, err := json.Marshal(pool)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var restored []MagicCandidate
if err := json.Unmarshal(raw, &restored); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(restored) != 1 {
t.Fatalf("pool length = %d, want 1", len(restored))
}
got, want := restored[0], pool[0]
if got.ItemID != want.ItemID || got.Title != want.Title || got.Score != want.Score ||
got.RuntimeMinutes != want.RuntimeMinutes ||
len(got.Signals) != len(want.Signals) || len(got.Reasons) != len(want.Reasons) {
t.Fatalf("pool did not survive being kept: %+v", got)
}
}
func TestChooseMagicNeverReturnsAnExcludedTitle(t *testing.T) {
pool := []MagicCandidate{
magicCandidate("playing-now", 9, 0),
magicCandidate("offered-before", 8, 0),
magicCandidate("fresh", 1, 0),
}
for roll := 0.0; roll < 1; roll += 0.01 {
selection, ok := ChooseMagic(pool, MagicOptions{
ExcludeIDs: []string{"playing-now", " offered-before "},
Roll: roll,
})
if !ok {
t.Fatalf("roll %.2f: expected a pick", roll)
}
if selection.ItemID != "fresh" {
t.Fatalf("roll %.2f: drew an excluded title %q", roll, selection.ItemID)
}
}
}
// A household that has been offered everything the pool holds is the one case the button
// has no answer for, and it must say so rather than repeat itself.
func TestChooseMagicRefusesWhenEverythingIsExcluded(t *testing.T) {
pool := []MagicCandidate{magicCandidate("only", 3, 0)}
if _, ok := ChooseMagic(pool, MagicOptions{ExcludeIDs: []string{"only"}, Roll: 0.5}); ok {
t.Fatal("expected no pick when the whole pool is excluded")
}
if _, ok := ChooseMagic(nil, MagicOptions{Roll: 0.5}); ok {
t.Fatal("expected no pick from an empty pool")
}
}
// The whole reason for drawing rather than sorting: pressing it twice must be able to give
// two answers, while merit still decides how many tickets each title holds.
func TestChooseMagicFavoursMeritWithoutBeingAForegoneConclusion(t *testing.T) {
pool := make([]MagicCandidate, 0, 10)
for index := 0; index < 10; index++ {
pool = append(pool, magicCandidate(string(rune('a'+index)), float64(10-index), 0))
}
counts := map[string]int{}
for roll := 0.0; roll < 1; roll += 0.001 {
selection, ok := ChooseMagic(pool, MagicOptions{Roll: roll})
if !ok {
t.Fatalf("roll %.3f: expected a pick", roll)
}
counts[selection.ItemID]++
}
if len(counts) != len(pool) {
t.Fatalf("every title should be reachable, got %d of %d", len(counts), len(pool))
}
if counts["a"] <= counts["j"] {
t.Fatalf("the best title should hold the most tickets: %v", counts)
}
}
// The pool is built once and asked more than one question, so the time budget cannot have
// been folded into it. The same pool has to answer "there is an hour" differently from
// "it is Saturday afternoon".
func TestChooseMagicAppliesTheTimeBudgetAtTheDraw(t *testing.T) {
pool := []MagicCandidate{
magicCandidate("epic", 1.0, 180),
magicCandidate("short", 0.6, 85),
}
unhurried, ok := ChooseMagic(pool, MagicOptions{Roll: 0})
if !ok || unhurried.ItemID != "epic" {
t.Fatalf("with no limit the better title should lead, got %+v", unhurried)
}
rushed, ok := ChooseMagic(pool, MagicOptions{AvailableMinutes: 90, Roll: 0})
if !ok || rushed.ItemID != "short" {
t.Fatalf("with 90 minutes the one that fits should lead, got %+v", rushed)
}
if !hasSignal(rushed.Signals, "fits_time") {
t.Fatalf("the fit should be reported as a signal: %v", rushed.Signals)
}
// And the pool itself must be unchanged by having been asked, or the second press
// would inherit the first press's constraints.
if pool[0].Score != 1.0 || len(pool[0].Signals) != 0 {
t.Fatalf("the draw mutated the kept pool: %+v", pool[0])
}
}
// Nothing recorded is not evidence either way: refusing those titles would quietly delete
// a slice of the library from the feature.
func TestMagicRuntimeAdjustmentStaysSilentWithoutEvidence(t *testing.T) {
cases := []struct {
name string
runtimeMinutes, availableMinutes int
wantSignal string
}{
{"no limit given", 200, 0, ""},
{"no runtime recorded", 0, 60, ""},
{"comfortably inside", 85, 90, "fits_time"},
{"inside the slack", 95, 90, "fits_time"},
{"past the slack", 101, 90, "too_long"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, signal := magicRuntimeAdjustment(tc.runtimeMinutes, tc.availableMinutes)
if signal != tc.wantSignal {
t.Fatalf("signal = %q, want %q", signal, tc.wantSignal)
}
})
}
}
// Ties break by id so that the pool is reproducible even though the draw from it is not.
func TestSortMagicPoolIsReproducible(t *testing.T) {
pool := []MagicCandidate{
magicCandidate("z", 2, 0),
magicCandidate("a", 2, 0),
magicCandidate("m", 5, 0),
}
sortMagicPool(pool)
got := []string{pool[0].ItemID, pool[1].ItemID, pool[2].ItemID}
want := []string{"m", "a", "z"}
for i := range want {
if got[i] != want[i] {
t.Fatalf("order = %v, want %v", got, want)
}
}
}
func hasSignal(signals []string, want string) bool {
for _, signal := range signals {
if signal == want {
return true
}
}
return false
}