package loop import ( "context" "testing" "time" "github.com/kami/maven/internal/store" ) // Tests for the universal restraint gate. // // docs/design.md § Trigger model: "the gate is universal, applied by the loop, never // per-rule — quiet-hours, presence, cooldown, snooze, calendar-busy all live in // one fires()." These tests pin the CONSERVATIVE side of that: the cases where // Maven must stay quiet. They exist so nobody loosens the gate by accident. // // Where the code does not yet do what docs/design.md promises, the test is written to // show the gap and then skipped, with the file and line to fix. Behaviour is not // changed to make a test pass. // testRule — a rule at the given severity that always wants to fire, so the // only thing under test is the gate. func testRule(name string, sev Severity) Rule { return Rule{ Name: name, Severity: sev, Cooldown: Cooldown{Base: 30 * time.Minute, Min: time.Minute, Max: time.Hour}, Predicate: func(State) bool { return true }, } } // ---------------------------- quiet hours ------------------------------------ // Quiet hours silence care and leave ops alone. A failed backup at 2am matters; // a water nudge at 2am does not. func TestGateQuietHoursSuppressesCareOnly(t *testing.T) { cases := []struct { sev Severity want bool }{ {Sev1, false}, {Sev2, false}, {Sev3, true}, {Sev4, true}, } for _, c := range cases { s := State{Now: refTime(), Presence: store.Present, QuietHours: true} if got := Gate(s, testRule("r", c.sev)); got != c.want { t.Errorf("quiet hours sev%d: want fire=%v, got %v", c.sev, c.want, got) } } } // ---------------------------- presence --------------------------------------- // docs/design.md § Delivery: "sev <= 2 drops on away, sev >= 3 holds: a missed water // nudge is noise, a missed backup failure isn't." func TestGateAwayDropsCareHoldsOps(t *testing.T) { cases := []struct { sev Severity want bool }{ {Sev1, false}, {Sev2, false}, {Sev3, true}, {Sev4, true}, } for _, c := range cases { s := State{Now: refTime(), Presence: store.Away} if got := Gate(s, testRule("r", c.sev)); got != c.want { t.Errorf("away sev%d: want fire=%v, got %v", c.sev, c.want, got) } } } // Care nudges are allowed through when the user is actually there and nothing // else is suppressing. Without this the "quiet" tests above could pass on a // gate that simply never fires. func TestGateAllowsCareWhenPresentAndClear(t *testing.T) { s := State{Now: refTime(), Presence: store.Present} if !Gate(s, testRule("r", Sev1)) { t.Fatal("present and clear: care nudge should be allowed") } } // ---------------------------- calendar busy ---------------------------------- // "Don't nag mid-meeting" is an env predicate in the gate, not the LLM's call. // Ops still gets through — a service being down mid-meeting is worth the // interruption. func TestGateCalendarBusySuppressesCareOnly(t *testing.T) { care := State{Now: refTime(), Presence: store.Present, CalendarBusy: true} if Gate(care, testRule("r", Sev2)) { t.Error("calendar busy: care nudge should be suppressed") } if !Gate(care, testRule("r", Sev4)) { t.Error("calendar busy: ops hard should still fire") } } // ---------------------------- cooldown --------------------------------------- // Cooldown holds for every severity — it is the anti-nag knob, so ops cannot // buy its way past it either. func TestGateCooldownHoldsForAllSeverities(t *testing.T) { now := refTime() for _, sev := range []Severity{Sev1, Sev2, Sev3, Sev4} { s := State{ Now: now, Presence: store.Present, CooldownUntil: map[string]time.Time{"r": now.Add(10 * time.Minute)}, } if Gate(s, testRule("r", sev)) { t.Errorf("cooldown sev%d: should be suppressed", sev) } } } // Cooldown is per-rule: one rule cooling down must not mute another. func TestGateCooldownIsPerRule(t *testing.T) { now := refTime() s := State{ Now: now, Presence: store.Present, CooldownUntil: map[string]time.Time{"water": now.Add(10 * time.Minute)}, } if Gate(s, testRule("water", Sev1)) { t.Error("water is cooling down and should be suppressed") } if !Gate(s, testRule("meal", Sev1)) { t.Error("meal has no cooldown and should be allowed") } } // The moment the cooldown expires the rule is free again — the gate compares // with Before, so "until" itself is already clear. func TestGateCooldownExpires(t *testing.T) { now := refTime() s := State{ Now: now, Presence: store.Present, CooldownUntil: map[string]time.Time{"r": now}, } if !Gate(s, testRule("r", Sev1)) { t.Fatal("cooldown at exactly now should already be clear") } } // ---------------------------- snooze ----------------------------------------- // Snooze is the user saying "not about this". It beats everything, including // ops hard. func TestGateSnoozeHoldsForAllSeverities(t *testing.T) { now := refTime() for _, sev := range []Severity{Sev1, Sev2, Sev3, Sev4} { s := State{ Now: now, Presence: store.Present, SnoozeUntil: map[string]time.Time{"r": now.Add(time.Hour)}, } if Gate(s, testRule("r", sev)) { t.Errorf("snooze sev%d: should be suppressed", sev) } } } // ---------------------------- no-data backstop ------------------------------- // The gate enforces no-data inertness a second time, for any rule that declared // the keys it needs. A predicate that forgets the check still cannot fire. func TestGateNoDataBackstopBeatsAnEagerPredicate(t *testing.T) { now := refTime() eager := Rule{ Name: "eager", Severity: Sev4, // even ops hard does not get past missing data Predicate: func(State) bool { return true }, InertWhenNoData: []string{"water", "meal"}, } // one of the two keys present is not enough. s := State{ Now: now, Presence: store.Present, Facts: map[string]store.Fact{"water": ago("water", "tap:water", `"250ml"`, time.Hour)}, } if Gate(s, eager) { t.Fatal("a rule missing one of its keys must stay inert") } } // ---------------------------- one nudge per tick ----------------------------- // All five default rules want to fire at once. The tick must still emit exactly // one candidate, the loudest — never a dogpile. func TestTickNeverDogpilesAndPicksLoudest(t *testing.T) { now := refTime() s := State{ Now: now, Presence: store.Present, Facts: map[string]store.Fact{ "water": ago("water", "tap:water", `"250ml"`, 5*time.Hour), "meal": ago("meal", "voice", `"lunch"`, 8*time.Hour), "desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second), "break": ago("break", "voice", `"walk"`, 3*time.Hour), "service_down:db": ago("service_down:db", "poll:uptimekuma", `"down"`, 5*time.Minute), "netdata_alarm": ago("netdata_alarm", "poll:netdata", `"critical"`, time.Minute), }, } // sanity: every rule really does want to fire, so the pick is a real choice. for _, r := range DefaultRules() { if !r.Predicate(s) { t.Fatalf("setup: rule %q does not want to fire", r.Name) } } got := Tick(s, DefaultRules()) if got == nil { t.Fatal("all rules firing: want one candidate, got nil") } if got.Rule.Name != "service_down" || got.Severity != Sev4 { t.Fatalf("want the loudest (service_down/sev4), got %s/sev%d", got.Rule.Name, got.Severity) } } // Tick returns a single Candidate by type, so "one per tick" cannot be violated // by count — what can drift is WHICH one. Equal severities tie-break by name so // the choice is deterministic across ticks. func TestTickTieBreaksByNameForDeterminism(t *testing.T) { s := State{Now: refTime(), Presence: store.Present} rules := []Rule{testRule("zebra", Sev2), testRule("apple", Sev2), testRule("mango", Sev2)} for i := 0; i < 5; i++ { got := Tick(s, rules) if got == nil || got.Rule.Name != "apple" { t.Fatalf("tie-break: want apple every time, got %+v", got) } } } // The loudest candidate wins even when the quiet one is listed first. func TestTickOrderOfRulesDoesNotMatter(t *testing.T) { s := State{Now: refTime(), Presence: store.Present} first := Tick(s, []Rule{testRule("care", Sev1), testRule("ops", Sev4)}) second := Tick(s, []Rule{testRule("ops", Sev4), testRule("care", Sev1)}) if first == nil || second == nil { t.Fatal("want a candidate from both orderings") } if first.Rule.Name != "ops" || second.Rule.Name != "ops" { t.Fatalf("order changed the pick: %s then %s", first.Rule.Name, second.Rule.Name) } } // ---------------------------- reminders bypass the gate ---------------------- // docs/design.md § User reminders: "bypasses the restraint gate — 'wake me 7' fires // in quiet hours; that's the point." Every suppressor set at once, and the // reminder still comes through. func TestRemindersBypassEverySuppressor(t *testing.T) { now := refTime() s := State{ Now: now, Presence: store.Away, QuietHours: true, CalendarBusy: true, CooldownUntil: map[string]time.Time{"reminder": now.Add(time.Hour)}, } due := []store.Reminder{{ID: 7, Payload: `{"text":"wake me"}`}} got := RemindDecisions(s, due) if len(got) != 1 || got[0].Reminder.ID != 7 { t.Fatalf("reminder must bypass the gate, got %+v", got) } } // GAP — docs/design.md § User reminders ends "Snooze still applies." RemindDecisions // passes every due reminder straight through with no snooze check, so a snoozed // reminder fires anyway. The test below is what the contract asks for. func TestRemindersStillHonourSnooze(t *testing.T) { now := refTime() s := State{ Now: now, Presence: store.Present, SnoozeUntil: map[string]time.Time{"reminder:7": now.Add(time.Hour)}, } due := []store.Reminder{{ID: 7, Payload: `{"text":"wake me"}`}} if got := RemindDecisions(s, due); len(got) != 0 { t.Fatalf("snoozed reminder should not be delivered, got %+v", got) } } // ---------------------------- digest eligibility ------------------------------ // A Sev2 care candidate (break) suppressed for a genuine restraint reason is // worth resurfacing later. func TestDigestEligibleSev2SuppressedByRestraint(t *testing.T) { for _, reason := range []string{"quiet_hours", "calendar_busy", "presence"} { if !DigestEligible(Sev2, reason) { t.Errorf("sev2 blocked by %q: want digest-eligible", reason) } } } // A Sev1 care candidate (water/meal) never digests — a biological timer // nudge is stale by the time anyone could resurface it, so it just drops. func TestDigestEligibleSev1NeverDigests(t *testing.T) { for _, reason := range []string{"quiet_hours", "calendar_busy", "presence"} { if DigestEligible(Sev1, reason) { t.Errorf("sev1 blocked by %q: want drop, got digest-eligible", reason) } } } // Ops severities are never blocked by these reasons in practice (Gate only // applies quiet_hours/calendar_busy/presence to care severities), but the // boundary itself must refuse to digest a high severity even if asked — // alarms bypass the gate and deliver now, unchanged, never delayed. func TestDigestEligibleNeverDigestsHighSeverity(t *testing.T) { for _, sev := range []Severity{Sev3, Sev4} { for _, reason := range []string{"quiet_hours", "calendar_busy", "presence"} { if DigestEligible(sev, reason) { t.Errorf("sev%d blocked by %q: high severity must never digest", sev, reason) } } } } // cooldown and snooze are not "suppression" in the digest sense — cooldown // means it was already said recently, snooze means the user asked to not // hear about it. Neither should resurface later just because the severity // matches. func TestDigestEligibleExcludesCooldownAndSnooze(t *testing.T) { for _, reason := range []string{"cooldown", "snooze", "inert_no_data", "predicate", ""} { if DigestEligible(Sev2, reason) { t.Errorf("sev2 blocked by %q: should not be digest-eligible", reason) } } } // GAP — the gate reads State.SnoozeUntil, but the Gatherer hard-codes it to nil // (internal/loop/gather.go:153), so snooze is dead in the running daemon: the // unit tests above pass while nothing can ever populate the map. This asserts // the Gatherer actually produces a snooze map. func TestGathererPopulatesSnoozeUntil(t *testing.T) { ctx := context.Background() st, err := store.Open(ctx, t.TempDir()+"/m.db") if err != nil { t.Fatal(err) } defer st.Close() g := NewGatherer(st, DefaultRules()) snap, _, err := g.GatherState(ctx, refTime()) if err != nil { t.Fatal(err) } if snap.SnoozeUntil == nil { t.Fatal("Gatherer returned a nil SnoozeUntil map") } }