Files
Maven/internal/loop/loop_test.go
kami d52f60c54e maven: fix test mocks for CalendarEvents interface (verification)
- Add CalendarEvents method to recordingAPI in auth_test.go
- Add CalendarEvents method to fakeCore in handlers_test.go

Co-Authored-By: opencode <opencode@anthropic.com>
2026-07-06 04:20:16 +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": 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)
}
}