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) } }