Files
Maven/internal/morning/morning_test.go
T
claude 5447f08c06 morning, routine: reject the two configs that silently do nothing (V-581)
Both Due functions key their last-fired map by routine name, so two routines sharing a name took turns suppressing each other and one of them never fired. Validate now rejects a duplicate name in either package.

parseHHMM checked the digits arithmetically, which let a stray character cancel out: window_start of 2 :00 loaded as 04:00 and passed the validation that exists to catch that typo. Each of the four positions is now checked as a digit, which makes the negative bounds unreachable and they are gone.

Folded the three copies of the unevidenced-item loop in Evaluate, Outstanding and Due into one helper.
2026-08-06 03:12:36 +04:00

235 lines
7.0 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")
}
// Due keys its once-a-day map by name, so two routines sharing one would
// suppress each other's nudge instead of both firing.
if err := Validate([]Routine{r, r}); err == nil {
t.Fatal("expected error for duplicate routine name")
}
// Arithmetic alone let a stray character cancel out: "2 :00" read as 04:00.
bad = r
bad.WindowStart = "2 :00"
if err := Validate([]Routine{bad}); err == nil {
t.Fatal("expected error for a non-digit in window_start")
}
}
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)
}
}