package morning import ( "strings" "testing" "time" "unicode" "github.com/kami/maven/internal/store" ) // planAt is at() for the plan tests' day (2026-08-03, a Monday); the existing // at() in morning_test.go is pinned to a different date. func planAt(now time.Time, hh, mm int) time.Time { y, m, d := now.Date() return time.Date(y, m, d, hh, mm, 0, 0, now.Location()) } func planFixture(t *testing.T) (Plan, time.Time) { t.Helper() now := time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC) routines := []Routine{{ Name: "утро", WindowStart: "07:00", WindowEnd: "11:00", NudgeAt: "10:30", Items: []Item{ {Key: "water", FactKey: "drank_water", Label: "выпить воды"}, {Key: "pills", FactKey: "took_pills", Label: "витамины"}, }, }} facts := map[string]store.Fact{ "drank_water": {Ts: planAt(now, 8, 0)}, } events := []PlanEntry{ {At: planAt(now, 14, 0), Text: "Планёрка @ 14:00-14:30", Kind: PlanEvent, Uncertain: true}, {At: planAt(now, 10, 0), Text: "Standup @ 10:00-10:30", Kind: PlanEvent}, } reminders := []PlanEntry{ {At: planAt(now, 18, 30), Text: "позвонить маме", Kind: PlanReminder}, } return BuildPlan(routines, facts, events, reminders, now), now } func TestBuildPlanOrdersTheDay(t *testing.T) { p, now := planFixture(t) if !p.Date.Equal(planAt(now, 0, 0)) { t.Errorf("Date = %v, want midnight of now's day", p.Date) } want := []struct { hhmm string kind PlanKind }{ {"10:00", PlanEvent}, {"10:30", PlanChecklist}, {"14:00", PlanEvent}, {"18:30", PlanReminder}, } if len(p.Items) != len(want) { t.Fatalf("got %d items, want %d: %+v", len(p.Items), len(want), p.Items) } for i, w := range want { if got := p.Items[i].At.Format("15:04"); got != w.hhmm { t.Errorf("item %d at %s, want %s", i, got, w.hhmm) } if p.Items[i].Kind != w.kind { t.Errorf("item %d kind %q, want %q", i, p.Items[i].Kind, w.kind) } } } // The checklist line says what is LEFT. An item already evidenced today must // not be read back as outstanding. func TestBuildPlanChecklistListsOnlyMissing(t *testing.T) { p, _ := planFixture(t) var line string for _, it := range p.Items { if it.Kind == PlanChecklist { line = it.Text } } if line == "" { t.Fatal("no checklist line in the plan") } if !strings.Contains(line, "витамины") { t.Errorf("missing item not listed: %q", line) } if strings.Contains(line, "выпить воды") { t.Errorf("a completed item must not be read back as outstanding: %q", line) } if !strings.HasPrefix(line, "утро — осталось:") { t.Errorf("line = %q", line) } } func TestBuildPlanSkipsCompleteAndInactiveRoutines(t *testing.T) { now := time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC) routines := []Routine{ { Name: "утро", WindowStart: "07:00", WindowEnd: "11:00", Items: []Item{{Key: "water", FactKey: "drank_water", Label: "выпить воды"}}, }, { // Not in its window at 09:00. Name: "вечер", WindowStart: "20:00", WindowEnd: "23:00", Items: []Item{{Key: "walk", FactKey: "walked", Label: "прогулка"}}, }, } facts := map[string]store.Fact{"drank_water": {Ts: planAt(now, 8, 0)}} p := BuildPlan(routines, facts, nil, nil, now) if len(p.Items) != 0 { t.Fatalf("a complete routine and an out-of-window one must contribute nothing: %+v", p.Items) } if got, want := p.FormatRU(), "на 03.08.2026 ничего не запланировано."; got != want { t.Errorf("got %q\nwant %q", got, want) } } // A plan for today that includes tomorrow's meeting is worse than terse. func TestBuildPlanDropsOtherDays(t *testing.T) { now := time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC) events := []PlanEntry{ {At: planAt(now, 10, 0), Text: "today", Kind: PlanEvent}, {At: planAt(now, 10, 0).AddDate(0, 0, 1), Text: "tomorrow", Kind: PlanEvent}, {At: planAt(now, 10, 0).AddDate(0, 0, -1), Text: "yesterday", Kind: PlanEvent}, {At: planAt(now, 12, 0), Text: " ", Kind: PlanEvent}, } p := BuildPlan(nil, nil, events, nil, now) if len(p.Items) != 1 || p.Items[0].Text != "today" { t.Fatalf("got %+v", p.Items) } } func TestPlanFormatRU(t *testing.T) { p, _ := planFixture(t) got := p.FormatRU() want := "план на 03.08.2026: 10:00 — Standup @ 10:00-10:30; " + "10:30 — утро — осталось: витамины; " + "похоже, 14:00 — Планёрка @ 14:00-14:30; " + "18:30 — позвонить маме" if got != want { t.Errorf("got %q\nwant %q", got, want) } // Persona: she recites, she does not exhort, and she never speaks of // herself in the masculine or addresses him formally. for _, bad := range []string{"рад ", "понял", "вы ", "ваш", "милый", "дорогой", "давай же", "не забудь"} { if strings.Contains(strings.ToLower(got), bad) { t.Errorf("plan text contains %q: %q", bad, got) } } } func TestPlanAfter(t *testing.T) { p, now := planFixture(t) rest := p.After(planAt(now, 11, 0)) if len(rest.Items) != 2 { t.Fatalf("got %d items, want the 14:00 and 18:30 ones: %+v", len(rest.Items), rest.Items) } if !rest.Date.Equal(p.Date) { t.Error("After must keep the date, so an empty rest-of-day still knows which day") } empty := p.After(planAt(now, 23, 0)) if len(empty.Items) != 0 { t.Errorf("got %+v", empty.Items) } // An empty rest-of-day is not an empty day. Saying "на 03.08.2026 ничего // не запланировано" at 23:00 denies the day he just lived. if got, want := empty.FormatRU(), "на сегодня больше ничего не запланировано."; got != want { t.Errorf("empty rest-of-day reads %q, want %q", got, want) } } // The plan says what today still has not got done, and a closed window does not // make a skipped routine untrue. Evaluate reports Active only inside the // window, so keying the checklist line off it meant the one thing the plan can // tell him that the calendar cannot went silent at 11:00. func TestBuildPlanKeepsAClosedWindowOutstanding(t *testing.T) { now := time.Date(2026, 8, 3, 14, 0, 0, 0, time.UTC) routines := []Routine{{ Name: "утро", WindowStart: "07:00", WindowEnd: "11:00", NudgeAt: "10:30", Items: []Item{ {Key: "water", FactKey: "drank_water", Label: "выпить воды"}, {Key: "pills", FactKey: "took_pills", Label: "витамины"}, }, }} facts := map[string]store.Fact{"drank_water": {Ts: planAt(now, 8, 0)}} p := BuildPlan(routines, facts, nil, nil, now) if len(p.Items) != 1 { t.Fatalf("got %+v, want the unfinished morning routine", p.Items) } it := p.Items[0] if it.Kind != PlanChecklist { t.Errorf("kind = %q", it.Kind) } // Placed at the nudge time, so it sorts to the top of the day rather than // to the moment of asking. if got := it.At.Format("15:04"); got != "10:30" { t.Errorf("placed at %s, want 10:30", got) } if !strings.Contains(it.Text, "витамины") || strings.Contains(it.Text, "выпить воды") { t.Errorf("line = %q", it.Text) } } // A routine whose window has not opened yet is not outstanding. Nothing has // been skipped at 06:00. func TestBuildPlanIgnoresAnUnopenedWindow(t *testing.T) { now := time.Date(2026, 8, 3, 6, 0, 0, 0, time.UTC) routines := []Routine{{ Name: "утро", WindowStart: "07:00", WindowEnd: "11:00", Items: []Item{{Key: "water", FactKey: "drank_water", Label: "выпить воды"}}, }} if p := BuildPlan(routines, nil, nil, nil, now); len(p.Items) != 0 { t.Fatalf("got %+v", p.Items) } } // plan_uncertain nests one rendered line inside another sentence: «похоже, » in // front of what this loop already built. That reads as one sentence only while // what arrives starts lowercase, and it does here because every line starts with // the clock time. A capital after the hedge would be «похоже, Планёрка». func TestTheUncertainHedgeRunsIntoLowercase(t *testing.T) { now := time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC) p := Plan{Date: now, Items: []PlanEntry{ {At: planAt(now, 14, 0), Text: "Планёрка", Kind: PlanEvent, Uncertain: true}, }} got := p.FormatRU() const hedge = "похоже, " i := strings.Index(got, hedge) if i < 0 { t.Fatalf("%q does not hedge an uncertain item", got) } for _, r := range got[i+len(hedge):] { if unicode.IsUpper(r) { t.Fatalf("the hedge runs into a capital: %q", got) } break } }