Files
Maven/cmd/mavend/tick_test.go
T
kami 20184874b2 Add the morning routine engine — a daily checklist, not four timers
Backlog item #3 (20-07-2026-BACKLOG.md). A morning routine is a checklist for
a daily window: several items, each evidenced by a fact key, completed in any
order, checked once near the end of the window. Modelling it as four
independent reminder timers would stack into exactly the kind of noise Maven is
supposed not to produce, so the engine nags at most once per day per routine
and only for what is actually still missing.

internal/morning follows the established pure-engine pattern (loop, routine,
pattern): no store, no clock of its own. Evaluate answers "what's still
missing" at any point; Due decides whether to nag. The impurity — reading
facts under the store lock, holding the last-nudge map across ticks — stays in
the tick driver, which calls Due each tick exactly as it does for loop.Rule
and routine.Routine.

Completion evidence is a fact key's latest non-voided value timestamped inside
today's window, so manual ("выпил воды", voice-tapped) and inferred (another
daemon writing the same key) are indistinguishable and both count. Weekdays
scopes which days a routine applies to, so weekday/weekend variants are two
routine rows rather than a special case in the engine.

Exposed read-only: a MorningStatus RPC over ipc, and a /morning page in mavweb
built on the same server-rendered shape as /trace — no live-update loop, since
checklist state moves on the scale of minutes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X5JApcrCRVGmqrxnhynSik
2026-07-30 23:49:10 +04:00

