Files
Maven/internal/loop/rules_test.go
T
claude 7e21cd06b3 phraser: name the service that is down (V-444)
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.
2026-08-04 05:15:01 +04:00

502 lines
17 KiB
Go

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")
}
}