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. 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. 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": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute)}, want: true, }, { name: "service_down quiet when kuma says up", rule: ServiceDownRule(), facts: map[string]store.Fact{"service_down": ago("service_down", "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": {Key: "service_down", 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": ago("service_down", "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": ago("service_down", "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": ago("service_down", "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": ago("service_down", "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": ago("service_down", "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) { for _, r := range DefaultRules() { if len(r.InertWhenNoData) == 0 { t.Errorf("rule %q declares no InertWhenNoData keys", 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 (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 — 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": ago("service_down", "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) } }