7f42cc73be
Seven fixes, each answering a line comment on the stack.
**Weather no longer invents Moscow** (PR 50). extractWeatherLocation returned
the string "Moscow" when he named no city and voice.weather.default_location
was unset — a made-up answer presented as fact, which is the one thing maven
must never do. It returns "" now and the query path says it does not know.
**Digest statuses are a defined type** (PR 50). DigestStatus string plus the
three constants, so a rule name cannot reach the status column.
**Quiet-mode negation is not adjacency** (PR 53). The OFF list carried
{"не","тих"}, an adjacency pattern, so "не надо тихий режим" missed OFF, hit
the ON pattern {"тих","режим"}, and asking for quiet mode to stop turned it
on. Negators are scanned over the whole utterance now, with the two ON phrases
that are themselves built on "не" excluded. "тихий режим выключи" works too,
which it did not before.
**Pattern stability uses a median band** (PR 54). max/min over the extremes
asked whether every gap resembles every other gap, so 7,7,7,7,20 — four clean
weeks and one holiday — was thrown away at a ratio of 2.9. Each interval is
now tested against the median and 70% must be in band, and the reported
interval is the median of the in-band ones, so a holiday no longer drags a
weekly habit to "every 9.6 days". The reviewer's 5,8,10,3 is still rejected.
**The weekday profile stops reciting everyday habits** (PR 59). "What do I do
on Saturdays?" answered "you drink water" — true, and useless, because it is
equally true of every other day. Activities that are habits on six or more
weekdays move to Profile.Everyday and are read back as daily habits instead of
as an answer about that day.
**Russian phrase tables move out of Go** (PR 59, PR 61). The behaviour glosses
and weekday names, and the task capture/urgency/list vocabulary, are now
behavior_ru.json and task_phrases.json, embedded with go:embed. Single-binary
deploy is unchanged; wording edits are no longer source diffs.
**nginx template stops taking nginx down** (PR 52). Two host-side failure
modes, both plausible causes of today's crash. The $connection_upgrade map is
fatal when duplicated, so it moved to its own nginx-upgrade-map.conf with a
grep-first note. And `listen 10.42.0.1:80` fails with EADDRNOTAVAIL when wg0
is not up yet, so nginx exits on a reboot that beats WireGuard — the header
now documents net.ipv4.ip_nonlocal_bind.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
219 lines
7.0 KiB
Go
219 lines
7.0 KiB
Go
package pattern
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestDetectEnoughEvents(t *testing.T) {
|
|
// MinEvents events with 7-day intervals → stable pattern
|
|
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
|
events := []Event{
|
|
{Action: "refill", Object: "cat_water", Ts: base},
|
|
{Action: "refill", Object: "cat_water", Ts: base.Add(7 * 24 * time.Hour)},
|
|
{Action: "refill", Object: "cat_water", Ts: base.Add(14 * 24 * time.Hour)},
|
|
{Action: "refill", Object: "cat_water", Ts: base.Add(21 * 24 * time.Hour)},
|
|
}
|
|
|
|
r, err := Detect(events)
|
|
if err != nil {
|
|
t.Fatalf("Detect: %v", err)
|
|
}
|
|
if r == nil {
|
|
t.Fatal("want a proposed routine, got nil")
|
|
}
|
|
if r.Action != "refill" || r.Object != "cat_water" {
|
|
t.Fatalf("action/object: want refill/cat_water, got %s/%s", r.Action, r.Object)
|
|
}
|
|
if r.N != 4 {
|
|
t.Fatalf("want N=4, got %d", r.N)
|
|
}
|
|
// ~7 days
|
|
if r.IntervalDays < 6.9 || r.IntervalDays > 7.1 {
|
|
t.Fatalf("want interval ~7, got %f", r.IntervalDays)
|
|
}
|
|
}
|
|
|
|
// TestDetectNotEnoughEvents — two intervals are a coincidence, not a routine
|
|
// (Vikunja #43). Three same-day-of-week events used to be enough to propose a
|
|
// weekly reminder; MinEvents is 4 now so a repeat has to happen a third time
|
|
// before Maven calls it a pattern.
|
|
func TestDetectNotEnoughEvents(t *testing.T) {
|
|
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
|
for _, n := range []int{1, 2, MinEvents - 1} {
|
|
events := make([]Event, n)
|
|
for i := range events {
|
|
events[i] = Event{
|
|
Action: "refill",
|
|
Object: "cat_water",
|
|
Ts: base.Add(time.Duration(i) * 7 * 24 * time.Hour),
|
|
}
|
|
}
|
|
r, err := Detect(events)
|
|
if err != nil {
|
|
t.Fatalf("Detect(%d events): %v", n, err)
|
|
}
|
|
if r != nil {
|
|
t.Fatalf("Detect(%d events) proposed %+v, want nil below MinEvents=%d", n, r, MinEvents)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDetectEmpty(t *testing.T) {
|
|
r, err := Detect(nil)
|
|
if err != nil {
|
|
t.Fatalf("Detect: %v", err)
|
|
}
|
|
if r != nil {
|
|
t.Fatal("want nil for empty events")
|
|
}
|
|
|
|
r, err = Detect([]Event{})
|
|
if err != nil {
|
|
t.Fatalf("Detect: %v", err)
|
|
}
|
|
if r != nil {
|
|
t.Fatal("want nil for empty events")
|
|
}
|
|
}
|
|
|
|
func TestDetectIrregularRejects(t *testing.T) {
|
|
// wildly irregular: 1 day, then 14 days → ratio 14 > 1.5
|
|
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
|
events := []Event{
|
|
{Action: "refill", Object: "cat_water", Ts: base},
|
|
{Action: "refill", Object: "cat_water", Ts: base.Add(1 * 24 * time.Hour)},
|
|
{Action: "refill", Object: "cat_water", Ts: base.Add(15 * 24 * time.Hour)},
|
|
{Action: "refill", Object: "cat_water", Ts: base.Add(16 * 24 * time.Hour)},
|
|
}
|
|
|
|
r, err := Detect(events)
|
|
if err != nil {
|
|
t.Fatalf("Detect: %v", err)
|
|
}
|
|
if r != nil {
|
|
t.Fatal("want nil for irregular intervals (ratio 14 > 1.5)")
|
|
}
|
|
}
|
|
|
|
func TestDetectBarelyStable(t *testing.T) {
|
|
// 4 events, intervals vary but within 1.5 ratio
|
|
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
|
events := []Event{
|
|
{Action: "feed", Object: "cat", Ts: base},
|
|
{Action: "feed", Object: "cat", Ts: base.Add(6 * 24 * time.Hour)}, // 6 days
|
|
{Action: "feed", Object: "cat", Ts: base.Add(12 * 24 * time.Hour)}, // 6 days
|
|
{Action: "feed", Object: "cat", Ts: base.Add(20 * 24 * time.Hour)}, // 8 days
|
|
}
|
|
|
|
r, err := Detect(events)
|
|
if err != nil {
|
|
t.Fatalf("Detect: %v", err)
|
|
}
|
|
if r == nil {
|
|
t.Fatal("want proposed routine for barely stable intervals (8/6=1.33 ≤ 1.5)")
|
|
}
|
|
if r.Action != "feed" || r.Object != "cat" {
|
|
t.Fatalf("action/object mismatch")
|
|
}
|
|
if r.N != 4 {
|
|
t.Fatalf("want N=4, got %d", r.N)
|
|
}
|
|
}
|
|
|
|
func TestDetectSameTimestamp(t *testing.T) {
|
|
// Two events at the same time — meaningless interval, should be ignored
|
|
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
|
events := []Event{
|
|
{Action: "refill", Object: "cat_water", Ts: base},
|
|
{Action: "refill", Object: "cat_water", Ts: base},
|
|
{Action: "refill", Object: "cat_water", Ts: base.Add(7 * 24 * time.Hour)},
|
|
{Action: "refill", Object: "cat_water", Ts: base.Add(14 * 24 * time.Hour)},
|
|
}
|
|
|
|
r, err := Detect(events)
|
|
if err != nil {
|
|
t.Fatalf("Detect: %v", err)
|
|
}
|
|
if r != nil {
|
|
t.Fatal("want nil when first two events have same timestamp")
|
|
}
|
|
}
|
|
|
|
func TestPhraseRoutine(t *testing.T) {
|
|
tests := []struct {
|
|
r ProposedRoutine
|
|
want string
|
|
}{
|
|
{ProposedRoutine{Action: "refill", Object: "cat_water", IntervalDays: 7}, "ты заправляешь cat water раз в неделю — напоминать?"},
|
|
{ProposedRoutine{Action: "feed", Object: "cat", IntervalDays: 1}, "ты кормишь cat каждый день — напоминать?"},
|
|
{ProposedRoutine{Action: "clean", Object: "litter_box", IntervalDays: 3}, "ты чистишь litter box раз в 3 дня — напоминать?"},
|
|
{ProposedRoutine{Action: "take", Object: "medicine", IntervalDays: 0.5}, "ты принимаешь medicine каждый день — напоминать?"},
|
|
{ProposedRoutine{Action: "walk", Object: "dog", IntervalDays: 14}, "ты выгуливаешь dog раз в 2 недели — напоминать?"},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.r.Action+"_"+tc.r.Object, func(t *testing.T) {
|
|
got := PhraseRoutine(&tc.r)
|
|
if got != tc.want {
|
|
t.Fatalf("phrase: want %q, got %q", tc.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// evAt builds a run of events at the given day offsets.
|
|
func evAt(offsets ...float64) []Event {
|
|
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
|
out := make([]Event, len(offsets))
|
|
for i, d := range offsets {
|
|
out[i] = Event{Action: "refill", Object: "cat_water",
|
|
Ts: base.Add(time.Duration(d * float64(24*time.Hour)))}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestDetectMedianBandNotExtremes — the stability test used to be
|
|
// longest/shortest, so a single outlier vetoed an otherwise clean rhythm and
|
|
// the reported interval was a mean dragged toward that outlier. Both are
|
|
// median-based now.
|
|
func TestDetectMedianBandNotExtremes(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
days []float64
|
|
want float64 // 0 means "expect no routine"
|
|
}{
|
|
// Four clean weeks and one holiday. max/min was 20/7 = 2.9, rejected.
|
|
{"weekly with one long gap", []float64{0, 7, 14, 21, 28, 48}, 7},
|
|
// The reviewer's case: 5, 8, 10, 3. Median 6.5, only two gaps in band.
|
|
{"genuinely irregular", []float64{0, 5, 13, 23, 26}, 0},
|
|
// A short gap outlier is treated the same as a long one.
|
|
{"weekly with one short gap", []float64{0, 7, 14, 15, 22, 29}, 7},
|
|
// Two outliers out of five is past the fraction.
|
|
{"too many outliers", []float64{0, 7, 14, 34, 41, 61}, 0},
|
|
// At the MinEvents floor there is no outlier budget at all.
|
|
{"floor rejects one outlier", []float64{0, 7, 14, 34}, 0},
|
|
{"floor accepts a clean run", []float64{0, 7, 14, 21}, 7},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
r, err := Detect(evAt(tc.days...))
|
|
if err != nil {
|
|
t.Fatalf("Detect: %v", err)
|
|
}
|
|
if tc.want == 0 {
|
|
if r != nil {
|
|
t.Fatalf("want no routine, got interval %.1f", r.IntervalDays)
|
|
}
|
|
return
|
|
}
|
|
if r == nil {
|
|
t.Fatal("want a routine, got nil")
|
|
}
|
|
if r.IntervalDays != tc.want {
|
|
t.Fatalf("interval: want %.1f, got %.1f", tc.want, r.IntervalDays)
|
|
}
|
|
})
|
|
}
|
|
}
|