package morning import ( "fmt" "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) } } } // One spoken sentence names one clock. An event and a reminder come off the // store as UTC and a checklist line is built in the asking clock's zone, so the // raw Format printed the two halves of one sentence in two zones (V-614). The // zones here are three hours off whatever this machine runs in, so the test // tells "read in his clock" apart from "read in the zone the instant arrived // in" under TZ=UTC as well. func TestPlanFormatRUReadsEveryHourInThePlansZone(t *testing.T) { _, off := time.Now().Zone() away := time.FixedZone("away", off+3*60*60) stored := time.Date(2026, 8, 3, 14, 0, 0, 0, time.UTC) p := Plan{ Date: time.Date(2026, 8, 3, 0, 0, 0, 0, away), Items: []PlanEntry{ {At: stored, Text: "Планёрка", Kind: PlanEvent}, {At: planAt(time.Date(2026, 8, 3, 0, 0, 0, 0, away), 10, 30), Text: "утро — осталось: витамины", Kind: PlanChecklist}, }, } got := p.FormatRU() if want := stored.In(away).Format("15:04"); !strings.Contains(got, want) { t.Errorf("the event is not read in the plan's zone (%s): %q", want, got) } if bad := stored.Format("15:04"); strings.Contains(got, bad) { t.Errorf("the event is read in the zone it was stored in (%s): %q", bad, got) } if !strings.Contains(got, "10:30") { t.Errorf("the checklist line moved zone: %q", 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) } } // nextFixture — a day with more entries than the cap, built in a zone three // hours off UTC so the test fails under TZ=UTC as well as under the machine's // own zone if the plan ever renders in the wrong one. func nextFixture(t *testing.T) (Plan, time.Time) { t.Helper() zone := time.FixedZone("MSK", 3*60*60) now := time.Date(2026, 8, 3, 4, 45, 0, 0, zone) var events []PlanEntry for _, hhmm := range [][2]int{{5, 45}, {10, 0}, {14, 0}, {18, 30}, {21, 12}} { events = append(events, PlanEntry{ At: planAt(now, hhmm[0], hhmm[1]), Text: fmt.Sprintf("событие %02d:%02d", hhmm[0], hhmm[1]), Kind: PlanEvent, }) } return BuildPlan(nil, nil, events, nil, now), now } // "что дальше?" asked at 04:45 on a day with everything still ahead. The trim // removes nothing there, so before V-618 she read the whole day out loud. func TestPlanNextCapsWhatIsSpoken(t *testing.T) { p, now := nextFixture(t) got := p.Next(now, NextSpoken).FormatRU() want := "дальше: 05:45 — событие 05:45; 10:00 — событие 10:00; " + "14:00 — событие 14:00. и ещё 2 дела до конца дня." if got != want { t.Errorf("got %q\nwant %q", got, want) } // No date: he asked what is next, not what day it is. if strings.Contains(got, "03.08.2026") { t.Errorf("rest-of-day answer stamps a date: %q", got) } } // Nothing hidden means nothing claimed hidden. func TestPlanNextWithinTheCapSaysNoMore(t *testing.T) { p, now := nextFixture(t) got := p.Next(planAt(now, 15, 0), NextSpoken).FormatRU() want := "дальше: 18:30 — событие 18:30; 21:12 — событие 21:12" if got != want { t.Errorf("got %q\nwant %q", got, want) } } // The whole-day question is not narrowed: same plan, no trim, no cap. func TestPlanWholeDayIsNotNarrowed(t *testing.T) { p, _ := nextFixture(t) got := p.FormatRU() if n := strings.Count(got, "событие"); n != 5 { t.Errorf("whole day read %d of 5 entries: %q", n, got) } if !strings.HasPrefix(got, "план на 03.08.2026: ") { t.Errorf("whole day lost its date: %q", got) } } // The empty case says the day is over rather than returning an empty sentence, // and it does not say the day was empty. func TestPlanNextEmptySaysSo(t *testing.T) { p, now := nextFixture(t) got := p.Next(planAt(now, 23, 30), NextSpoken).FormatRU() if got != "на сегодня больше ничего не запланировано." { t.Errorf("got %q", got) } } // An entry at exactly the asking minute is what is happening, not what is next. func TestPlanNextIsStrictlyAfterNow(t *testing.T) { p, now := nextFixture(t) rest := p.Next(planAt(now, 5, 45), NextSpoken) if len(rest.Items) != 3 || rest.Items[0].At.Hour() != 10 { t.Errorf("got %+v", rest.Items) } if rest.More != 1 { t.Errorf("More = %d, want 1", rest.More) } } // 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 } }