Files
Maven/internal/loop/loop_test.go
T
claude 0560684b35 kuma: a monitor must stay down before it wakes him (V-536)
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.
2026-08-05 02:27:22 +04:00

383 lines
12 KiB
Go

package loop
import (
"context"
"testing"
"time"
"github.com/kami/maven/internal/store"
)
func refTime() time.Time { return time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) }
func factAt(key, source, value string, ts time.Time) store.Fact {
return store.Fact{Ts: ts, Key: key, Source: source, Value: value, Confidence: 1.0}
}
// ----------------------------- Tick + Gate -----------------------------------
func TestTickNothingFiresColdBoot(t *testing.T) {
// cold boot — no facts at all. every rule's InertWhenNoData kicks in; gate
// returns false; Tick returns nil. "shuts up when uncertain" is the default.
s := State{Now: refTime(), Presence: store.Away}
if got := Tick(s, DefaultRules()); got != nil {
t.Fatalf("cold boot: want nil, got %+v", got)
}
}
func TestTickWaterFiresWhenThirstyAndPresent(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)),
},
}
got := Tick(s, DefaultRules())
if got == nil || got.Rule.Name != "water" {
t.Fatalf("want water candidate, got %+v", got)
}
if got.Severity != Sev1 {
t.Fatalf("water sev mismatch: %d", got.Severity)
}
}
func TestTickWaterSuppressedOnAwayCareDrops(t *testing.T) {
// sev1 (care) → drops on away. spec: "a missed water nudge is noise."
now := refTime()
s := State{
Now: now,
Presence: store.Away,
Facts: map[string]store.Fact{
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
},
}
if got := Tick(s, DefaultRules()); got != nil {
t.Fatalf("care on away: want nil, got %+v", got)
}
}
func TestTickWaterSuppressedInQuietHours(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)),
},
}
if got := Tick(s, DefaultRules()); got != nil {
t.Fatalf("care in quiet hours: want nil, got %+v", got)
}
}
func TestTickOpsHardSurvivesAwayAndQuiet(t *testing.T) {
// sev4 ops hard — must survive both away AND quiet hours. the gate only
// suppresses sev≤2 for either flag; sev4 is the disk-fire alarm.
now := refTime()
s := State{
Now: now,
Presence: store.Away,
QuietHours: true,
Facts: map[string]store.Fact{
"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-5*time.Minute)),
},
}
got := Tick(s, DefaultRules())
if got == nil || got.Rule.Name != "service_down" || got.Severity != Sev4 {
t.Fatalf("ops hard survives: want service_down/sev4, got %+v", got)
}
}
func TestTickServiceSourceTrustRefusesForgedTrigger(t *testing.T) {
// a non-poll:uptimekuma source recording "down" must NOT fire the ops
// rule — compromised poller / ambient can't forge a trigger.
now := refTime()
s := State{
Now: now,
Presence: store.Present,
Facts: map[string]store.Fact{
"service_down:db": factAt("service_down:db", "ambient", `"down"`, now.Add(-5*time.Minute)),
},
}
if got := Tick(s, DefaultRules()); got != nil {
t.Fatalf("forged source: want nil, got %+v", got)
}
}
func TestTickOneNudgePerTickMaxSeverityWins(t *testing.T) {
// both water (sev1) and service_down (sev4) want to fire and clear the gate.
// max severity wins — disk-fire preempts water. never dogpile.
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)),
"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-5*time.Minute)),
},
}
got := Tick(s, DefaultRules())
if got == nil || got.Rule.Name != "service_down" {
t.Fatalf("max sev wins: want service_down, got %+v", got)
}
}
func TestTickCooldownSuppresses(t *testing.T) {
now := refTime()
// 4h since water (would fire) — but cooldown until now+10min. Suppressed.
s := State{
Now: now,
Presence: store.Present,
Facts: map[string]store.Fact{
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
},
CooldownUntil: map[string]time.Time{
"water": now.Add(10 * time.Minute),
},
}
if got := Tick(s, DefaultRules()); got != nil {
t.Fatalf("cooldown: want nil, got %+v", got)
}
}
func TestTickSnoozeSuppressesBeforeCooldown(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)),
},
SnoozeUntil: map[string]time.Time{
"water": now.Add(time.Hour),
},
}
if got := Tick(s, DefaultRules()); got != nil {
t.Fatalf("snooze: want nil, got %+v", got)
}
}
func TestTickCalendarBusySuppressesCare(t *testing.T) {
// "don't nag mid-meeting" lives in the gate as an env predicate.
now := refTime()
s := State{
Now: now,
Presence: store.Present,
CalendarBusy: true,
Facts: map[string]store.Fact{
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
},
}
if got := Tick(s, DefaultRules()); got != nil {
t.Fatalf("busy care: want nil, got %+v", got)
}
}
func TestTickPredicateFalseSkipsGateEntirely(t *testing.T) {
// water since lastFact < 3h ⇒ predicate false ⇒ not a candidate at all,
// regardless of any gate state. cheap path; gate never consulted.
now := refTime()
s := State{
Now: now,
Presence: store.Present,
Facts: map[string]store.Fact{
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-30*time.Minute)),
},
}
if got := Tick(s, DefaultRules()); got != nil {
t.Fatalf("predicate false: want nil, got %+v", got)
}
}
func TestGateNoDataInertShutsUp(t *testing.T) {
// predicate true (carelessly), but rule declared InertWhenNoData and key
// is missing in the snapshot — gate must still return false.
s := State{Now: refTime(), Presence: store.Present}
iwantfire := Rule{
Name: "x",
Severity: Sev1,
Predicate: func(State) bool { return true },
InertWhenNoData: []string{"missing_key"},
}
if Gate(s, iwantfire) {
t.Fatalf("no-data rule should be inert, got fire")
}
}
func TestRemindDecisionsDoesNotGate(t *testing.T) {
// reminders bypass restraint — they pass through untouched even in quiet,
// away, etc. this is the documented two-delivery-path split.
now := refTime()
s := State{Now: now, Presence: store.Away, QuietHours: true, CalendarBusy: true}
due := []store.Reminder{{ID: 1, Payload: `{"text":"wake me"}`}}
got := RemindDecisions(s, due)
if len(got) != 1 || got[0].Reminder.ID != 1 {
t.Fatalf("reminders must bypass gate, got %+v", got)
}
}
// ----------------------------- Gatherer + real store -------------------------
func TestGathererEndToEndWaterFires(t *testing.T) {
path := t.TempDir() + "/m.db"
st, err := store.Open(context.Background(), path)
if err != nil {
t.Fatal(err)
}
defer st.Close()
ctx := context.Background()
// write a water fact 4h ago — drop into the past by direct INSERT.
now := refTime()
if _, err := st.SetValue(ctx, store.KindSelf, "water", "tap:water", "250ml", now.Add(-4*time.Hour)); err != nil {
t.Fatal(err)
}
// (no `break` fact seeded — BreakRule's InertWhenNoData keeps it inert,
// so water is the only care candidate.)
// silence env flags so they don't accidentally suppress.
if _, err := st.SetValue(ctx, store.KindConfig, "quiet_hours", "promote", "false", now); err != nil {
t.Fatal(err)
}
if _, err := st.SetValue(ctx, store.KindConfig, "calendar_busy", "promote", "false", now); err != nil {
t.Fatal(err)
}
g := NewGatherer(st, DefaultRules())
snap, due, err := g.GatherState(ctx, now)
if err != nil {
t.Fatalf("GatherState: %v", err)
}
if len(due) != 0 {
t.Fatalf("no reminders due, got %d", len(due))
}
// cold-boot presence (no signal facts) ⇒ away ⇒ care should drop.
if snap.Presence != store.Away {
t.Fatalf("cold presence: want Away, got %s (score %f)", snap.Presence, snap.PresenceScore)
}
// water predicate true but presence away ⇒ gate suppresses care ⇒ nil.
if got := Tick(snap, DefaultRules()); got != nil {
t.Fatalf("away cold presence should suppress care: want nil, got %+v", got)
}
// now create a fresh desk_active signal so presence flips to present.
if _, err := st.SetValue(ctx, store.KindSelf, "desk_active", "infer:hyprland", "1", now.Add(-1*time.Second)); err != nil {
t.Fatal(err)
}
snap2, _, err := g.GatherState(ctx, now)
if err != nil {
t.Fatal(err)
}
if snap2.Presence != store.Present {
t.Fatalf("with desk signal: want Present, got %s (score %f)", snap2.Presence, snap2.PresenceScore)
}
got := Tick(snap2, DefaultRules())
if got == nil || got.Rule.Name != "water" {
t.Fatalf("present + thirsty should fire water, got %+v", got)
}
}
// TestGathererUsesFeedbackTunedCooldown — write a `cooldown:water` feedback
// fact, then verify the gatherer's CooldownUntil for the rule uses the tuned
// base (last nudge ts + tuned base), not the rule's static Base. This is the
// end-to-end shape of the feedback loop: tuner writes fact → gatherer reads
// it next tick → gate consults the adjusted CooldownUntil.
func TestGathererUsesFeedbackTunedCooldown(t *testing.T) {
path := t.TempDir() + "/m.db"
st, err := store.Open(context.Background(), path)
if err != nil {
t.Fatal(err)
}
defer st.Close()
ctx := context.Background()
now := refTime()
// seed water + a presence signal so the rule has a snapshot worth gating
// against (we're not asserting Tick here — just the CooldownUntil field).
if _, err := st.SetValue(ctx, store.KindSelf, "water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)); err != nil {
t.Fatal(err)
}
if _, err := st.SetValue(ctx, store.KindSelf, "desk_active", "infer:hyprland", "1", now.Add(-1*time.Second)); err != nil {
t.Fatal(err)
}
if _, err := st.SetValue(ctx, store.KindConfig, "quiet_hours", "promote", "false", now); err != nil {
t.Fatal(err)
}
if _, err := st.SetValue(ctx, store.KindConfig, "calendar_busy", "promote", "false", now); err != nil {
t.Fatal(err)
}
// send a water nudge 10m ago so cooldown-until is lastSend+base.
sendTs := now.Add(-10 * time.Minute)
nudgeID, err := st.RecordNudge(ctx, "water", "voice", "drink water", sendTs)
if err != nil {
t.Fatal(err)
}
// leave it pending so LastNudge still surfaces it as the most recent send.
g := NewGatherer(st, DefaultRules())
// baseline: rule's static Base (30m) → CooldownUntil == sendTs + 30m.
snap1, _, err := g.GatherState(ctx, now)
if err != nil {
t.Fatalf("gather baseline: %v", err)
}
wantStatic := sendTs.Add(30 * time.Minute)
if got := snap1.CooldownUntil["water"]; got != wantStatic {
t.Fatalf("baseline cooldown-until: want %v, got %v", wantStatic, got)
}
// the feedback tuner writes a tuned base as facts(kind=config,
// source=feedback, key=cooldown:water). simulate the daemon: write a
// 5h tuned cooldown fact.
tuned := 5 * time.Hour
water := WaterRule()
if _, err := st.SetValue(ctx, store.KindConfig, FeedbackKey(water), FeedbackSource, tuned, now); err != nil {
t.Fatal(err)
}
snap2, _, err := g.GatherState(ctx, now)
if err != nil {
t.Fatalf("gather tuned: %v", err)
}
wantTuned := sendTs.Add(tuned)
if got := snap2.CooldownUntil["water"]; got != wantTuned {
t.Fatalf("tuned cooldown-until: want %v, got %v", wantTuned, got)
}
// a poisoned row from a non-feedback source must be ignored (trust by
// provenance — gatherer reads LatestFactBySource(source=feedback), so a
// tap:water row at the same key doesn't reach the cooldown).
if _, err := st.SetValue(ctx, store.KindConfig, FeedbackKey(water), "tap:water", 1*time.Minute, now); err != nil {
t.Fatal(err)
}
snap3, _, err := g.GatherState(ctx, now)
if err != nil {
t.Fatalf("gather poisoned: %v", err)
}
// LatestFactBySource returns the latest NON-voided feedback row, which is
// still the 5h one we wrote — the tap:water row is invisible to this read.
if got := snap3.CooldownUntil["water"]; got != wantTuned {
t.Fatalf("poisoned row leaked: want %v, got %v", wantTuned, got)
}
// sanity: the water nudge row is still findable (the gatherer's LastNudge
// + gate's cooldown consult it).
n, err := st.LastNudge(ctx, "water")
if err != nil || n.ID != nudgeID {
t.Fatalf("LastNudge: want id %d, got %+v err=%v", nudgeID, n, err)
}
// sanity: the feedback fact lookup round-trips via ParseCooldownFact too.
fb, err := st.LatestFactBySource(ctx, FeedbackKey(water), FeedbackSource)
if err != nil {
t.Fatalf("LatestFactBySource feedback: %v", err)
}
if d, ok := ParseCooldownFact(fb); !ok || d != tuned {
t.Fatalf("ParseCooldownFact round-trip: want %v ok, got %v ok=%v", tuned, d, ok)
}
}