Files
Maven/internal/router/dateparser_test.go
claude 44320ee496 dates: a bare hour after a day word is an hour, not the current clock (V-551)
At 21:12 "напомни мне завтра в семь позвонить маме" confirmed a reminder for
21:12 tomorrow. The hour was dropped and the wall clock carried onto the named
day. She did not ask; she named a time nobody gave her, on a path that fires.
A bare "напомни в семь" declines correctly, so adding "завтра" turned a decline
into an invented answer.

dateparser only reads a bare hour when it carries a qualifier or a colon.
"завтра в 7" keeps the current clock and "завтра в 7 часов" is read as seven
hours from now, which moves the day as well. English "at 7" fails identically,
so this is not a Russian defect and both prepositions are rewritten.

The script now gives it the colon: "в 7", "в 7 часов" and "at 7" become
"в 07:00" beside the existing утра/вечера rewrites. A duration is untouched,
because "через 2 часа" has no preposition to match, and so are "в 7:30",
"в 30 минут" and "в 2026 году".

The stub parser has always read the token after the day word, so the floor was
right and the production parser was not. No test on the stub could have caught
this. The four new cases are in TestPythonDateParser, which runs where
dateparser is installed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
2026-08-05 21:17:21 +04:00

205 lines
6.5 KiB
Go

