From 9e2af3286bcce1cdc2cca689610e268a8e1782a8 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 31 Jul 2026 02:12:25 +0400 Subject: [PATCH] Unit-test all five proactive rule predicates Table-driven tests for water, meal, break, service_down and netdata_critical, straight against the predicate with a fake State. Reviewers: the no-data rows (every rule must stay quiet when its key is missing) and the ops forgery rows, where a fact with the right value but the wrong source must be refused. No rule fired on missing data, so no fix was needed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ --- internal/loop/rules_test.go | 394 ++++++++++++++++++++++++++++++++++++ 1 file changed, 394 insertions(+) create mode 100644 internal/loop/rules_test.go diff --git a/internal/loop/rules_test.go b/internal/loop/rules_test.go new file mode 100644 index 0000000..0da19e2 --- /dev/null +++ b/internal/loop/rules_test.go @@ -0,0 +1,394 @@ +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) + } + } + } +}