7e21cd06b3
Stub and LLM paths both read loop.DownServices, so the message can never name a service the predicate did not fire on. Two down at once are both named — he needs the blast radius.
311 lines
9.1 KiB
Go
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(-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:db": factAt("service_down:db", "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")
|
|
}
|
|
}
|
|
}
|
|
}
|