ed9bdd5e09
The plan answers "какие планы на сегодня?" by putting one day in order: calendar events (with #126's ambient provenance carried through and hedged), pending reminders, and one line per morning routine that still has items outstanding. "что дальше?" trims what has already passed. It lives in internal/morning, not in a parallel system, because it is the same question the checklist asks at a different scale — the routine knows what is missing from a window, the plan knows what the whole day holds, and both read the same facts and the same idea of "today". BuildPlan is pure; tickLoop.dayPlan is the impure half that reads the store. It is not a nag. Nothing here fires, schedules or announces: the plan is built only when asked, over IPC (day_plan) or on the existing /morning page. Unprompted delivery stays with the morning nudge and the dispatcher's policy. The query source sits before "calendar" in querySources because both match "…на сегодня" and the plan's matcher is the more specific one; IsDayPlanQuery matches whole words so "планёрка" (a meeting) is not read as a request for the plan, and refuses any utterance naming another day, since the plan is built for the clock's own day only. Verified: make build and make test both exit 0; new tests cover plan ordering, the checklist-only-what-is-left rule, other-day rejection, the RU rendering against the persona checks, rest-of-day trimming, the source ordering, and the matcher's refusals.
170 lines
5.4 KiB
Go
170 lines
5.4 KiB
Go
package morning
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"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)
|
|
}
|
|
if !strings.Contains(empty.FormatRU(), "ничего не запланировано") {
|
|
t.Errorf("empty plan reads %q", empty.FormatRU())
|
|
}
|
|
}
|