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)) } } // TestOptionalItemsDoNotEarnANudge — behaviour 1 of #280, which could not hold // while every item was implicitly required (Vikunja #473). func TestOptionalItemsDoNotEarnANudge(t *testing.T) { r := Routine{ Name: "утро", WindowStart: "07:00", WindowEnd: "10:00", Items: []Item{ {Key: "meds", FactKey: "meds", Label: "таблетки"}, {Key: "stretch", FactKey: "stretch", Label: "растяжка", Optional: true}, }, } now := time.Date(2026, 8, 4, 10, 0, 0, 0, time.UTC) took := map[string]store.Fact{"meds": {Key: "meds", Ts: now.Add(-2 * time.Hour)}} // Only the stretch was skipped: nothing to say. if due := Due([]Routine{r}, took, map[string]time.Time{}, now); len(due) != 0 { t.Fatalf("an optional item alone must not nudge, got %+v", due) } // The medication was skipped: she says so, and mentions the stretch too. due := Due([]Routine{r}, map[string]store.Fact{}, map[string]time.Time{}, now) if len(due) != 1 { t.Fatalf("a missing required item must nudge, got %+v", due) } if got := Required(due[0].Missing); len(got) != 1 || got[0].Key != "meds" { t.Fatalf("Required = %+v, want the meds item alone", got) } if got := OptionalOnly(due[0].Missing); len(got) != 1 || got[0].Key != "stretch" { t.Fatalf("OptionalOnly = %+v, want the stretch item alone", got) } // The window still reports it as missing — optional is not invisible. st := Evaluate(r, map[string]store.Fact{}, now.Add(-time.Hour)) if len(st.Missing) != 2 { t.Fatalf("Evaluate must still list both, got %+v", st.Missing) } }