package router import ( "context" "os/exec" "testing" "time" ) // probeNow is the clock the five sentences below were measured against on the // box at 03:53 on 2026-08-06, right after #252 deployed. Three of them wrote a // reminder for 03:53 itself, which is the current minute and not an hour anyone // said (V-610). var probeNow = time.Date(2026, 8, 6, 3, 53, 0, 0, time.Local) // kProbe — the five sentences, with what each must resolve to. The two that // already worked are here so that fixing "к" cannot cost "в". var kProbe = []struct { text string // day is the offset from probeNow's date, hour is the fire hour. day int hour int whyItIs string }{ { text: "напомни завтра в три часа дня позвонить врачу", day: 1, hour: 15, whyItIs: "в plus a spoken hour and a qualifier resolves, and did before #252", }, { text: "напомни завтра в 15:00 позвонить врачу", day: 1, hour: 15, whyItIs: "a written clock resolves, and did before #252", }, { text: "напомни завтра к трём часам дня позвонить врачу", day: 1, hour: 15, whyItIs: "к names the same hour в does, and wrote 03:53 after #252", }, { text: "напомни сегодня к пяти часам вечера позвонить врачу", day: 0, hour: 17, whyItIs: "the same defect on today and on the oblique пяти", }, { text: "напомни завтра к трём часам позвонить врачу", day: 1, hour: 3, whyItIs: "no qualifier, so the hour reads as spoken and the daemon asks which half", }, } // TestKPrepositionHourStub — the probe against the floor parser, which answers // on every box whether or not python is installed. func TestKPrepositionHourStub(t *testing.T) { ex := Extractor{Time: StubDateTimeParser{}} for _, c := range kProbe { t.Run(c.text, func(t *testing.T) { got := ex.Extract(context.Background(), IntentReminder, c.text, probeNow) if !got.HasTime { t.Fatalf("time slot empty: %s", c.whyItIs) } checkFire(t, got.Time, c.day, c.hour, c.whyItIs) }) } } // TestKPrepositionHourPython — the same probe against the production parser. // Skips where python3 or dateparser is missing, as the rest of this package's // python tests do. func TestKPrepositionHourPython(t *testing.T) { requirePython(t) p := NewPythonDateParser() ex := Extractor{Time: p} for _, c := range kProbe { t.Run(c.text, func(t *testing.T) { got := ex.Extract(context.Background(), IntentReminder, c.text, probeNow) if !got.HasTime { t.Fatalf("time slot empty: %s", c.whyItIs) } checkFire(t, got.Time, c.day, c.hour, c.whyItIs) }) } } func checkFire(t *testing.T, fire time.Time, dayOffset, hour int, why string) { t.Helper() if fire.Hour() != hour || fire.Minute() != 0 { t.Errorf("fire = %s, want %02d:00 — %s", fire.Format("2006-01-02 15:04"), hour, why) } if fire.Minute() == probeNow.Minute() && fire.Hour() == probeNow.Hour() { t.Errorf("fire = %s, which is the clock at the moment of the turn and not an hour he said", fire.Format("15:04")) } want := probeNow.AddDate(0, 0, dayOffset) if fire.Year() != want.Year() || fire.Month() != want.Month() || fire.Day() != want.Day() { t.Errorf("fire = %s, want the %s — %s", fire.Format("2006-01-02"), want.Format("2006-01-02"), why) } } // TestUnresolvedHourLeavesTheSlotEmpty — the durable half. A parser that read // the day and took the minute off the clock must not fill the time slot, no // matter which preposition lost the hour. This is the whole class the "к" case // was one member of. func TestUnresolvedHourLeavesTheSlotEmpty(t *testing.T) { ex := Extractor{Time: clockEchoParser{}} for _, s := range []string{ "напомни завтра к трём часам дня позвонить врачу", "напомни завтра в три часа дня позвонить врачу", "напомни завтра на девять позвонить врачу", "напомни сегодня к пяти часам вечера позвонить врачу", } { got := ex.Extract(context.Background(), IntentReminder, s, probeNow) if got.HasTime { t.Errorf("Extract(%q) filled the slot with %s, which is the current minute; she has to ask", s, got.Time.Format("15:04")) } } } // TestSpokenMinutesSurviveTheRefusal — the three shapes that name a minute of // their own, and an hour that happens to be the hour it is spoken in. Refusing // on the whole instant instead of on the minute would cost every one of these, // and ru-rem-006 in the routing fixture is the case that says so. func TestSpokenMinutesSurviveTheRefusal(t *testing.T) { noon := time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC) ex := Extractor{Time: StubDateTimeParser{}} for _, c := range []struct { text string hour int min int }{ {"напомни послезавтра в 12 забрать заказ", 12, 0}, {"разбуди меня в 6:30", 6, 30}, {"напомни в половине восьмого выпить таблетку", 7, 30}, {"напомни без четверти восемь выходить", 7, 45}, } { got := ex.Extract(context.Background(), IntentReminder, c.text, noon) if !got.HasTime { t.Errorf("Extract(%q) left the slot empty; he said the time", c.text) continue } if got.Time.Hour() != c.hour || got.Time.Minute() != c.min { t.Errorf("Extract(%q) = %s, want %02d:%02d", c.text, got.Time.Format("15:04"), c.hour, c.min) } } } // TestIntervalKeepsTheCurrentMinute — an interval is measured from now and may // land on now's own minute, so the refusal above must not reach it. func TestIntervalKeepsTheCurrentMinute(t *testing.T) { ex := Extractor{Time: StubDateTimeParser{}} got := ex.Extract(context.Background(), IntentReminder, "напомни через час позвонить врачу", probeNow) if !got.HasTime { t.Fatal("через час names one instant and answers the hour and the day together") } if want := probeNow.Add(time.Hour); !got.Time.Equal(want) { t.Errorf("fire = %s, want %s", got.Time.Format("15:04"), want.Format("15:04")) } } func requirePython(t *testing.T) { t.Helper() if _, err := exec.LookPath("python3"); err != nil { t.Skip("python3 not on PATH — skipping dateparser tests") } if err := exec.Command("python3", "-c", "import dateparser").Run(); err != nil { t.Skip("python dateparser not installed — skipping dateparser tests") } }