561 lines
19 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/config"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/routine"
"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, digestCfg *config.DigestConfig) *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, digestCfg, nil, nil)
}
func TestTickFiresRoutineWhenScheduleCrosses(t *testing.T) {
// A routine scheduled for 12:00 daily. On the first tick it seeds (cold-start
// guard — no fire); on a tick past the next 12:00 crossing it fires through
// the dispatcher with its literal body and severity.
st := newTestStore(t)
ctx := context.Background()
now := refNow() // 2026-06-30 12:00 UTC
markPresent(t, st, ctx, now)
rules := loop.DefaultRules()
g := loop.NewGatherer(st, rules)
sink := &fakeSink{}
d := delivery.NewDispatcher(delivery.Config{Voice: sink, Ntfy: sink, Telegram: sink, Nudges: st, Reminders: st})
rs := []routine.Routine{{Name: "morning", Cron: "0 12 * * *", Body: "полдень, время воды", Severity: 1}}
tl := newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, nil, rs, nil)
// first tick: seeds, does not fire the routine.
tl.tick(ctx, now)
for _, s := range sink.sends {
if s.RuleName == "routine:morning" {
t.Fatal("routine fired on the seeding tick (cold-start guard failed)")
}
}
// next day, just past 12:00 — the schedule crossed.
next := now.Add(24 * time.Hour).Add(time.Minute)
markPresent(t, st, ctx, next)
sink.sends = nil
tl.tick(ctx, next)
var got *delivery.Sendable
for i := range sink.sends {
if sink.sends[i].RuleName == "routine:morning" {
got = &sink.sends[i]
}
}
if got == nil {
t.Fatalf("routine did not fire after its schedule crossed; sends=%+v", sink.sends)
}
if got.Body != "полдень, время воды" {
t.Errorf("routine body = %q, want the literal config body", got.Body)
}
if got.Channel != delivery.ChannelVoice {
t.Errorf("routine channel = %v, want voice (sev1 present)", got.Channel)
}
}
// 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, nil)
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, nil)
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, nil)
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, nil)
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, nil)
// 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)
}
}
// ----------------------------- digest / batching ----------------------------
func TestDigestQueuesEligibleNudge(t *testing.T) {
// sev1 (water) with digest enabled → queued, not dispatched.
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{}
dc := &config.DigestConfig{
Enabled: true,
Window: config.Duration(30 * time.Minute),
MaxItems: 5,
SeverityCeiling: 2,
}
tl := newTestTickLoop(t, st, sink, dc)
tl.tick(ctx, now)
if len(sink.sends) != 0 {
t.Fatalf("digest: eligible nudge should not dispatch immediately, got %d sends", len(sink.sends))
}
if len(tl.digestQ) != 1 {
t.Fatalf("digestQ length = %d, want 1", len(tl.digestQ))
}
if tl.digestQ[0].Rule != "water" {
t.Errorf("queued rule = %q, want %q", tl.digestQ[0].Rule, "water")
}
}
func TestDigestFlushesAfterWindow(t *testing.T) {
// Queue a sev1 nudge, advance past the window, tick again → flush.
// Re-mark presence on the second tick so the flush dispatch has a
// non-away presence route (care nudges drop on away).
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{}
dc := &config.DigestConfig{
Enabled: true,
Window: config.Duration(30 * time.Minute),
MaxItems: 5,
SeverityCeiling: 2,
}
tl := newTestTickLoop(t, st, sink, dc)
// Tick 1: water queues, nothing dispatched.
tl.tick(ctx, now)
if len(sink.sends) != 0 {
t.Fatalf("tick 1: want 0 sends (queued), got %d", len(sink.sends))
}
if len(tl.digestQ) != 1 {
t.Fatalf("tick 1: digestQ = %d, want 1", len(tl.digestQ))
}
// Tick 2: window elapsed → flush. Re-mark presence to keep routing
// present (care nudge away → drop).
sink.sends = nil
later := now.Add(31 * time.Minute)
markPresent(t, st, ctx, later)
tl.tick(ctx, later)
if len(sink.sends) != 1 {
t.Fatalf("tick 2 (flush): sends = %d, want 1", len(sink.sends))
}
if sink.sends[0].RuleName != "digest" {
t.Errorf("flush rule = %q, want %q", sink.sends[0].RuleName, "digest")
}
if sink.sends[0].Body == "" {
t.Error("digest body must not be empty")
}
if len(tl.digestQ) != 0 {
t.Errorf("digestQ should be empty after flush, got %d", len(tl.digestQ))
}
}
func TestDigestFlushesAtMaxItems(t *testing.T) {
// Queue water via tick, then manually push a second item, then tick again
// so the before-candidate maybeFlush sees len >= MaxItems and flushes.
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{}
dc := &config.DigestConfig{
Enabled: true,
Window: config.Duration(30 * time.Minute),
MaxItems: 2,
SeverityCeiling: 2,
}
tl := newTestTickLoop(t, st, sink, dc)
// Tick 1: water queues (1 item, below MaxItems).
tl.tick(ctx, now)
if len(tl.digestQ) != 1 {
t.Fatalf("tick 1: digestQ = %d, want 1", len(tl.digestQ))
}
if len(sink.sends) != 0 {
t.Fatalf("tick 1: sends = %d, want 0", len(sink.sends))
}
// Manually push a second item to hit MaxItems.
tl.digestQ = append(tl.digestQ, QueuedNudge{
Rule: "meal", Severity: 1, Body: "eat something", Key: "meal", QueuedAt: now.Add(time.Second),
})
// Tick 2: after-candidate maybeFlush sees len=2 >= MaxItems=2 → flush.
sink.sends = nil
tl.tick(ctx, now.Add(2*time.Second))
if len(sink.sends) != 1 {
t.Fatalf("after flush tick: sends = %d, want 1", len(sink.sends))
}
if sink.sends[0].RuleName != "digest" {
t.Errorf("flush rule = %q, want %q", sink.sends[0].RuleName, "digest")
}
if len(tl.digestQ) != 0 {
t.Errorf("digestQ should be empty after flush, got %d", len(tl.digestQ))
}
}
func TestDigestSev4BypassesQueue(t *testing.T) {
// Sev4 (service_down) with digest enabled → dispatches immediately.
st := newTestStore(t)
ctx := context.Background()
now := refNow()
markPresent(t, st, ctx, now)
if _, err := st.SetValue(ctx, store.KindSelf, "service_down", "poll:uptimekuma", "down", now); err != nil {
t.Fatalf("seed service_down: %v", err)
}
sink := &fakeSink{}
dc := &config.DigestConfig{
Enabled: true,
Window: config.Duration(30 * time.Minute),
MaxItems: 5,
SeverityCeiling: 2,
}
tl := newTestTickLoop(t, st, sink, dc)
tl.tick(ctx, now)
if len(sink.sends) == 0 {
t.Fatal("sev4 nudge must dispatch immediately, bypassing digest queue")
}
if len(tl.digestQ) != 0 {
t.Errorf("digestQ should be empty (sev4 bypassed), got %d", len(tl.digestQ))
}
}
func TestDigestDisabledSendsImmediately(t *testing.T) {
// Digest disabled (nil config) → sev1 dispatches immediately.
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, nil)
tl.tick(ctx, now)
if len(sink.sends) != 1 {
t.Fatalf("digest disabled: sends = %d, want 1", len(sink.sends))
}
if sink.sends[0].RuleName != "water" {
t.Errorf("send rule = %q, want %q", sink.sends[0].RuleName, "water")
}
}
func TestDigestDeduplicatesByRule(t *testing.T) {
// Same rule (water) fires in two ticks; only one copy in the queue.
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{}
dc := &config.DigestConfig{
Enabled: true,
Window: config.Duration(30 * time.Minute),
MaxItems: 5,
SeverityCeiling: 2,
}
tl := newTestTickLoop(t, st, sink, dc)
// Tick 1: water queues.
tl.tick(ctx, now)
if len(tl.digestQ) != 1 {
t.Fatalf("tick 1: digestQ = %d, want 1", len(tl.digestQ))
}
// Tick 2: water still in cooldown → gate suppresses, but if cooldown
// weren't active the dedup check would prevent a second copy. We verify
// by making a second direct call to queueNudge with the same rule name.
// We also tick again with a different time to see if water fires again
// through the normal path — but cooldown should suppress it. Instead,
// directly verify dedup by calling queueNudge with a synthetic candidate.
tl.queueNudge(ctx, &loop.Candidate{
Rule: loop.Rule{Name: "water", Severity: loop.Sev1},
Severity: loop.Sev1,
}, loop.State{}, now.Add(time.Minute))
if len(tl.digestQ) != 1 {
t.Fatalf("after duplicate queue attempt: digestQ = %d, want 1 (dedup)", len(tl.digestQ))
}
}