package memory import ( "strings" "testing" "time" "unicode" ) // 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(); !strings.Contains(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 !strings.Contains(wed, "ничего особенного") || !strings.Contains(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 him, not about her. "у меня пока нет // ничего постоянного" answers a question nobody asked. func TestEmptyWeekdayAnswerIsAboutHim(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 an answer about him", 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) } } func TestPluralDaysRU(t *testing.T) { for _, c := range []struct { n int want string }{{1, "день"}, {2, "дня"}, {5, "дней"}, {11, "дней"}, {21, "день"}, {22, "дня"}, {114, "дней"}} { if got := pluralDaysRU(c.n); got != c.want { t.Errorf("pluralDaysRU(%d) = %q, want %q", c.n, got, c.want) } } }