Files
Maven/cmd/mavend/tick_test.go
T
kami 1eca17f37b reminders: add recurring reminder support
Add cron expression support for recurring reminders using robfig/cron/v3.

Changes:
- Migration #2: ALTER TABLE reminders ADD COLUMN cron TEXT + next_fire_ts INTEGER
- Reminder struct: add Cron and NextFireTs fields
- scanReminder helper extracts full row including nullable cron
- CreateReminder: accept optional cron param, store next_fire_ts = fire_ts
- DueReminders: query on next_fire_ts instead of fire_ts
- RescheduleReminder: new method — parse cron, compute next fire, update
  next_fire_ts or mark fired if no more valid times
- Dispatcher: call RescheduleReminder for cron reminders, MarkReminder for
  one-shots (preserving existing behavior for ID=0 digest skip)
- ReminderCompleter interface: add RescheduleReminder method
- storeAPI adapter: forward RescheduleReminder
- All callers updated: CreateReminder signature includes cron param
- Tests: TestRecurringReminder (store), TestDispatchRecurringReminderReschedules
- Existing tests updated for new signature
2026-07-05 11:46:12 +04:00

288 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:<rule>` (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)
}
}