package router
import (
"context"
"os/exec"
"testing"
"time"
)
// TestPythonDateParser requires python3 + dateparser installed. Skips
// gracefully in environments without them (e.g. bare go test, dev machines
// without the Docker runtime image).
func TestPythonDateParser(t *testing.T) {
// Skip if python3 isn't on PATH.
if _, err := exec.LookPath("python3"); err != nil {
t.Skip("python3 not on PATH — skipping dateparser tests")
}
// Skip if dateparser isn't installed.
cmd := exec.Command("python3", "-c", "import dateparser")
if err := cmd.Run(); err != nil {
t.Skip("python dateparser not installed — skipping dateparser tests")
}
p := NewPythonDateParser()
now := time.Date(2026, 7, 6, 18, 0, 0, 0, time.Local)
ctx := context.Background()
tests := []struct {
name string
text string
wantOK bool
checkT func(t *testing.T, got, now time.Time)
}{
{
name: "ru relative — через час",
text: "напомни через час выпить воды",
wantOK: true,
checkT: func(t *testing.T, got, now time.Time) {
d := got.Sub(now)
if d < 50*time.Minute || d > 70*time.Minute {
t.Errorf("через час: got %v from now, want ~1h", d)
}
},
},
{
name: "ru relative — через 30 минут",
text: "напомни через 30 минут снять бельё",
wantOK: true,
checkT: func(t *testing.T, got, now time.Time) {
d := got.Sub(now)
if d < 25*time.Minute || d > 35*time.Minute {
t.Errorf("через 30 минут: got %v from now, want ~30m", d)
}
},
},
{
name: "ru absolute — завтра в 9 утра",
text: "напомни завтра в 9 утра позвонить",
wantOK: true,
checkT: func(t *testing.T, got, now time.Time) {
if got.Hour() != 9 {
t.Errorf("завтра в 9 утра: hour=%d, want 9", got.Hour())
}
if got.Sub(now) < 12*time.Hour {
t.Errorf("завтра в 9 утра: only %v from now, want >12h (tomorrow)", got.Sub(now))
}
},
},
{
name: "ru complex — в следующую пятницу",
text: "напомни в следующую пятницу оплатить счёт",
wantOK: true,
checkT: func(t *testing.T, got, now time.Time) {
if got.Weekday() != time.Friday {
t.Errorf("в следующую пятницу: weekday=%v, want Friday", got.Weekday())
}
if got.Sub(now) < 24*time.Hour {
t.Errorf("в следующую пятницу: only %v from now, want >24h (future)", got.Sub(now))
}
},
},
{
name: "en relative — in 30 minutes",
text: "remind me in 30 minutes to drink water",
wantOK: true,
checkT: func(t *testing.T, got, now time.Time) {
d := got.Sub(now)
if d < 25*time.Minute || d > 35*time.Minute {
t.Errorf("in 30 minutes: got %v from now, want ~30m", d)
}
},
},
{
name: "en absolute — tomorrow at 8am",
text: "remind me tomorrow at 8am to call the doctor",
wantOK: true,
checkT: func(t *testing.T, got, now time.Time) {
if got.Hour() != 8 {
t.Errorf("tomorrow at 8am: hour=%d, want 8", got.Hour())
}
if got.Sub(now) < 12*time.Hour {
t.Errorf("tomorrow at 8am: only %v from now, want >12h (tomorrow)", got.Sub(now))
}
},
},
// A bare hour after a day word used to be dropped, and the current
// clock carried onto that day: at 21:12 "завтра в семь" confirmed a
// reminder for 21:12 tomorrow (Vikunja #551). She invented a time
// instead of asking, on a path that then fires.
{
name: "ru bare hour — завтра в семь",
text: "напомни мне завтра в семь позвонить маме",
wantOK: true,
checkT: func(t *testing.T, got, now time.Time) {
if got.Hour() != 7 || got.Minute() != 0 {
t.Errorf("завтра в семь: %02d:%02d, want 07:00", got.Hour(), got.Minute())
}
},
},
{
// "7 часов" was read as seven hours from now, which also moved the day.
name: "ru bare hour — завтра в 7 часов",
text: "напомни завтра в 7 часов позвонить",
wantOK: true,
checkT: func(t *testing.T, got, now time.Time) {
if got.Hour() != 7 || got.Minute() != 0 {
t.Errorf("завтра в 7 часов: %02d:%02d, want 07:00", got.Hour(), got.Minute())
}
},
},
{
name: "en bare hour — tomorrow at 7",
text: "remind me tomorrow at 7 to call mum",
wantOK: true,
checkT: func(t *testing.T, got, now time.Time) {
if got.Hour() != 7 || got.Minute() != 0 {
t.Errorf("tomorrow at 7: %02d:%02d, want 07:00", got.Hour(), got.Minute())
}
},
},
{
// The rewrite must not touch a duration: "через 2 часа" is not "в 2".
name: "ru duration is untouched — через 2 часа",
text: "напомни через 2 часа выпить воды",
wantOK: true,
checkT: func(t *testing.T, got, now time.Time) {
d := got.Sub(now)
if d < 110*time.Minute || d > 130*time.Minute {
t.Errorf("через 2 часа: got %v from now, want ~2h", d)
}
},
},
{
name: "no date — напомни мне",
text: "напомни мне",
wantOK: false,
},
{
name: "no date — remind me",
text: "remind me to do something",
wantOK: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok, err := p.Parse(ctx, tt.text, now)
if err != nil {
t.Fatalf("Parse(%q) error: %v", tt.text, err)
}
if ok != tt.wantOK {
t.Fatalf("Parse(%q) ok=%v, want %v (got time=%v)", tt.text, ok, tt.wantOK, got)
}
if ok && tt.checkT != nil {
tt.checkT(t, got, now)
}
})
}
}
// TestPythonDateParser_FallbackToStub verifies that when python3 is not
// available, the parser falls back to StubDateTimeParser instead of
// returning an error.
func TestPythonDateParser_FallbackToStub(t *testing.T) {
if _, err := exec.LookPath("python3"); err == nil {
t.Skip("python3 is available — fallback path not exercised")
}
p := NewPythonDateParser()
ctx := context.Background()
now := time.Date(2026, 7, 6, 18, 0, 0, 0, time.Local)
// The stub handles "in 1 hour" — should still work via fallback.
got, ok, err := p.Parse(ctx, "in 1 hour", now)
if err != nil {
t.Fatalf("fallback Parse error: %v", err)
}
if !ok {
t.Fatal("fallback Parse: ok=false, want true (stub should handle 'in 1 hour')")
}
d := got.Sub(now)
if d < 50*time.Minute || d > 70*time.Minute {
t.Errorf("fallback 'in 1 hour': got %v from now, want ~1h", d)
}
}