169 lines
5.7 KiB
Go
169 lines
5.7 KiB
Go
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
|
||
|
|
}
|