Files
Maven/internal/loop/gate_test.go
T
kami 2f00593411 Wire the snooze read into the Gatherer and honour it for reminders (#364)
The Gatherer now fills State.SnoozeUntil from store.SnoozedUntil instead
of nil, so a snooze finally reaches the gate. RemindDecisions gains the
one restraint check that applies to a reminder — quiet hours, presence
and cooldown are still bypassed, so "wake me 7" is unchanged. Reviewer:
the two tests in internal/loop/gate_test.go are the contract.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
2026-07-31 02:32:24 +04:00

107 lines
3.3 KiB
Go

package loop
import (
"context"
"testing"
"time"
"github.com/kami/maven/internal/store"
)
// openStore — a real store for the two snooze paths below.
func openStore(t *testing.T) *store.Store {
t.Helper()
st, err := store.Open(context.Background(), t.TempDir()+"/m.db")
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
return st
}
// snoozeRule sends a nudge for rule and snoozes it at ts.
func snoozeRule(t *testing.T, st *store.Store, rule string, ts time.Time) {
t.Helper()
ctx := context.Background()
id, err := st.RecordNudge(ctx, rule, "voice", "drink water", ts)
if err != nil {
t.Fatalf("RecordNudge: %v", err)
}
if err := st.ResolveNudge(ctx, id, store.NudgeSnoozed, ts); err != nil {
t.Fatalf("ResolveNudge: %v", err)
}
}
// The bug in Vikunja #364: the Gatherer used to hard-code SnoozeUntil to nil,
// so a snooze the operator asked for never reached the gate and Maven nudged
// him again.
func TestGathererPopulatesSnoozeUntil(t *testing.T) {
st := openStore(t)
ctx := context.Background()
now := refTime()
snoozeAt := now.Add(-15 * time.Minute)
snoozeRule(t, st, "water", snoozeAt)
// an old snooze on another rule must NOT come back.
snoozeRule(t, st, "break", now.Add(-store.SnoozeDuration-time.Hour))
g := NewGatherer(st, DefaultRules())
s, _, err := g.GatherState(ctx, now)
if err != nil {
t.Fatalf("GatherState: %v", err)
}
want := snoozeAt.Add(store.SnoozeDuration)
if got, ok := s.SnoozeUntil["water"]; !ok || !got.Equal(want) {
t.Fatalf("water snooze-until = %v (present %v), want %v", got, ok, want)
}
if _, ok := s.SnoozeUntil["break"]; ok {
t.Fatalf("expired snooze leaked into the snapshot: %v", s.SnoozeUntil)
}
// and the gate must now actually suppress the snoozed rule.
if Gate(s, WaterRule()) {
t.Fatal("gate let a snoozed rule fire")
}
}
// DESIGN.md § User reminders: a reminder bypasses the gate, but "Snooze still
// applies."
func TestRemindersStillHonourSnooze(t *testing.T) {
now := refTime()
due := []store.Reminder{{ID: 1, Payload: `{"text":"wake me"}`}}
// snoozed as a class → held back.
s := State{Now: now, SnoozeUntil: map[string]time.Time{
ReminderSnoozeKey: now.Add(time.Hour),
}}
if got := RemindDecisions(s, due); len(got) != 0 {
t.Fatalf("snoozed reminder still delivered: %+v", got)
}
// snoozed by id → that one held back, others still delivered.
s = State{Now: now, SnoozeUntil: map[string]time.Time{
ReminderSnoozeKeyFor(1): now.Add(time.Hour),
}}
two := append([]store.Reminder{}, due...)
two = append(two, store.Reminder{ID: 2, Payload: `{"text":"call mum"}`})
got := RemindDecisions(s, two)
if len(got) != 1 || got[0].Reminder.ID != 2 {
t.Fatalf("per-id snooze wrong: %+v", got)
}
// expired snooze → delivered again. silence must never be permanent.
s = State{Now: now, SnoozeUntil: map[string]time.Time{
ReminderSnoozeKey: now.Add(-time.Minute),
}}
if got := RemindDecisions(s, due); len(got) != 1 {
t.Fatalf("expired snooze still holding the reminder: %+v", got)
}
// quiet hours, away and calendar-busy must STILL not hold a reminder back
// — "wake me 7" is the point.
s = State{Now: now, Presence: store.Away, QuietHours: true, CalendarBusy: true}
if got := RemindDecisions(s, due); len(got) != 1 {
t.Fatalf("reminder must bypass the rest of the gate: %+v", got)
}
}