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": factAt("service_down", "poll:uptimekuma", `"down"`, now.Add(-1*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": factAt("service_down", "poll:uptimekuma", `"down"`, now.Add(-1*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") } } } }