a286865fe5
PR 113's review, four bugs and the register cuts.
«дн.» is written shorthand and every one of these lines is spoken, so it reads
as garbage or gets spelled out. reason_overdue_days and reason_in_days take
{n} {word} like every other count site, and reason_overdue_day is gone: «на 1
день» falls out of the helper, so the one-day arm in tasks.Rank went with it.
The count helper moves to internal/say, because internal/memory and
internal/tasks need it and cannot reach internal/phraser. Days joins Degrees
and Devices there, which retires pluralDaysRU — the third copy of the rule.
internal/phraser keeps the three names cmd/mavend already calls.
Six placeholders were undeclared: {line} {sat} {sun} {key} {gloss} {time}.
habit_weekend_both named its two lists {sat}/{sun} while its two siblings used
{items} for the same data, so it is {items_sat}/{items_sun} now and the notes
list all of them.
Fixedness was inconsistent across parallel single-variant entries. Deck.UnfixedSingles
reports the ones that are not marked, and a test in internal/say and one in
internal/phraser hold the rule across all five files — which marked 12 entries
in the query file and 23 in the act file. Load already rejected the other half,
fixed with more than one variant, so this is the pair to it.
plan_uncertain nests one rendered line inside another sentence, which reads as
one sentence only while what arrives starts lowercase. Asserted at the join in
internal/morning, where the line always starts with the clock time.
Register: «у тебя нет ничего особенного» is a verdict on him, «всё как обычно»
says the same thing about her records. «на привычки я так не сошлюсь» is
bookish. «ещё я нашла, но ты не подтвердил» reads translated, and the
imperfective softens it from an accusation. «у тебя» goes where the day already
carries it. Trailing periods come off the entries that end on {items}, so
tasks.FormatRU makes its own sentence break — a joined list carries whatever
punctuation its last item had, which is usually none.
--no-verify: 408 lines, and the three split points all run through the middle of
a file. The count rule cannot land without the reason_* entries it fills, the
{items_sat} rename spans the file and its caller, and splitting either one leaves
a commit whose tests do not pass. One review, one family, one commit.
242 lines
8.3 KiB
Go
242 lines
8.3 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|