diff --git a/internal/loop/gate_test.go b/internal/loop/gate_test.go new file mode 100644 index 0000000..09bcb62 --- /dev/null +++ b/internal/loop/gate_test.go @@ -0,0 +1,312 @@ +package loop + +import ( + "context" + "testing" + "time" + + "github.com/kami/maven/internal/store" +) + +// Tests for the universal restraint gate. +// +// 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 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 --------------------------------------- + +// 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": ago("service_down", "poll:uptimekuma", `"down"`, 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 ---------------------- + +// 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 — 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) { + t.Skip("snooze is not applied to reminders — RemindDecisions ignores SnoozeUntil, internal/loop/loop.go:120") + + 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) + } +} + +// 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) { + t.Skip("Gatherer never populates SnoozeUntil, so snooze cannot suppress anything at runtime, internal/loop/gather.go:153") + + 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") + } +}