package loop import ( "testing" "time" "github.com/kami/maven/internal/store" ) // Direct tests for the five default rule predicates. // // A predicate is pure — (State) -> bool, no I/O — so these need no store and no // daemon. They test the predicate ALONE: the restraint gate is tested in // loop_test.go and gate_test.go, never here. // // Every rule gets the same three questions plus its own edges: // - does it fire when it should? // - does it stay quiet when it should? // - is it silent when the key it needs has no data at all? // // The last one is load-bearing. docs/design.md: "since(key)==null → don't fire. // Silence on no-data is 'shuts up when uncertain'." // stateWith builds a snapshot at refTime() holding just the given facts. // Presence and the env flags are left zero — the predicate must not read them. func stateWith(facts map[string]store.Fact) State { return State{Now: refTime(), Facts: facts} } // ago is a fact for key written `d` before refTime(). func ago(key, source, value string, d time.Duration) store.Fact { return factAt(key, source, value, refTime().Add(-d)) } // ---------------------------- since-based care rules ------------------------- // The three care rules share one shape: "fire when it has been at least N since // the last fact for key". One table drives all of them. func TestCareRulePredicates(t *testing.T) { cases := []struct { name string rule Rule facts map[string]store.Fact want bool }{ // water — threshold 3h. { name: "water fires at 4h", rule: WaterRule(), facts: map[string]store.Fact{"water": ago("water", "tap:water", `"250ml"`, 4*time.Hour)}, want: true, }, { name: "water fires exactly at the 3h threshold", rule: WaterRule(), facts: map[string]store.Fact{"water": ago("water", "tap:water", `"250ml"`, 3*time.Hour)}, want: true, }, { name: "water quiet just under 3h", rule: WaterRule(), facts: map[string]store.Fact{"water": ago("water", "tap:water", `"250ml"`, 3*time.Hour-time.Minute)}, want: false, }, { name: "water quiet on no data", rule: WaterRule(), facts: nil, want: false, }, { name: "water quiet on a zero-timestamp fact", rule: WaterRule(), facts: map[string]store.Fact{"water": {Key: "water", Source: "tap:water", Value: `"250ml"`}}, want: false, }, { name: "water quiet when the only fact is for another key", rule: WaterRule(), facts: map[string]store.Fact{"meal": ago("meal", "voice", `"lunch"`, 9*time.Hour)}, want: false, }, // meal — threshold 6h. { name: "meal fires at 7h", rule: MealRule(), facts: map[string]store.Fact{"meal": ago("meal", "voice", `"lunch"`, 7*time.Hour)}, want: true, }, { name: "meal fires exactly at the 6h threshold", rule: MealRule(), facts: map[string]store.Fact{"meal": ago("meal", "voice", `"lunch"`, 6*time.Hour)}, want: true, }, { name: "meal quiet just under 6h", rule: MealRule(), facts: map[string]store.Fact{"meal": ago("meal", "voice", `"lunch"`, 6*time.Hour-time.Minute)}, want: false, }, { name: "meal quiet on no data", rule: MealRule(), facts: nil, want: false, }, // break — needs BOTH anchors: at the desk now, and no break for 90min. { name: "break fires when at desk and no break for 2h", rule: BreakRule(), facts: map[string]store.Fact{ "desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second), "break": ago("break", "voice", `"walk"`, 2*time.Hour), }, want: true, }, { name: "break fires exactly at both thresholds", rule: BreakRule(), facts: map[string]store.Fact{ "desk_active": ago("desk_active", "infer:hyprland", "1", 2*time.Minute), "break": ago("break", "voice", `"walk"`, 90*time.Minute), }, want: true, }, { name: "break quiet when the desk signal is stale (user left)", rule: BreakRule(), facts: map[string]store.Fact{ "desk_active": ago("desk_active", "infer:hyprland", "1", 10*time.Minute), "break": ago("break", "voice", `"walk"`, 2*time.Hour), }, want: false, }, { name: "break quiet when the last break was recent", rule: BreakRule(), facts: map[string]store.Fact{ "desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second), "break": ago("break", "voice", `"walk"`, 20*time.Minute), }, want: false, }, { name: "break quiet with only the desk anchor", rule: BreakRule(), facts: map[string]store.Fact{ "desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second), }, want: false, }, { name: "break quiet with only the break anchor", rule: BreakRule(), facts: map[string]store.Fact{ "break": ago("break", "voice", `"walk"`, 2*time.Hour), }, want: false, }, { name: "break quiet on no data", rule: BreakRule(), facts: nil, want: false, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if got := c.rule.Predicate(stateWith(c.facts)); got != c.want { t.Fatalf("%s predicate: want %v, got %v", c.rule.Name, c.want, got) } }) } } // ---------------------------- ops rules -------------------------------------- // The two ops rules match on a value AND on which poller wrote it. docs/design.md: // "a compromised poller must not be able to forge a trigger." Half of this // table is forgery attempts; all of them must be refused. func TestOpsRulePredicates(t *testing.T) { cases := []struct { name string rule Rule facts map[string]store.Fact want bool }{ // service_down — only poll:uptimekuma may say a service is down. { name: "service_down fires on a kuma down fact", rule: ServiceDownRule(), facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma", `"down"`, time.Minute)}, want: true, }, { name: "service_down quiet when kuma says up", rule: ServiceDownRule(), facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma", `"up"`, time.Minute)}, want: false, }, { name: "service_down quiet on no data", rule: ServiceDownRule(), facts: nil, want: false, }, { name: "service_down quiet on a zero-timestamp fact", rule: ServiceDownRule(), facts: map[string]store.Fact{"service_down:db": {Key: "service_down:db", Source: "poll:uptimekuma", Value: `"down"`}}, want: false, }, // forgery attempts — right value, wrong writer. { name: "service_down refuses a forgery from the netdata poller", rule: ServiceDownRule(), facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:netdata", `"down"`, time.Minute)}, want: false, }, { name: "service_down refuses a forgery from ambient audio", rule: ServiceDownRule(), facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "ambient:other", `"down"`, time.Minute)}, want: false, }, { name: "service_down refuses a forgery from the user's own voice", rule: ServiceDownRule(), facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "voice", `"down"`, time.Minute)}, want: false, }, { name: "service_down refuses a source that only looks like kuma", rule: ServiceDownRule(), facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma-staging", `"down"`, time.Minute)}, want: false, }, { name: "service_down refuses an unquoted down value", rule: ServiceDownRule(), facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma", `down`, time.Minute)}, want: false, }, // netdata_critical — only poll:netdata may raise a critical alarm. { name: "netdata_critical fires on a netdata critical alarm", rule: NetdataCriticalRule(), facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"critical"`, time.Minute)}, want: true, }, { name: "netdata_critical quiet on a warning alarm", rule: NetdataCriticalRule(), facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"warning"`, time.Minute)}, want: false, }, { name: "netdata_critical quiet on a cleared alarm", rule: NetdataCriticalRule(), facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"clear"`, time.Minute)}, want: false, }, { name: "netdata_critical quiet on no data", rule: NetdataCriticalRule(), facts: nil, want: false, }, { name: "netdata_critical quiet on a zero-timestamp fact", rule: NetdataCriticalRule(), facts: map[string]store.Fact{"netdata_alarm": {Key: "netdata_alarm", Source: "poll:netdata", Value: `"critical"`}}, want: false, }, { name: "netdata_critical refuses a forgery from the kuma poller", rule: NetdataCriticalRule(), facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "poll:uptimekuma", `"critical"`, time.Minute)}, want: false, }, { name: "netdata_critical refuses a forgery from ambient audio", rule: NetdataCriticalRule(), facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "ambient:other", `"critical"`, time.Minute)}, want: false, }, { name: "netdata_critical reads netdata_alarm, not netdata_critical", rule: NetdataCriticalRule(), facts: map[string]store.Fact{"netdata_critical": ago("netdata_critical", "poll:netdata", `"critical"`, time.Minute)}, want: false, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if got := c.rule.Predicate(stateWith(c.facts)); got != c.want { t.Fatalf("%s predicate: want %v, got %v", c.rule.Name, c.want, got) } }) } } // ---------------------------- rule metadata ---------------------------------- // Every default rule must declare the keys it needs. The gate uses that list as // a second no-data backstop, so a rule that forgets it loses the safety net // even if its predicate happens to check. func TestDefaultRulesDeclareInertKeys(t *testing.T) { // A rule over a key set that only exists at read time declares a prefix // instead — the gatherer still needs to be told what to load. for _, r := range DefaultRules() { if len(r.InertWhenNoData) == 0 && len(r.WantPrefixes) == 0 { t.Errorf("rule %q declares neither InertWhenNoData keys nor WantPrefixes", r.Name) } } } // A no-data snapshot must make EVERY default rule quiet, predicate alone, with // the gate out of the picture. This is the whole-set version of the per-rule // no-data cases above. func TestNoDefaultRuleFiresOnEmptyState(t *testing.T) { empty := stateWith(nil) for _, r := range DefaultRules() { if r.Predicate(empty) { t.Errorf("rule %q fires on an empty snapshot", r.Name) } } } // Severities are the delivery contract (docs/design.md § Delivery / channel // routing): care is sev1-2 and drops when away, ops is sev3-4 and holds. Pin // them so a change to a rule's insistence has to be deliberate. func TestDefaultRuleSeverities(t *testing.T) { want := map[string]Severity{ "water": Sev1, "meal": Sev1, "break": Sev2, "service_down": Sev4, "netdata_critical": Sev3, } got := map[string]Severity{} for _, r := range DefaultRules() { got[r.Name] = r.Severity } if len(got) != len(want) { t.Fatalf("rule count changed: want %d, got %d", len(want), len(got)) } for name, sev := range want { if got[name] != sev { t.Errorf("rule %q severity: want %d, got %d", name, sev, got[name]) } } } // Cooldown bounds keep the feedback tuner honest — docs/design.md wants // `cooldown in [min,max]` "so a weird week can't mutate Maven silent or // stalker". A base outside its own envelope would make that meaningless. func TestDefaultRuleCooldownsAreBounded(t *testing.T) { for _, r := range DefaultRules() { c := r.Cooldown if c.Min <= 0 || c.Base <= 0 || c.Max <= 0 { t.Errorf("rule %q has a non-positive cooldown: %+v", r.Name, c) continue } if c.Base < c.Min || c.Base > c.Max { t.Errorf("rule %q base %v outside envelope [%v, %v]", r.Name, c.Base, c.Min, c.Max) } } } // A predicate must read only the snapshot it is handed. Same snapshot twice // (and a snapshot shared between two rules) must give the same answer — no // hidden state, no clock reads. func TestPredicatesArePure(t *testing.T) { s := stateWith(map[string]store.Fact{ "water": ago("water", "tap:water", `"250ml"`, 4*time.Hour), "meal": ago("meal", "voice", `"lunch"`, 7*time.Hour), "desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second), "break": ago("break", "voice", `"walk"`, 2*time.Hour), "service_down:db": ago("service_down:db", "poll:uptimekuma", `"down"`, time.Minute), }) for _, r := range DefaultRules() { first := r.Predicate(s) for i := 0; i < 3; i++ { if again := r.Predicate(s); again != first { t.Fatalf("rule %q predicate is not pure: %v then %v", r.Name, first, again) } } } } func ruleNames(rs []Rule) []string { out := make([]string, len(rs)) for i, r := range rs { out[i] = r.Name } return out } func TestRulesExceptDropsOnlyTheNamed(t *testing.T) { rules, dropped := RulesExcept([]string{"service_down"}) if len(rules) != len(DefaultRules())-1 { t.Fatalf("got %v", ruleNames(rules)) } for _, r := range rules { if r.Name == "service_down" { t.Error("a disabled rule was still wired") } } if len(dropped) != 1 || dropped[0] != "service_down" { t.Errorf("dropped = %v", dropped) } } // A config that names a rule nobody wrote must not stop the daemon booting, // and must not quietly drop a real rule alongside it. func TestRulesExceptIgnoresUnknownNames(t *testing.T) { rules, dropped := RulesExcept([]string{"no_such_rule", " ", ""}) if len(rules) != len(DefaultRules()) { t.Errorf("an unknown name removed something: %v", ruleNames(rules)) } if len(dropped) != 0 { t.Errorf("dropped = %v, want nothing", dropped) } } func TestRulesExceptEmptyKeepsEverything(t *testing.T) { rules, dropped := RulesExcept(nil) if len(rules) != len(DefaultRules()) || dropped != nil { t.Errorf("rules = %v, dropped = %v", ruleNames(rules), dropped) } } // ---------------------------- per-monitor service_down ----------------------- // The rule must name what fired on it, and the phraser reads the same helper, // so a service that is up can never be spoken as down. func TestDownServicesNamesOnlyTheDownOnes(t *testing.T) { s := State{ Now: refTime(), Facts: map[string]store.Fact{ "service_down:web": ago("service_down:web", ServiceDownSource, `"up"`, time.Minute), "service_down:db": ago("service_down:db", ServiceDownSource, `"down"`, time.Minute), "service_down:vault": ago("service_down:vault", ServiceDownSource, `"down"`, time.Minute), "service_down:paused": ago("service_down:paused", ServiceDownSource, `"maintenance"`, time.Minute), "service_down:forged": ago("service_down:forged", "voice", `"down"`, time.Minute), "service_down:missing": ago("service_down:missing", ServiceDownSource, `"unknown"`, time.Minute), }, } got := DownServices(s) want := []string{"db", "vault"} // key order, so speech is stable if len(got) != len(want) { t.Fatalf("DownServices = %v, want %v", got, want) } for i := range want { if got[i] != want[i] { t.Fatalf("DownServices = %v, want %v", got, want) } } } // A monitor paused in kuma must silence that monitor. Before per-monitor facts // the aggregate stayed "down" and pausing achieved nothing. func TestPausedMonitorSilencesOnlyItself(t *testing.T) { base := map[string]store.Fact{ "service_down:db": ago("service_down:db", ServiceDownSource, `"maintenance"`, time.Minute), "service_down:web": ago("service_down:web", ServiceDownSource, `"down"`, time.Minute), } if !ServiceDownRule().Predicate(State{Now: refTime(), Facts: base}) { t.Fatal("web is still down, the rule must fire") } delete(base, "service_down:web") if ServiceDownRule().Predicate(State{Now: refTime(), Facts: base}) { t.Fatal("only a paused monitor is left, the rule must be quiet") } } // Edge-triggered: he is told once per transition. A service that stays down // for a day used to qualify on every tick, with cooldown as the only brake. func TestServiceDownFiresOncePerTransition(t *testing.T) { down := ago("service_down:db", ServiceDownSource, `"down"`, time.Hour) s := State{Now: refTime(), Facts: map[string]store.Fact{"service_down:db": down}} if !ServiceDownRule().Predicate(s) { t.Fatal("first sight of the transition must fire") } s.LastNudge = map[string]store.Nudge{"service_down": {Ts: down.Ts.Add(time.Minute)}} if ServiceDownRule().Predicate(s) { t.Fatal("already told about this transition, must be quiet") } // A second service goes down after that nudge — a new edge, so it fires. s.Facts["service_down:web"] = ago("service_down:web", ServiceDownSource, `"down"`, time.Minute) if !ServiceDownRule().Predicate(s) { t.Fatal("a later transition must fire again") } }