285 lines
10 KiB
Go
285 lines
10 KiB
Go
package prediction
|
||
|
||
import (
|
||
"testing"
|
||
"time"
|
||
|
||
"doormile/utils"
|
||
)
|
||
|
||
func ptr(n int) *int { return &n }
|
||
|
||
// A calibration that is deliberately coarse-to-fine, so the fallback ladder is
|
||
// observable: the zone_hour_weekday cell has a different factor from the
|
||
// zone_weekday one, which differs from zone, which differs from global.
|
||
func fixture() []ETACalibration {
|
||
return []ETACalibration{
|
||
{Scope: "zone_hour_weekday", Zone: "641", Hourbucket: ptr(3), Weekday: ptr(1), Factor: 1.5, Samples: 100},
|
||
{Scope: "zone_weekday", Zone: "641", Weekday: ptr(1), Factor: 2.0, Samples: 100},
|
||
{Scope: "zone", Zone: "641", Factor: 2.5, Samples: 100},
|
||
{Scope: "global", Factor: 3.0, Samples: 100},
|
||
}
|
||
}
|
||
|
||
// 2026-10-05 is a Monday. 10:00 IST is hour bucket 3 (10/3). The database
|
||
// stores IST digits labelled UTC, so the fixture time is built that way on
|
||
// purpose — it is the shape a real column read produces.
|
||
func mondayTenAM() time.Time {
|
||
return time.Date(2026, 10, 5, 10, 0, 0, 0, time.UTC)
|
||
}
|
||
|
||
// ─── The floor rule: these are the cases that must return false ────────────
|
||
|
||
func TestNoRoutedDurationFallsBack(t *testing.T) {
|
||
Load(fixture())
|
||
defer Reset()
|
||
|
||
// The dominant case in production today: ROUTE_OPTIMIZER_URL is unset, so
|
||
// internal/routing never runs and etaminutes is 0.
|
||
if _, ok := ETAMinutes(Input{RoutedMinutes: 0, DeliveryPincode: "641001", At: mondayTenAM()}); ok {
|
||
t.Fatal("ETAMinutes answered with no routed duration; the promise table must keep answering")
|
||
}
|
||
if _, ok := ETAMinutes(Input{RoutedMinutes: -5, DeliveryPincode: "641001", At: mondayTenAM()}); ok {
|
||
t.Fatal("ETAMinutes answered on a negative routed duration")
|
||
}
|
||
}
|
||
|
||
func TestNoCalibrationFallsBack(t *testing.T) {
|
||
Reset()
|
||
if _, ok := ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: "641001", At: mondayTenAM()}); ok {
|
||
t.Fatal("ETAMinutes answered with no calibration loaded")
|
||
}
|
||
}
|
||
|
||
func TestBelowSampleFloorFallsBack(t *testing.T) {
|
||
Load([]ETACalibration{
|
||
{Scope: "zone", Zone: "641", Factor: 2.0, Samples: minSamples - 1},
|
||
})
|
||
defer Reset()
|
||
|
||
if _, ok := ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: "641001", At: mondayTenAM()}); ok {
|
||
t.Fatalf("ETAMinutes trusted a cell with %d samples (floor is %d)", minSamples-1, minSamples)
|
||
}
|
||
}
|
||
|
||
func TestOutOfBoundsFactorFallsBack(t *testing.T) {
|
||
for _, f := range []float64{minFactor - 0.1, maxFactor + 0.1, 0, -1} {
|
||
Load([]ETACalibration{{Scope: "zone", Zone: "641", Factor: f, Samples: 100}})
|
||
if _, ok := ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: "641001", At: mondayTenAM()}); ok {
|
||
t.Errorf("ETAMinutes served an out-of-bounds factor %v; an absurd ETA is worse than a flat constant", f)
|
||
}
|
||
Reset()
|
||
}
|
||
}
|
||
|
||
// This is also the H1 regression test, verified by mutation: Refreshedat is
|
||
// written through utils.DBNow (IST wall-clock digits labelled UTC), so reading
|
||
// it as a raw instant makes it appear 5h30m NEWER than it is. A calibration
|
||
// 73h old then measures as 67.5h and slips under the 72h staleAfter bound.
|
||
// Removing the utils.IST call in Load fails exactly this test.
|
||
//
|
||
// The margin matters: with staleAfter at 72h, the 5.5h error is a 7.6% window
|
||
// in which a stale calibration keeps answering. Narrow staleAfter and the bug
|
||
// gets proportionally worse, which is why the correction belongs in Load rather
|
||
// than in a wider bound here.
|
||
func TestStaleCalibrationFallsBack(t *testing.T) {
|
||
old := utils.DBNow().Add(-(staleAfter + time.Hour))
|
||
Load([]ETACalibration{
|
||
{Scope: "zone", Zone: "641", Factor: 2.0, Samples: 100, Refreshedat: old},
|
||
})
|
||
defer Reset()
|
||
|
||
if _, ok := ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: "641001", At: mondayTenAM()}); ok {
|
||
t.Fatal("ETAMinutes trusted a calibration older than staleAfter; refreshes have been failing")
|
||
}
|
||
}
|
||
|
||
// The companion case: a just-refreshed calibration must answer. Note this one
|
||
// passes with or without the IST correction (the raw read errs toward "newer",
|
||
// not "older"), so it documents intent rather than guarding the bug — the guard
|
||
// above is what fails on mutation.
|
||
func TestFreshCalibrationIsNotMisreadAsStale(t *testing.T) {
|
||
Load([]ETACalibration{
|
||
{Scope: "zone", Zone: "641", Factor: 2.0, Samples: 100, Refreshedat: utils.DBNow()},
|
||
})
|
||
defer Reset()
|
||
|
||
if _, ok := ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: "641001", At: mondayTenAM()}); !ok {
|
||
t.Fatal("a just-refreshed calibration was treated as stale — the DBNow/IST correction in Load is wrong")
|
||
}
|
||
}
|
||
|
||
// Regression: loading month-old rows from the store at boot must stay stale.
|
||
// Stamping builtAt with time.Now() here would re-arm a dead calibration on
|
||
// every deploy.
|
||
func TestBootLoadDoesNotRearmStaleRows(t *testing.T) {
|
||
old := utils.DBNow().Add(-(staleAfter + 24*time.Hour))
|
||
Load([]ETACalibration{
|
||
{Scope: "zone", Zone: "641", Factor: 2.0, Samples: 100, Refreshedat: old},
|
||
{Scope: "global", Factor: 2.2, Samples: 100, Refreshedat: old},
|
||
})
|
||
defer Reset()
|
||
|
||
if _, ok := ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: "641001", At: mondayTenAM()}); ok {
|
||
t.Fatal("a restart re-armed a stale stored calibration")
|
||
}
|
||
}
|
||
|
||
// ─── The ladder: most specific cell wins, then progressively coarser ───────
|
||
|
||
func TestMostSpecificCellWins(t *testing.T) {
|
||
Load(fixture())
|
||
defer Reset()
|
||
|
||
r, ok := ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: "641001", At: mondayTenAM()})
|
||
if !ok {
|
||
t.Fatal("expected an estimate")
|
||
}
|
||
if r.Source != "zone_hour_weekday" {
|
||
t.Errorf("source = %q, want zone_hour_weekday", r.Source)
|
||
}
|
||
if r.Minutes != 60 { // 40 × 1.5
|
||
t.Errorf("minutes = %d, want 60 (40 × 1.5)", r.Minutes)
|
||
}
|
||
}
|
||
|
||
func TestFallsToZoneWeekdayThenZoneThenGlobal(t *testing.T) {
|
||
full := fixture()
|
||
defer Reset()
|
||
|
||
// Drop the finest cell: 13:00 IST is bucket 4, for which there is no
|
||
// zone_hour_weekday row, so zone_weekday answers.
|
||
Load(full)
|
||
r, ok := ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: "641001",
|
||
At: time.Date(2026, 10, 5, 13, 0, 0, 0, time.UTC)})
|
||
if !ok || r.Source != "zone_weekday" || r.Minutes != 80 { // 40 × 2.0
|
||
t.Errorf("got %+v ok=%v, want zone_weekday 80", r, ok)
|
||
}
|
||
|
||
// A Tuesday has no weekday cell for this zone, so zone answers.
|
||
r, ok = ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: "641001",
|
||
At: time.Date(2026, 10, 6, 13, 0, 0, 0, time.UTC)})
|
||
if !ok || r.Source != "zone" || r.Minutes != 100 { // 40 × 2.5
|
||
t.Errorf("got %+v ok=%v, want zone 100", r, ok)
|
||
}
|
||
|
||
// An unknown zone falls all the way to global.
|
||
r, ok = ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: "500081", At: mondayTenAM()})
|
||
if !ok || r.Source != "global" || r.Minutes != 120 { // 40 × 3.0
|
||
t.Errorf("got %+v ok=%v, want global 120", r, ok)
|
||
}
|
||
}
|
||
|
||
func TestShortOrEmptyPincodeUsesGlobalOnly(t *testing.T) {
|
||
Load(fixture())
|
||
defer Reset()
|
||
|
||
for _, p := range []string{"", "6", "64", " "} {
|
||
r, ok := ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: p, At: mondayTenAM()})
|
||
if !ok || r.Source != "global" {
|
||
t.Errorf("pincode %q: got %+v ok=%v, want global", p, r, ok)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── Bucket arithmetic must match the refresh SQL, or the lookup misses ────
|
||
|
||
func TestHourBucketMatchesRefreshSQL(t *testing.T) {
|
||
// refreshSQL does EXTRACT(HOUR FROM createdat)::int / hourBucketSize.
|
||
// hourBucket must agree exactly, or every finest-grain lookup misses.
|
||
for hour := 0; hour < 24; hour++ {
|
||
got := hourBucket(time.Date(2026, 10, 5, hour, 30, 0, 0, time.UTC))
|
||
if want := hour / hourBucketSize; got != want {
|
||
t.Errorf("hour %d: bucket = %d, want %d", hour, got, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestWeekdayMatchesPostgresISODOW(t *testing.T) {
|
||
// EXTRACT(ISODOW) is Monday=1 … Sunday=7. Go's time.Weekday is Sunday=0.
|
||
// 2026-10-05 is a Monday.
|
||
want := map[int]int{5: 1, 6: 2, 7: 3, 8: 4, 9: 5, 10: 6, 11: 7}
|
||
for day, w := range want {
|
||
got := weekdayOf(time.Date(2026, 10, day, 12, 0, 0, 0, time.UTC))
|
||
if got != w {
|
||
t.Errorf("2026-10-%02d: weekday = %d, want %d (ISODOW)", day, got, w)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestZoneIsPincodePrefix(t *testing.T) {
|
||
cases := map[string]string{
|
||
"641001": "641", "641 ": "641", "500081": "500",
|
||
"": "", "64": "", " ": "",
|
||
}
|
||
for in, want := range cases {
|
||
if got := zoneOf(in); got != want {
|
||
t.Errorf("zoneOf(%q) = %q, want %q", in, got, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── ETAAt and handling term ───────────────────────────────────────────────
|
||
|
||
func TestETAAtAddsToTheGivenTime(t *testing.T) {
|
||
Load(fixture())
|
||
defer Reset()
|
||
|
||
from := mondayTenAM()
|
||
at, r, ok := ETAAt(Input{RoutedMinutes: 40, DeliveryPincode: "641001", At: from}, from)
|
||
if !ok {
|
||
t.Fatal("expected an estimate")
|
||
}
|
||
if want := from.Add(60 * time.Minute); !at.Equal(want) {
|
||
t.Errorf("ETAAt = %v, want %v", at, want)
|
||
}
|
||
// The returned time keeps the caller's location, so it round-trips into the
|
||
// database in the same shape it came out.
|
||
if at.Location() != from.Location() {
|
||
t.Errorf("ETAAt changed the location from %v to %v", from.Location(), at.Location())
|
||
}
|
||
if r.Minutes != 60 {
|
||
t.Errorf("result minutes = %d, want 60", r.Minutes)
|
||
}
|
||
}
|
||
|
||
func TestETAAtFallsBackWithoutCalibration(t *testing.T) {
|
||
Reset()
|
||
from := mondayTenAM()
|
||
if _, _, ok := ETAAt(Input{RoutedMinutes: 40, DeliveryPincode: "641001", At: from}, from); ok {
|
||
t.Fatal("ETAAt answered with no calibration")
|
||
}
|
||
}
|
||
|
||
func TestHandlingTermIsAddedAndBounded(t *testing.T) {
|
||
Load([]ETACalibration{
|
||
{Scope: "zone", Zone: "641", Factor: 2.0, Handling: 15, Samples: 100},
|
||
})
|
||
r, ok := ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: "641001", At: mondayTenAM()})
|
||
if !ok || r.Minutes != 95 { // 40 × 2.0 + 15
|
||
t.Errorf("got %+v ok=%v, want 95", r, ok)
|
||
}
|
||
Reset()
|
||
|
||
Load([]ETACalibration{
|
||
{Scope: "zone", Zone: "641", Factor: 2.0, Handling: maxHandlingMinutes + 1, Samples: 100},
|
||
})
|
||
if _, ok := ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: "641001", At: mondayTenAM()}); ok {
|
||
t.Error("served a handling term above the cap")
|
||
}
|
||
Reset()
|
||
}
|
||
|
||
func TestLoadedReportsState(t *testing.T) {
|
||
Reset()
|
||
if ok, _, _ := Loaded(); ok {
|
||
t.Error("Loaded() true after Reset")
|
||
}
|
||
Load(fixture())
|
||
defer Reset()
|
||
ok, _, cells := Loaded()
|
||
if !ok || cells != 3 { // 3 keyed cells; global is held separately
|
||
t.Errorf("Loaded() = %v, cells %d; want true, 3", ok, cells)
|
||
}
|
||
}
|