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": factAt("service_down", "poll:uptimekuma", `"down"`, now.Add(-1*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": factAt("service_down", "ambient", `"down"`, now.Add(-1*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": factAt("service_down", "poll:uptimekuma", `"down"`, now.Add(-1*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) } }