Files
Maven/internal/loop/explain_test.go
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

311 lines
9.1 KiB
Go

package loop
import (
"testing"
"time"
"github.com/kami/maven/internal/store"
)
// ----------------------------- ExplainGate --------------------------------
func TestExplainGate_AllPass(t *testing.T) {
now := refTime()
s := State{Now: now, Presence: store.Present}
r := Rule{Name: "test", Severity: Sev3, Predicate: func(State) bool { return true }}
passed, blocked, d := ExplainGate(s, r)
if !passed {
t.Fatalf("all-pass gate: want passed, blocked by %q; detail=%+v", blocked, d)
}
}
func TestExplainGate_Snooze(t *testing.T) {
now := refTime()
s := State{
Now: now,
Presence: store.Present,
SnoozeUntil: map[string]time.Time{"water": now.Add(time.Hour)},
Facts: map[string]store.Fact{"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour))},
}
r := WaterRule()
passed, blocked, d := ExplainGate(s, r)
if passed || blocked != "snooze" {
t.Fatalf("snooze: want blocked=snooze, got passed=%v blocked=%q", passed, blocked)
}
if d.SnoozeUntil == nil || !d.SnoozeUntil.Equal(now.Add(time.Hour)) {
t.Fatalf("snooze-until missing or wrong: %v", d.SnoozeUntil)
}
}
func TestExplainGate_Cooldown(t *testing.T) {
now := refTime()
s := State{
Now: now,
Presence: store.Present,
CooldownUntil: map[string]time.Time{"water": now.Add(10 * time.Minute)},
Facts: map[string]store.Fact{"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour))},
}
r := WaterRule()
passed, blocked, d := ExplainGate(s, r)
if passed || blocked != "cooldown" {
t.Fatalf("cooldown: want blocked=cooldown, got passed=%v blocked=%q", passed, blocked)
}
if d.CooldownUntil == nil || !d.CooldownUntil.Equal(now.Add(10*time.Minute)) {
t.Fatalf("cooldown-until missing or wrong: %v", d.CooldownUntil)
}
}
func TestExplainGate_QuietHours(t *testing.T) {
now := refTime()
s := State{Now: now, Presence: store.Present, QuietHours: true}
r := WaterRule() // Sev1 — care
passed, blocked, _ := ExplainGate(s, r)
if passed || blocked != "quiet_hours" {
t.Fatalf("quiet hours: want blocked=quiet_hours, got passed=%v blocked=%q", passed, blocked)
}
}
func TestExplainGate_QuietHoursOpsBypass(t *testing.T) {
now := refTime()
s := State{Now: now, Presence: store.Present, QuietHours: true,
Facts: map[string]store.Fact{"netdata_alarm": factAt("netdata_alarm", "poll:netdata", `"critical"`, now.Add(-1*time.Minute))},
}
r := NetdataCriticalRule() // Sev3 — ops
passed, blocked, d := ExplainGate(s, r)
if !passed {
t.Fatalf("ops sev3 should bypass quiet hours, blocked=%q detail=%+v", blocked, d)
}
}
func TestExplainGate_CalendarBusy(t *testing.T) {
now := refTime()
s := State{Now: now, Presence: store.Present, CalendarBusy: true}
r := WaterRule() // Sev1
passed, blocked, _ := ExplainGate(s, r)
if passed || blocked != "calendar_busy" {
t.Fatalf("calendar busy: want blocked=calendar_busy, got passed=%v blocked=%q", passed, blocked)
}
}
func TestExplainGate_PresenceAway(t *testing.T) {
now := refTime()
s := State{Now: now, Presence: store.Away}
r := WaterRule() // Sev1 care
passed, blocked, d := ExplainGate(s, r)
if passed || blocked != "presence" {
t.Fatalf("presence away: want blocked=presence, got passed=%v blocked=%q detail=%+v", passed, blocked, d)
}
if d.Presence != "away" {
t.Fatalf("detail presence: want away, got %q", d.Presence)
}
}
func TestExplainGate_PresenceAwayOpsBypass(t *testing.T) {
now := refTime()
s := State{Now: now, Presence: store.Away,
Facts: map[string]store.Fact{"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-5*time.Minute))},
}
r := ServiceDownRule() // Sev4 ops
passed, blocked, d := ExplainGate(s, r)
if !passed {
t.Fatalf("ops sev4 should bypass away, blocked=%q detail=%+v", blocked, d)
}
}
func TestExplainGate_InertNoData(t *testing.T) {
now := refTime()
s := State{Now: now, Presence: store.Present} // no facts at all
r := WaterRule() // InertWhenNoData: ["water"]
passed, blocked, d := ExplainGate(s, r)
if passed || blocked != "inert_no_data" {
t.Fatalf("inert: want blocked=inert_no_data, got passed=%v blocked=%q", passed, blocked)
}
if len(d.InertKeysMissing) != 1 || d.InertKeysMissing[0] != "water" {
t.Fatalf("inert keys: want [water], got %v", d.InertKeysMissing)
}
}
func TestExplainGate_AllBlockersOrdered(t *testing.T) {
// Snooze is checked before cooldown, quiet hours, etc.
now := refTime()
s := State{
Now: now,
Presence: store.Present,
SnoozeUntil: map[string]time.Time{"water": now.Add(time.Hour)},
CooldownUntil: map[string]time.Time{"water": now.Add(10 * time.Minute)},
QuietHours: true,
}
r := WaterRule()
_, blocked, d := ExplainGate(s, r)
if blocked != "snooze" {
t.Fatalf("snooze should be checked first: got blocked=%q", blocked)
}
if d.SnoozeUntil == nil {
t.Fatal("snooze-until should be set")
}
}
// ----------------------------- ExplainTick --------------------------------
func TestExplainTick_NothingFires(t *testing.T) {
now := refTime()
s := State{Now: now, Presence: store.Away}
cand, trace := ExplainTick(s, DefaultRules())
if cand != nil {
t.Fatalf("nil candidate expected, got %+v", cand)
}
if trace.Winner != "" {
t.Fatalf("empty winner expected, got %q", trace.Winner)
}
if !trace.Now.Equal(now) {
t.Fatalf("trace now mismatch")
}
if len(trace.RuleTraces) != len(DefaultRules()) {
t.Fatalf("expected %d rule traces, got %d", len(DefaultRules()), len(trace.RuleTraces))
}
}
func TestExplainTick_OneFires(t *testing.T) {
now := refTime()
s := State{
Now: now,
Presence: store.Present,
Facts: map[string]store.Fact{
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
},
}
cand, trace := ExplainTick(s, DefaultRules())
if cand == nil || cand.Rule.Name != "water" {
t.Fatalf("want water candidate, got %+v", cand)
}
if trace.Winner != "water" {
t.Fatalf("winner water, got %q", trace.Winner)
}
var found bool
for _, r := range trace.RuleTraces {
if r.RuleName == "water" {
found = true
if !r.PredicateResult {
t.Error("water predicate should be true")
}
if !r.GateResult {
t.Error("water gate should be true")
}
if !r.WasSelected {
t.Error("water should be selected")
}
}
}
if !found {
t.Fatal("water rule not in trace")
}
}
func TestExplainTick_Tiebreak(t *testing.T) {
// Two rules with same severity — alphabetically first should win.
now := refTime()
s := State{
Now: now,
Presence: store.Present,
Facts: map[string]store.Fact{
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
"meal": factAt("meal", "tap:meal", `"pizza"`, now.Add(-7*time.Hour)),
},
}
cand, trace := ExplainTick(s, []Rule{MealRule(), WaterRule()})
if cand == nil {
t.Fatal("expected a candidate")
}
// Both Sev1, "meal" < "water" alphabetically → meal wins
if cand.Rule.Name != "meal" {
t.Fatalf("tiebreak: want meal, got %q", cand.Rule.Name)
}
if trace.Winner != "meal" {
t.Fatalf("winner should be meal, got %q", trace.Winner)
}
// meal was selected, water lost to meal
var waterFound, mealFound bool
for _, r := range trace.RuleTraces {
switch r.RuleName {
case "water":
waterFound = true
if r.LostTo != "meal" {
t.Errorf("water.LostTo = %q, want meal", r.LostTo)
}
if r.WasSelected {
t.Error("water should not be selected in tiebreak")
}
case "meal":
mealFound = true
if !r.WasSelected {
t.Error("meal should be selected")
}
if r.LostTo != "" {
t.Errorf("meal.LostTo = %q, want empty", r.LostTo)
}
}
}
if !waterFound || !mealFound {
t.Fatal("both rules should be in trace")
}
}
func TestExplainTick_WinnerRecorded(t *testing.T) {
now := refTime()
// water (Sev1) and service_down (Sev4) both want to fire.
s := State{
Now: now,
Presence: store.Present,
Facts: map[string]store.Fact{
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-5*time.Minute)),
},
}
cand, trace := ExplainTick(s, DefaultRules())
if cand == nil || cand.Rule.Name != "service_down" {
t.Fatalf("max severity wins: want service_down, got %+v", cand)
}
if trace.Winner != "service_down" {
t.Fatalf("winner = %q, want service_down", trace.Winner)
}
// water lost to service_down
var waterLost bool
for _, r := range trace.RuleTraces {
if r.RuleName == "water" && r.LostTo == "service_down" {
waterLost = true
}
}
if !waterLost {
t.Error("water should show lost_to=service_down")
}
}
func TestExplainTick_GateBlockedRecorded(t *testing.T) {
now := refTime()
s := State{
Now: now,
Presence: store.Present,
QuietHours: true,
Facts: map[string]store.Fact{
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
},
}
_, trace := ExplainTick(s, DefaultRules())
for _, tr := range trace.RuleTraces {
if tr.RuleName == "water" {
if tr.GateBlockedBy != "quiet_hours" {
t.Errorf("water blocked by %q, want quiet_hours", tr.GateBlockedBy)
}
if tr.PredicateResult != true {
t.Error("water predicate should be true")
}
if tr.GateResult != false {
t.Error("water gate should be false")
}
}
}
}