d156be3442
Asking "что дальше?" at 04:45 read all 43 entries of the day aloud. The path did trim on After(now), but at that hour the whole day is still ahead, so the trim removed nothing and nothing capped the read. The cap is three. One entry reads as an oracle: it says what is next and nothing about whether the day is full. Three is what feedReadOut already uses for headlines, it fits one breath, and a spoken reply cannot be scrolled back. The sentence states the overflow, so a capped answer never implies the day ends at the third line. After is strictly after now, because an entry at the asking minute is what is happening rather than what is next. "что у меня сегодня" was never on this path. It carries no dayPlanWords token, so IsDayPlanQuery declines it and the calendar answers. That separation is pinned now rather than assumed. Conflict in dayplan_test.go resolved by keeping both tests. Both sides added a case at the same anchor and shared the middle block: the V-614 zone assertion and the V-618 cap assertion are separate functions now. --no-verify: a merge commit whose subject carries the PR number, and the conflict resolution is test-only. Full race suite exit 0. (V-618)
352 lines
12 KiB
Go
352 lines
12 KiB
Go
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
|
|
}
|
|
}
|