package main import ( "context" "path/filepath" "testing" "time" "github.com/kami/maven/internal/delivery" "github.com/kami/maven/internal/loop" "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/store" ) // fakeSink — captures every Send for assertion. implements delivery.Sink. type fakeSink struct { sends []delivery.Sendable } func (f *fakeSink) Send(_ context.Context, s delivery.Sendable) error { f.sends = append(f.sends, s) return nil } func newTestStore(t *testing.T) *store.Store { t.Helper() path := filepath.Join(t.TempDir(), "mavend_test.db") st, err := store.Open(context.Background(), path) if err != nil { t.Fatalf("store.Open: %v", err) } t.Cleanup(func() { _ = st.Close() }) return st } func newTestTickLoop(t *testing.T, st *store.Store, sink delivery.Sink) *tickLoop { t.Helper() rules := loop.DefaultRules() g := loop.NewGatherer(st, rules) d := delivery.NewDispatcher(delivery.Config{ Voice: sink, Ntfy: sink, Telegram: sink, Nudges: st, Reminders: st, }) return newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0) } // refNow — fixed tick time so presence decay + since durations are deterministic. func refNow() time.Time { return time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) } // markPresent seeds desk_active + page_heartbeat with fresh ts so presence // resolves to Present for the given tick time (cold-start is away; ENTER at // 0.55 — a fresh desk_active alone gives 0.90, well over). func markPresent(t *testing.T, st *store.Store, ctx context.Context, now time.Time) { t.Helper() for _, key := range []string{"desk_active", "page_heartbeat"} { if _, err := st.SetValue(ctx, store.KindSelf, key, "tap:desk", map[string]bool{key: true}, now); err != nil { t.Fatalf("seed %s: %v", key, err) } } } func TestTickColdStoreSendsNothing(t *testing.T) { // The "shuts up when uncertain" floor: no facts ⇒ every rule's // InertWhenNoData keys are missing ⇒ gate skips them. the loop's silence // is the default outcome of a tick on an empty store. st := newTestStore(t) ctx := context.Background() sink := &fakeSink{} tl := newTestTickLoop(t, st, sink) tl.tick(ctx, refNow()) if len(sink.sends) != 0 { t.Fatalf("cold-store tick sent %d; want 0 (shuts up when no data)", len(sink.sends)) } } func TestTickWaterFiresWhenDueAndPresent(t *testing.T) { // water fact 4h ago ⇒ since(water)=4h ≥ 3h ⇒ predicate true. presence // present ⇒ sev1 care gate holds (no quiet/cal/cooldown). routing for // sev1 present is [voice] — one send captured. st := newTestStore(t) ctx := context.Background() now := refNow() markPresent(t, st, ctx, now) if _, err := st.SetValue(ctx, store.KindSelf, "water", "tap:water", map[string]int{"ml": 0}, now.Add(-4*time.Hour)); err != nil { t.Fatalf("seed water: %v", err) } sink := &fakeSink{} tl := newTestTickLoop(t, st, sink) tl.tick(ctx, now) if len(sink.sends) != 1 { t.Fatalf("tick sends = %d, want 1 (water, voice only)", len(sink.sends)) } if got, want := sink.sends[0].RuleName, "water"; got != want { t.Errorf("send rule = %q, want %q", got, want) } if got, want := sink.sends[0].Channel, delivery.ChannelVoice; got != want { t.Errorf("send channel = %v, want voice (sev1 present)", got) } if sink.sends[0].Body == "" { t.Error("phraser Stub produced an empty body for the water nudge") } // one nudge row recorded with channel=voice — verify the dispatch path // wrote through to the store (the feedback loop's only input). RecentOutcomes // filters to resolved rows, so confirm the recorded nudge exists via LastNudge. n, err := st.LastNudge(ctx, "water") if err != nil { t.Fatalf("LastNudge: %v", err) } if n.Channel != string(delivery.ChannelVoice) { t.Errorf("recorded nudge channel = %q, want %q", n.Channel, delivery.ChannelVoice) } } func TestTickCooldownSuppressesSecondSend(t *testing.T) { // After a water nudge, the gate's cooldown (DefaultRules sets water // base cooldown = 30m) suppresses the same rule on the next tick. st := newTestStore(t) ctx := context.Background() now := refNow() markPresent(t, st, ctx, now) if _, err := st.SetValue(ctx, store.KindSelf, "water", "tap:water", map[string]int{"ml": 0}, now.Add(-4*time.Hour)); err != nil { t.Fatalf("seed water: %v", err) _ = err } sink := &fakeSink{} tl := newTestTickLoop(t, st, sink) tl.tick(ctx, now) // fires tl.tick(ctx, now.Add(time.Minute)) // still within 30m cooldown ⇒ suppressed if len(sink.sends) != 1 { t.Fatalf("sends after second tick = %d, want 1 (cooldown should suppress)", len(sink.sends)) } } func TestTickReminderFiresOnceAndMarkedFired(t *testing.T) { // A due reminder bypasses the gate. away (no presence probes ⇒ cold // start away) routes the reminder to [ntfy]. the dispatcher marks the // reminder fired only after at least one channel succeeded; verify by // re-ticking and confirming it isn't re-dispatched (DueReminders returns // only `status == pending AND fire_ts <= now`). st := newTestStore(t) ctx := context.Background() now := refNow() if _, err := st.CreateReminder(ctx, now.Add(-time.Minute), `{"text":"stand up"}`); err != nil { t.Fatalf("CreateReminder: %v", err) } sink := &fakeSink{} tl := newTestTickLoop(t, st, sink) tl.tick(ctx, now) if got, want := len(sink.sends), 1; got != want { t.Fatalf("reminder tick sends = %d, want 1 (away → ntfy)", got) } if sink.sends[0].Channel != delivery.ChannelNtfy { t.Errorf("reminder channel = %v, want ntfy (away)", sink.sends[0].Channel) } if sink.sends[0].Body != "stand up" { t.Errorf("reminder body = %q, want %q (router payload text)", sink.sends[0].Body, "stand up") } if sink.sends[0].Kind != delivery.KindReminder { t.Errorf("reminder kind = %v, want %v", sink.sends[0].Kind, delivery.KindReminder) } // re-tick: the reminder is no longer pending (marked fired) ⇒ not in // DueReminders ⇒ the reminder path is silent. sink.sends = nil tl.tick(ctx, now.Add(time.Minute)) if len(sink.sends) != 0 { t.Fatalf("second reminder tick sends = %d, want 0 (fired once)", len(sink.sends)) } } // TestTuneWritesFeedbackCooldownToStore — the daemon's impure tune() step end // to end: seed a rule with ≥ TuneMinOutcomes resolved `ignored` outcomes, // call tune(), assert it wrote a `cooldown:` (source=feedback) fact that // the gatherer then reads back as the active cooldown base. This is the closed // feedback loop: outcomes → tune → write → gather → gate sees the tuned base. func TestTuneWritesFeedbackCooldownToStore(t *testing.T) { st := newTestStore(t) ctx := context.Background() now := refNow() sink := &fakeSink{} tl := newTestTickLoop(t, st, sink) // seed enough resolved `ignored` nudge outcomes to trip the tuner (above // TuneMinOutcomes). all-ignored ⇒ factor 1.5 ⇒ base × 1.5; clamped to Max. r := loop.WaterRule() for i := 0; i < loop.TuneSampleN; i++ { id, err := st.RecordNudge(ctx, r.Name, "voice", "drink water", now.Add(-time.Hour)) if err != nil { t.Fatalf("RecordNudge %d: %v", i, err) } if err := st.ResolveNudge(ctx, id, store.NudgeIgnored, now); err != nil { t.Fatalf("ResolveNudge %d: %v", i, err) } } tl.tune(ctx) // the gatherer should now use the tuned base (clamped to WaterRule.Max = // 6h) as the cooldown base for `water`. verify via the cooldown-until // field by seeding a water nudge + asserting cooldown = sendTs + Max. // (we read the persisted tuned base directly rather than going via gather // so the assertion isolates tune()'s write from the gatherer path.) fb, err := st.LatestFactBySource(ctx, loop.FeedbackKey(r), loop.FeedbackSource) if err != nil { t.Fatalf("LatestFactBySource: %v (no feedback fact written?)", err) } tuned, ok := loop.ParseCooldownFact(fb) if !ok { t.Fatalf("ParseCooldownFact: not ok (value %q)", fb.Value) } if tuned != 45*time.Minute { t.Fatalf("all-ignored: base 30m × 1.5 = 45m (no — under WaterRule.Max 6h so unclamped): want 45m, got %v", tuned) } // idempotent: a second tune() with the same outcomes writes nothing new // (RecentOutcomes is steady between resolves; the persisted value equals // the computed one ⇒ skip). fb1 := fb tl.tune(ctx) fb2, err := st.LatestFactBySource(ctx, loop.FeedbackKey(r), loop.FeedbackSource) if err != nil { t.Fatalf("LatestFactBySource second call: %v", err) } if fb2.ID != fb1.ID { t.Fatalf("tune() re-wrote identical value: fact id %d → %d (should skip when unchanged)", fb1.ID, fb2.ID) } // flip the outcomes pattern to `acted`: next tune() writes a new value, // shorter than Max. RecentOutcomes is sorted DESC ts,id, so re-seeding // newer-acted nudges makes them dominate the older-ignored set. for i := 0; i < loop.TuneSampleN; i++ { id, err := st.RecordNudge(ctx, r.Name, "voice", "drink water", now.Add(time.Minute+time.Duration(i)*time.Second)) if err != nil { t.Fatalf("RecordNudge acted %d: %v", i, err) } if err := st.ResolveNudge(ctx, id, store.NudgeActed, now); err != nil { t.Fatalf("ResolveNudge acted %d: %v", i, err) } } tl.tune(ctx) fb3, err := st.LatestFactBySource(ctx, loop.FeedbackKey(r), loop.FeedbackSource) if err != nil { t.Fatalf("LatestFactBySource post-flip: %v", err) } tuned3, ok := loop.ParseCooldownFact(fb3) if !ok { t.Fatalf("ParseCooldownFact post-flip: not ok (value %q)", fb3.Value) } if tuned3 >= r.Cooldown.Max { t.Fatalf("acted-dominated outcomes should shrink cooldown below Max: got %v (Max %v)", tuned3, r.Cooldown.Max) } if tuned3 < r.Cooldown.Min { t.Fatalf("tuned cooldown below Min envelope: got %v (Min %v) — clamp broken", tuned3, r.Cooldown.Min) } // the gatherer actually reads it back: assert CooldownUntil for water is // derived from the tuned base (not the rule's static Base) when a real // nudge exists. seed a water nudge now and gather. nudgeTs := now.Add(2 * time.Minute) if _, err := st.RecordNudge(ctx, r.Name, "voice", "drink water", nudgeTs); err != nil { t.Fatalf("final RecordNudge: %v", err) } g := loop.NewGatherer(st, loop.DefaultRules()) snap, _, err := g.GatherState(ctx, now.Add(3*time.Minute)) if err != nil { t.Fatalf("GatherState: %v", err) } wantUntil := nudgeTs.Add(tuned3) if got := snap.CooldownUntil[r.Name]; got != wantUntil { t.Fatalf("gatherer used tuned base: CooldownUntil want %v, got %v", wantUntil, got) } }