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 }