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