46acf3cba0
FormatRU printed a plan item's At raw. An event and a reminder come off the store as UTC — a calendar fact's Ts, a reminder's FireTs — while a checklist line is built in the asking clock's zone, so one spoken sentence named two zones. This is the voice path, so it is what he actually heard; the same defect on /morning and /events was V-612. Every hour is now read in the plan's own zone, Date's, which BuildPlan sets from the asking clock. The rest-of-day path in queryDayPlan rebuilds a plan off the wire, where nothing had put the instants in that frame, so it does now. formatTime is the same bug in the same daemon: "когда я это сделал?" names a fact's Ts, and the branch that prints a wall clock printed the store's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
273 lines
9.6 KiB
Go
273 lines
9.6 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|