package nightchain import ( "testing" "time" ) // R-871 / decision 110 — the banner's rules. Red-proofs: catchup-2026-10-05/partB/red-proofs.txt. func laptop() *HourDays { // on 18:00–23:59 every day of the week (Tester 2's shape) var h HourDays for k := 18; k <= 23; k++ { h[k] = 7 } return &h } func offAt(on bool) func(time.Time) (bool, bool) { return func(time.Time) (bool, bool) { return on, true } } func base(now time.Time) BannerInput { return BannerInput{Now: now, Window: "02:30", Loc: bud, SeededAt: at(1, 12, 0), DBDumpOK: at(3, 2, 31), OnAtW: offAt(false), Hours: laptop()} } // Shown: the last dump is 2+ days old, the box was off at 02:30, and the record suggests 21:00. // RED-PROOF: return Banner{} when stale (the pre-v0.295.0 silence) → "not shown". func TestBanner_ShownWithReasonAndSuggestion(t *testing.T) { b := ComputeBanner(base(at(5, 19, 0))) if !b.Show { t.Fatal("not shown although the last backup is 2 days old") } if b.OffAt != "02:30" || b.Suggest != "21:00" || b.DaysAgo != 2 { t.Fatalf("banner = %+v, want off at 02:30, suggest 21:00, 2 days ago", b) } } // Not shown: a backup within the last 26 h. func TestBanner_NotShownWhenFresh(t *testing.T) { in := base(at(5, 19, 0)) in.DBDumpOK = at(5, 2, 31) if b := ComputeBanner(in); b.Show { t.Fatalf("shown although the last dump is 16 h old: %+v", b) } } // Not shown right after an upgrade (the ledger was seeded less than 26 h ago and knows no dump). func TestBanner_NotShownBeforeTheRecordKnows(t *testing.T) { in := base(at(5, 19, 0)) in.SeededAt, in.DBDumpOK = at(5, 9, 0), time.Time{} if b := ComputeBanner(in); b.Show { t.Fatalf("shown on the day of the upgrade: %+v", b) } } // Dismissed: closed until the NEXT missed backup time, then back. // RED-PROOF: drop the Dismissed check → "a closed banner came back without a new miss". func TestBanner_DismissedThenBackAfterANewMiss(t *testing.T) { in := base(at(5, 19, 0)) b := ComputeBanner(in) in.Dismissed = b.MissedAt // the household closed it (02:30 on the 5th) if ComputeBanner(in).Show { t.Fatal("a closed banner came back without a new miss") } in.Now = at(5, 23, 0) // same evening — still the same miss if ComputeBanner(in).Show { t.Fatal("a closed banner came back the same evening") } in.Now = at(6, 19, 0) // off again at 02:30 on the 6th if !ComputeBanner(in).Show { t.Fatal("the banner did not come back after the next missed backup time") } } // Gone by itself after a successful night (e.g. the catch-up ran). func TestBanner_GoneAfterASuccess(t *testing.T) { in := base(at(5, 19, 0)) in.DBDumpOK = at(5, 18, 20) // the catch-up's dump if ComputeBanner(in).Show { t.Fatal("still shown after a successful backup") } } // The off-site tier counts when configured: a fresh dump with a 3-day-old off-site copy still shows. func TestBanner_OffsiteTierCounts(t *testing.T) { in := base(at(5, 19, 0)) in.DBDumpOK = at(5, 18, 20) in.OffsiteConfigured, in.OffsiteOK = true, at(2, 4, 20) b := ComputeBanner(in) if !b.Show || b.DaysAgo != 3 { t.Fatalf("banner = %+v, want shown with the off-site copy's 3 days", b) } } // No clear pattern → no suggestion ("choose a time when the box is usually on"); box ON at W → no "off at". // RED-PROOF: let Suggest return the first start without checking the days → a time is suggested from noise. func TestBanner_NoPatternNoSuggestion(t *testing.T) { in := base(at(5, 19, 0)) var noisy HourDays for k := 18; k <= 23; k++ { noisy[k] = 2 // on some evenings — 2 of 7 days is not "usually" } in.Hours, in.OnAtW = &noisy, offAt(true) b := ComputeBanner(in) if !b.Show || b.Suggest != "" || b.OffAt != "" { t.Fatalf("banner = %+v, want shown, no suggestion, no 'off at'", b) } } // A box usually ON at its window gets no new time (the miss has another cause). func TestBanner_UsuallyOnAtWNoSuggestion(t *testing.T) { var always HourDays for k := range always { always[k] = 7 } if s := Suggest(always, 2); s != "" { t.Fatalf("suggested %q for a box that is on at its window", s) } if s := Suggest(*laptop(), 2); s != "21:00" { t.Fatalf("laptop suggestion %q, want 21:00", s) } }