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) } }