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) } }