Files
Maven/internal/loop/gate_test.go
T
claude 0560684b35 kuma: a monitor must stay down before it wakes him (V-536)
Technitium read down on one poll and up on the next, sixty seconds apart, and
the sev4 arrived after the service was already back.

mavpoll writes a service_down fact only when the state changes, so the fact's
timestamp IS the moment the monitor went down and its age is how long it has
stayed there. The debounce is that age against MinDownAge, 90s — one poll
interval plus jitter. No history to keep and no counter to persist.

It bounds the alarm and not the truth: DownServices still reports a monitor the
instant it goes down, because /dash showing a fresh outage is right even when
phoning him about it is not. Existing fixtures that seeded a one-minute-old
down fact now seed five, which is what they always meant.
2026-08-05 02:27:22 +04:00

359 lines
12 KiB
Go

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