0560684b35
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.
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(-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")
|
|
}
|
|
}
|
|
}
|
|
}
|