382 lines
12 KiB
Go
382 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)
|
|
}
|
|
} |