288 lines
10 KiB
Go
288 lines
10 KiB
Go
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)
|
||
}
|
||
} |