Files
Maven/internal/morning/morning_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

185 lines
5.1 KiB
Go

package morning
import (
"testing"
"time"
"github.com/kami/maven/internal/store"
)
func mkRoutine() Routine {
return Routine{
Name: "weekday_morning",
Weekdays: []time.Weekday{time.Monday, time.Tuesday, time.Wednesday, time.Thursday, time.Friday},
WindowStart: "08:00",
WindowEnd: "11:00",
Items: []Item{
{Key: "medicine", FactKey: "medicine", Label: "лекарство"},
{Key: "water", FactKey: "water", Label: "вода"},
{Key: "pets", FactKey: "pets", Label: "кот"},
},
}
}
func at(h, m int) time.Time {
return time.Date(2026, 7, 20, h, m, 0, 0, time.UTC) // 2026-07-20 is a Monday
}
func fact(ts time.Time) store.Fact { return store.Fact{Ts: ts} }
func TestValidate(t *testing.T) {
r := mkRoutine()
if err := Validate([]Routine{r}); err != nil {
t.Fatalf("valid routine rejected: %v", err)
}
bad := r
bad.Items = nil
if err := Validate([]Routine{bad}); err == nil {
t.Fatal("expected error for no items")
}
bad = r
bad.WindowStart = "25:00"
if err := Validate([]Routine{bad}); err == nil {
t.Fatal("expected error for bad window_start")
}
bad = r
bad.WindowStart, bad.WindowEnd = "11:00", "08:00"
if err := Validate([]Routine{bad}); err == nil {
t.Fatal("expected error for inverted window")
}
bad = r
bad.Items = append(bad.Items, Item{Key: "medicine", FactKey: "x"})
if err := Validate([]Routine{bad}); err == nil {
t.Fatal("expected error for duplicate item key")
}
}
func TestEvaluateInactiveOutsideWindow(t *testing.T) {
r := mkRoutine()
st := Evaluate(r, nil, at(7, 59))
if st.Active {
t.Fatal("expected inactive before window opens")
}
st = Evaluate(r, nil, at(11, 0))
if st.Active {
t.Fatal("expected inactive at/after window closes")
}
}
func TestEvaluateInactiveOnWrongWeekday(t *testing.T) {
r := mkRoutine() // weekdays only
saturday := time.Date(2026, 7, 25, 9, 0, 0, 0, time.UTC)
if Evaluate(r, nil, saturday).Active {
t.Fatal("expected inactive on a weekend day not in Weekdays")
}
}
func TestEvaluateMissingAndCompleted(t *testing.T) {
r := mkRoutine()
facts := map[string]store.Fact{
"medicine": fact(at(8, 30)),
}
st := Evaluate(r, facts, at(9, 0))
if !st.Active {
t.Fatal("expected active within window")
}
if len(st.Completed) != 1 || st.Completed[0].Key != "medicine" {
t.Fatalf("expected medicine completed, got %+v", st.Completed)
}
if len(st.Missing) != 2 {
t.Fatalf("expected 2 missing, got %+v", st.Missing)
}
}
func TestEvaluateEvidenceBeforeWindowDoesNotCount(t *testing.T) {
r := mkRoutine()
facts := map[string]store.Fact{
"medicine": fact(at(7, 0)), // before window opened today
}
st := Evaluate(r, facts, at(9, 0))
for _, it := range st.Completed {
if it.Key == "medicine" {
t.Fatal("stale (pre-window) evidence should not count as completion")
}
}
}
func TestDueFiresOnlyAtNudgeTimeWithMissingItems(t *testing.T) {
r := mkRoutine() // NudgeAt empty -> defaults to WindowEnd (11:00)
facts := map[string]store.Fact{
"medicine": fact(at(8, 30)),
"water": fact(at(8, 40)),
// pets missing
}
last := map[string]time.Time{}
if out := Due([]Routine{r}, facts, last, at(9, 0)); len(out) != 0 {
t.Fatalf("expected no candidate before nudge time, got %+v", out)
}
out := Due([]Routine{r}, facts, last, at(11, 0))
if len(out) != 1 {
t.Fatalf("expected 1 candidate at nudge time, got %d", len(out))
}
if len(out[0].Missing) != 1 || out[0].Missing[0].Key != "pets" {
t.Fatalf("expected only pets missing, got %+v", out[0].Missing)
}
}
func TestDueDoesNotRepeatSameDay(t *testing.T) {
r := mkRoutine()
facts := map[string]store.Fact{} // nothing done
last := map[string]time.Time{}
if out := Due([]Routine{r}, facts, last, at(11, 0)); len(out) != 1 {
t.Fatalf("expected first nudge to fire, got %d", len(out))
}
if out := Due([]Routine{r}, facts, last, at(11, 30)); len(out) != 0 {
t.Fatalf("expected no repeat nudge same day, got %d", len(out))
}
}
func TestDueFiresAgainNextDay(t *testing.T) {
r := mkRoutine()
facts := map[string]store.Fact{}
last := map[string]time.Time{}
Due([]Routine{r}, facts, last, at(11, 0))
tomorrow := time.Date(2026, 7, 21, 11, 0, 0, 0, time.UTC) // Tuesday
if out := Due([]Routine{r}, facts, last, tomorrow); len(out) != 1 {
t.Fatalf("expected nudge to fire again on a new day, got %d", len(out))
}
}
func TestDueSkipsWhenAllItemsComplete(t *testing.T) {
r := mkRoutine()
facts := map[string]store.Fact{
"medicine": fact(at(8, 30)),
"water": fact(at(8, 40)),
"pets": fact(at(8, 50)),
}
last := map[string]time.Time{}
if out := Due([]Routine{r}, facts, last, at(11, 0)); len(out) != 0 {
t.Fatalf("expected no nudge when all items complete, got %+v", out)
}
}
func TestDueRespectsExplicitNudgeAt(t *testing.T) {
r := mkRoutine()
r.NudgeAt = "10:00"
facts := map[string]store.Fact{}
last := map[string]time.Time{}
if out := Due([]Routine{r}, facts, last, at(9, 30)); len(out) != 0 {
t.Fatalf("expected no candidate before explicit nudge_at, got %+v", out)
}
if out := Due([]Routine{r}, facts, last, at(10, 0)); len(out) != 1 {
t.Fatalf("expected candidate at explicit nudge_at, got %d", len(out))
}
}