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