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.
366 lines
13 KiB
Go
366 lines
13 KiB
Go
package memory
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
"unicode"
|
||
|
||
"github.com/kami/maven/internal/say"
|
||
)
|
||
|
||
// habitHistory — n weeks of the same weekday, at the given local time.
|
||
func habitHistory(key string, wd time.Weekday, hh, mm, weeks int, from time.Time) []Observation {
|
||
var out []Observation
|
||
d := from
|
||
for d.Weekday() != wd {
|
||
d = d.AddDate(0, 0, -1)
|
||
}
|
||
for i := 0; i < weeks; i++ {
|
||
day := d.AddDate(0, 0, -7*i)
|
||
out = append(out, Observation{
|
||
At: time.Date(day.Year(), day.Month(), day.Day(), hh, mm, 0, 0, from.Location()),
|
||
Key: key,
|
||
Kind: "self",
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func behaviorNow() time.Time {
|
||
// A Monday, so "по вторникам" is a past weekday and not today.
|
||
return time.Date(2026, 8, 3, 20, 0, 0, 0, time.UTC)
|
||
}
|
||
|
||
func TestBuildProfileCountsWeekdayHabits(t *testing.T) {
|
||
now := behaviorNow()
|
||
obs := append(
|
||
habitHistory("workout", time.Tuesday, 19, 0, 4, now),
|
||
habitHistory("water", time.Tuesday, 9, 0, 3, now)...,
|
||
)
|
||
p := BuildProfile(obs, now)
|
||
|
||
tue := p.Weekly[time.Tuesday]
|
||
if len(tue) != 2 {
|
||
t.Fatalf("got %d tuesday activities, want 2: %+v", len(tue), tue)
|
||
}
|
||
// Most-established first.
|
||
if tue[0].Key != "workout" || tue[0].Days != 4 {
|
||
t.Errorf("first = %+v, want workout on 4 days", tue[0])
|
||
}
|
||
if tue[0].TypicalAt != 19*time.Hour {
|
||
t.Errorf("typical at %v, want 19:00", tue[0].TypicalAt)
|
||
}
|
||
if len(p.Weekly[time.Wednesday]) != 0 {
|
||
t.Errorf("wednesday must be empty: %+v", p.Weekly[time.Wednesday])
|
||
}
|
||
if len(p.All) != 2 {
|
||
t.Errorf("the week-wide list should hold both: %+v", p.All)
|
||
}
|
||
}
|
||
|
||
// A one-off is not a habit. Saying "ты обычно X" off a single row is a
|
||
// confidently wrong claim about his life.
|
||
func TestBuildProfileNeedsMoreThanOneDay(t *testing.T) {
|
||
now := behaviorNow()
|
||
obs := habitHistory("workout", time.Tuesday, 19, 0, 1, now)
|
||
// Three rows, same day — a busy Tuesday, not a habit.
|
||
obs = append(obs, Observation{At: obs[0].At.Add(time.Hour), Key: "workout", Kind: "self"})
|
||
obs = append(obs, Observation{At: obs[0].At.Add(2 * time.Hour), Key: "workout", Kind: "self"})
|
||
|
||
p := BuildProfile(obs, now)
|
||
if len(p.All) != 0 || len(p.Weekly) != 0 {
|
||
t.Fatalf("one day of rows must produce no habit: %+v / %+v", p.All, p.Weekly)
|
||
}
|
||
if got := p.FormatOverallRU(); !say.IsS(say.HabitOverallNone, nil, got) {
|
||
t.Errorf("empty profile reads %q", got)
|
||
}
|
||
}
|
||
|
||
// Only self-facts describe him. Env rows are the world and config rows are
|
||
// maven's own tuning state; counting either as a habit would be a category
|
||
// error the owner would then be told about.
|
||
func TestBuildProfileIgnoresNonSelfAndMachineryKeys(t *testing.T) {
|
||
now := behaviorNow()
|
||
var obs []Observation
|
||
for _, o := range habitHistory("water", time.Tuesday, 9, 0, 3, now) {
|
||
o.Kind = "env"
|
||
obs = append(obs, o)
|
||
}
|
||
for _, o := range habitHistory("cooldown:water", time.Tuesday, 9, 0, 3, now) {
|
||
obs = append(obs, o) // kind=self, but a machinery key
|
||
}
|
||
for _, o := range habitHistory("calendar_event_20260804_standup", time.Tuesday, 10, 0, 3, now) {
|
||
obs = append(obs, o)
|
||
}
|
||
if p := BuildProfile(obs, now); len(p.All) != 0 {
|
||
t.Fatalf("nothing here is a habit of his: %+v", p.All)
|
||
}
|
||
}
|
||
|
||
// The median, not the mean: one 03:00 outlier must not move a morning habit
|
||
// into the night.
|
||
func TestBuildProfileTypicalTimeIsMedian(t *testing.T) {
|
||
now := behaviorNow()
|
||
obs := habitHistory("water", time.Tuesday, 9, 0, 4, now)
|
||
obs = append(obs, Observation{At: obs[0].At.AddDate(0, 0, -28).Add(-6 * time.Hour), Key: "water", Kind: "self"})
|
||
p := BuildProfile(obs, now)
|
||
if len(p.All) != 1 {
|
||
t.Fatalf("got %+v", p.All)
|
||
}
|
||
if p.All[0].TypicalAt != 9*time.Hour {
|
||
t.Errorf("typical at %v, want 09:00 despite the outlier", p.All[0].TypicalAt)
|
||
}
|
||
}
|
||
|
||
func TestProfileFormatRUPersona(t *testing.T) {
|
||
now := behaviorNow()
|
||
obs := append(
|
||
habitHistory("workout", time.Tuesday, 19, 0, 4, now),
|
||
habitHistory("water", time.Tuesday, 9, 5, 3, now)...,
|
||
)
|
||
p := BuildProfile(obs, now)
|
||
|
||
got := p.FormatWeekdayRU(time.Tuesday)
|
||
want := "по вторникам ты обычно тренируешься около 19:00 и пьёшь воду около 09:05."
|
||
if got != want {
|
||
t.Errorf("got %q\nwant %q", got, want)
|
||
}
|
||
if empty := p.FormatWeekdayRU(time.Thursday); !strings.Contains(empty, "постоянного") {
|
||
t.Errorf("an unknown weekday reads %q", empty)
|
||
}
|
||
// Persona: she addresses him informally, never in the masculine about
|
||
// herself, and never with a pet name.
|
||
for _, s := range []string{got, p.FormatOverallRU(), p.FormatWeekdayRU(time.Thursday)} {
|
||
// Whole words: "ничего" contains "его", and a substring test would
|
||
// call a correct sentence a persona violation.
|
||
for _, tok := range strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
|
||
return !unicode.IsLetter(r)
|
||
}) {
|
||
switch tok {
|
||
case "рад", "понял", "вы", "ваш", "ваши", "милый", "дорогой", "он", "его":
|
||
t.Errorf("%q uses %q", s, tok)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// An unrecognised key is read back verbatim rather than glossed into something
|
||
// maven made up.
|
||
func TestProfileUnknownKeyReadBackVerbatim(t *testing.T) {
|
||
now := behaviorNow()
|
||
p := BuildProfile(habitHistory("починил кран", time.Tuesday, 12, 0, 2, now), now)
|
||
if got := p.FormatOverallRU(); !strings.Contains(got, "починил кран") {
|
||
t.Errorf("got %q", got)
|
||
}
|
||
}
|
||
|
||
// A future-dated row is a clock problem, not a habit.
|
||
func TestBuildProfileIgnoresFutureRows(t *testing.T) {
|
||
now := behaviorNow()
|
||
obs := habitHistory("water", time.Tuesday, 9, 0, 3, now.AddDate(0, 2, 0))
|
||
if p := BuildProfile(obs, now); len(p.All) != 0 {
|
||
t.Fatalf("future rows counted: %+v", p.All)
|
||
}
|
||
}
|
||
|
||
// TestWeekdayProfileExcludesEverydayHabits — the "You drink water" case.
|
||
// Drinking water every day is not what he does on Saturdays, and answering
|
||
// with it makes the weekday question pointless.
|
||
func TestWeekdayProfileExcludesEverydayHabits(t *testing.T) {
|
||
now := time.Date(2026, 8, 1, 20, 0, 0, 0, time.UTC) // a Saturday
|
||
var obs []Observation
|
||
// water: twice a day, every day, for three weeks.
|
||
for d := 1; d <= 21; d++ {
|
||
day := now.AddDate(0, 0, -d)
|
||
obs = append(obs,
|
||
Observation{At: time.Date(day.Year(), day.Month(), day.Day(), 9, 0, 0, 0, time.UTC), Key: "water", Kind: "self"},
|
||
Observation{At: time.Date(day.Year(), day.Month(), day.Day(), 18, 0, 0, 0, time.UTC), Key: "water", Kind: "self"})
|
||
}
|
||
// workout: Saturdays only.
|
||
for _, d := range []int{7, 14, 21} {
|
||
day := now.AddDate(0, 0, -d)
|
||
obs = append(obs, Observation{
|
||
At: time.Date(day.Year(), day.Month(), day.Day(), 11, 0, 0, 0, time.UTC), Key: "workout", Kind: "self"})
|
||
}
|
||
|
||
p := BuildProfile(obs, now)
|
||
|
||
sat := p.Weekly[time.Saturday]
|
||
if len(sat) != 1 || sat[0].Key != "workout" {
|
||
t.Fatalf("Saturday should be characterised by workout alone, got %+v", sat)
|
||
}
|
||
if len(p.Everyday) != 1 || p.Everyday[0].Key != "water" {
|
||
t.Fatalf("water should be an everyday habit, got %+v", p.Everyday)
|
||
}
|
||
|
||
got := p.FormatWeekdayRU(time.Saturday)
|
||
if !strings.Contains(got, "тренируешься") {
|
||
t.Fatalf("Saturday readout should name the workout: %q", got)
|
||
}
|
||
if strings.Contains(got, "воду") {
|
||
t.Fatalf("Saturday readout must not recite the everyday habit: %q", got)
|
||
}
|
||
|
||
// A day with nothing of its own says so rather than reciting water as if
|
||
// Wednesday were the reason for it.
|
||
wed := p.FormatWeekdayRU(time.Wednesday)
|
||
if !say.IsS(say.HabitWeekdaySame, map[string]string{
|
||
"day": "средам", "items": "пьёшь воду около 13:30",
|
||
}, wed) {
|
||
t.Fatalf("plain weekday readout should say the day is unremarkable and name the daily habits: %q", wed)
|
||
}
|
||
}
|
||
|
||
// TestTypicalTimeIsCircular — the median is over a clock, not a number line.
|
||
// Bedtimes either side of midnight used to average to midday, which is the
|
||
// exact error the median was chosen to avoid, on the one activity most likely
|
||
// to cross the boundary.
|
||
func TestTypicalTimeIsCircular(t *testing.T) {
|
||
now := behaviorNow()
|
||
var obs []Observation
|
||
for i, mins := range []int{23*60 + 40, 23*60 + 50, 10, 20} {
|
||
day := now.AddDate(0, 0, -(i + 1))
|
||
obs = append(obs, Observation{
|
||
At: time.Date(day.Year(), day.Month(), day.Day(), 0, mins, 0, 0, now.Location()),
|
||
Key: "sleep",
|
||
Kind: "self",
|
||
})
|
||
}
|
||
p := BuildProfile(obs, now)
|
||
if len(p.All) != 1 {
|
||
t.Fatalf("got %+v, want one activity", p.All)
|
||
}
|
||
got := p.All[0].TypicalAt
|
||
if !p.All[0].HasTypical {
|
||
t.Fatal("a four-observation cluster has a typical time")
|
||
}
|
||
if got < 23*time.Hour+55*time.Minute && got > 5*time.Minute {
|
||
t.Errorf("typical bedtime = %v, want just either side of midnight", got)
|
||
}
|
||
if s := p.FormatOverallRU(); strings.Contains(s, "около 12:00") {
|
||
t.Errorf("read back as %q", s)
|
||
}
|
||
}
|
||
|
||
// Times spread across the whole clock have no typical value, and she must not
|
||
// name one.
|
||
func TestNoTypicalTimeWhenSpreadWide(t *testing.T) {
|
||
now := behaviorNow()
|
||
var obs []Observation
|
||
for i, hh := range []int{2, 9, 16, 21} {
|
||
day := now.AddDate(0, 0, -(i + 1))
|
||
obs = append(obs, Observation{
|
||
At: time.Date(day.Year(), day.Month(), day.Day(), hh, 0, 0, 0, now.Location()),
|
||
Key: "water",
|
||
Kind: "self",
|
||
})
|
||
}
|
||
p := BuildProfile(obs, now)
|
||
if len(p.All) != 1 {
|
||
t.Fatalf("got %+v, want one activity", p.All)
|
||
}
|
||
if p.All[0].HasTypical {
|
||
t.Errorf("times spanning %v were given a typical value", p.All[0].TypicalAt)
|
||
}
|
||
if s := p.FormatOverallRU(); strings.Contains(s, "около") {
|
||
t.Errorf("read back with a time she cannot support: %q", s)
|
||
}
|
||
}
|
||
|
||
// TestKeysAreCanonicalisedBeforeCounting — the fact key comes out of the LLM
|
||
// with no allowlist behind it, so the same habit arrives spelled several ways.
|
||
// Counted separately, each spelling sits below MinHabitDays and the habit
|
||
// vanishes.
|
||
func TestKeysAreCanonicalisedBeforeCounting(t *testing.T) {
|
||
now := behaviorNow()
|
||
var obs []Observation
|
||
for i, key := range []string{"water", "Воду", "выпил воды", "попил_воды"} {
|
||
day := now.AddDate(0, 0, -(i + 1))
|
||
obs = append(obs, Observation{
|
||
At: time.Date(day.Year(), day.Month(), day.Day(), 9, 0, 0, 0, now.Location()),
|
||
Key: key,
|
||
Kind: "self",
|
||
})
|
||
}
|
||
p := BuildProfile(obs, now)
|
||
if len(p.All) != 1 {
|
||
t.Fatalf("got %+v, want one activity — the spellings are one habit", p.All)
|
||
}
|
||
if p.All[0].Key != "water" || p.All[0].Days != 4 {
|
||
t.Errorf("got %+v, want water on 4 days", p.All[0])
|
||
}
|
||
if s := p.FormatOverallRU(); !strings.Contains(s, "пьёшь воду") {
|
||
t.Errorf("read back as %q, want the glossed canonical key", s)
|
||
}
|
||
}
|
||
|
||
// An unglossed key is quoted, not read as a verb. "обычно ты выпил_воды около
|
||
// 09:00" is what reciting the raw key produced.
|
||
func TestUnglossedKeyIsQuoted(t *testing.T) {
|
||
now := behaviorNow()
|
||
p := BuildProfile(habitHistory("починил_кран", time.Tuesday, 12, 0, 2, now), now)
|
||
got := p.FormatOverallRU()
|
||
if !strings.Contains(got, "отмечаешь «починил кран»") {
|
||
t.Errorf("got %q", got)
|
||
}
|
||
}
|
||
|
||
// She says over what stretch of records "обычно" is claimed. Without it the
|
||
// same sentence comes out of three days and out of a year.
|
||
func TestOverallNamesThePeriod(t *testing.T) {
|
||
now := behaviorNow()
|
||
p := BuildProfile(habitHistory("water", time.Tuesday, 9, 0, 3, now), now)
|
||
got := p.FormatOverallRU()
|
||
if !strings.Contains(got, "по записям за последние 21 день") {
|
||
t.Errorf("got %q, want the period spoken", got)
|
||
}
|
||
}
|
||
|
||
// The no-data weekday answer is about his week, not about her. "у меня пока нет
|
||
// ничего постоянного" answers a question nobody asked. It does say "я не вижу",
|
||
// because what she is reporting is the state of her records — and it names the
|
||
// day, which is what makes it an answer to what he asked (Vikunja #521: "у тебя"
|
||
// came out as filler, the day carries it).
|
||
func TestEmptyWeekdayAnswerIsAboutHisWeek(t *testing.T) {
|
||
p := BuildProfile(nil, behaviorNow())
|
||
got := p.FormatWeekdayRU(time.Wednesday)
|
||
if strings.Contains(got, "у меня") {
|
||
t.Errorf("got %q", got)
|
||
}
|
||
if !strings.Contains(got, "средам") {
|
||
t.Errorf("got %q, want the day he asked about", got)
|
||
}
|
||
}
|
||
|
||
// "quiet" is machinery, but only as a whole key. As a bare five-letter prefix
|
||
// it silently swallowed any future self-fact key starting with those letters.
|
||
func TestQuietPrefixDoesNotSwallowRealKeys(t *testing.T) {
|
||
now := behaviorNow()
|
||
p := BuildProfile(habitHistory("quietude", time.Tuesday, 8, 0, 3, now), now)
|
||
if len(p.All) != 1 {
|
||
t.Fatalf("got %+v, want the key counted", p.All)
|
||
}
|
||
p = BuildProfile(habitHistory("quiet_hours", time.Tuesday, 8, 0, 3, now), now)
|
||
if len(p.All) != 0 {
|
||
t.Fatalf("got %+v, want maven's own tuning state dropped", p.All)
|
||
}
|
||
}
|
||
|
||
// "по выходным" is a question about two days, answered as two days.
|
||
func TestFormatWeekendRU(t *testing.T) {
|
||
now := behaviorNow()
|
||
obs := append(
|
||
habitHistory("workout", time.Saturday, 11, 0, 3, now),
|
||
habitHistory("walk", time.Sunday, 15, 0, 3, now)...,
|
||
)
|
||
got := BuildProfile(obs, now).FormatWeekendRU()
|
||
if !strings.Contains(got, "по субботам") || !strings.Contains(got, "по воскресеньям") {
|
||
t.Errorf("got %q, want both weekend days named", got)
|
||
}
|
||
empty := BuildProfile(nil, now).FormatWeekendRU()
|
||
if strings.Contains(empty, "у меня") {
|
||
t.Errorf("got %q", empty)
|
||
}
|
||
}
|