package calendar import ( "os" "testing" "time" ) // A bare clock reading in a notification is read in the daemon's zone, so every // test here needs a known one. UTC+4 is the deploy's (Europe/Samara) and it is // the offset the 18:30 bug was measured at, so a regression shows up as four // hours rather than as nothing at all on a UTC runner. func TestMain(m *testing.M) { time.Local = time.FixedZone("+04", 4*3600) os.Exit(m.Run()) } func TestEventFromNotification(t *testing.T) { posted := time.Date(2026, 8, 3, 9, 40, 0, 0, time.Local) tests := []struct { name string title, text string wantOK bool wantSummary string wantStart string // "15:04" wantEnd string }{ { name: "range in the body", title: "Планёрка", text: "10:00-10:30", wantOK: true, wantSummary: "Планёрка", wantStart: "10:00", wantEnd: "10:30", }, { name: "russian preposition and single time", title: "Встреча с подрядчиком в 14:00", wantOK: true, wantSummary: "Встреча с подрядчиком", wantStart: "14:00", wantEnd: "14:30", }, { name: "en dash range", title: "Sprint review", text: "Today 16:00 – 17:00, Meet", wantOK: true, wantSummary: "Sprint review", wantStart: "16:00", wantEnd: "17:00", }, { name: "до as a range separator", title: "Созвон", text: "с 11:30 до 12:15", wantOK: true, wantSummary: "Созвон", wantStart: "11:30", wantEnd: "12:15", }, { name: "dotted clock", title: "Обед 13.00", wantOK: true, wantSummary: "Обед", wantStart: "13:00", wantEnd: "13:30", }, { name: "range crossing midnight", title: "Ночной релиз", text: "23:30-00:30", wantOK: true, wantSummary: "Ночной релиз", wantStart: "23:30", wantEnd: "00:30", }, // The conservative half: no clock reading, no event. {name: "no time at all", title: "3 новых письма", wantOK: false}, {name: "bare hour is not a time", title: "Планёрка в 14", wantOK: false}, {name: "unread count", title: "Входящие", text: "12 непрочитанных", wantOK: false}, {name: "a date is not a clock", title: "Отчёт", text: "срок 2026.08.15", wantOK: false}, {name: "time but nothing named", title: "10:00-10:30", wantOK: false}, {name: "impossible clock", title: "Смена 99:99", wantOK: false}, {name: "empty", wantOK: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ev, ok := EventFromNotificationIn(Notification{ Package: "com.google.android.gm", Title: tt.title, Text: tt.text, Posted: posted, }, posted.Location()) if ok != tt.wantOK { t.Fatalf("ok = %v, want %v (event %+v)", ok, tt.wantOK, ev) } if !ok { return } if ev.Summary != tt.wantSummary { t.Errorf("summary = %q, want %q", ev.Summary, tt.wantSummary) } if got := ev.Start.Format("15:04"); got != tt.wantStart { t.Errorf("start = %s, want %s", got, tt.wantStart) } if got := ev.End.Format("15:04"); got != tt.wantEnd { t.Errorf("end = %s, want %s", got, tt.wantEnd) } if !ev.End.After(ev.Start) { t.Errorf("end %v must be after start %v", ev.End, ev.Start) } // The event lands on the day the phone showed it, in the zone it // was resolved against — never shifted into UTC. if ev.Start.Location() != posted.Location() { t.Errorf("location = %v, want %v", ev.Start.Location(), posted.Location()) } if y, m, d := ev.Start.Date(); y != 2026 || m != time.August || d != 3 { t.Errorf("date = %d-%02d-%02d, want 2026-08-03", y, m, d) } }) } } // A notification is not always about today. A 21:00 reminder reading // "Tomorrow at 09:00" used to be dated to the notification's own day, which put // the meeting twelve hours in the past and filed it under today in FactKey. A // wrong meeting stored is worse than nothing stored. func TestEventFromNotificationDayWords(t *testing.T) { evening := time.Date(2026, 8, 3, 21, 0, 0, 0, time.Local) tests := []struct { name string title, text string posted time.Time wantOK bool wantDay int // day of month wantSummary string }{ { name: "tomorrow in english", title: "Standup", text: "Tomorrow at 09:00", posted: evening, wantOK: true, wantDay: 4, wantSummary: "Standup", }, { name: "завтра in russian", title: "Планёрка", text: "завтра в 09:00", posted: evening, wantOK: true, wantDay: 4, wantSummary: "Планёрка", }, { name: "завтра in the title, summary in the body", title: "Завтра в 09:00", text: "Планёрка", posted: evening, wantOK: true, wantDay: 4, wantSummary: "Планёрка", }, { name: "послезавтра is two days, not one", title: "Ретро", text: "послезавтра 11:00", posted: evening, wantOK: true, wantDay: 5, wantSummary: "Ретро", }, { name: "сегодня stays on the posted day", title: "Созвон", text: "сегодня 21:30", posted: evening, wantOK: true, wantDay: 3, wantSummary: "Созвон", }, // No day word: the 09:00 is twelve hours behind the notification, so the // inferred day is wrong and there is nothing honest to store. { name: "stale morning time with no day word", title: "Standup", text: "at 09:00", posted: evening, wantOK: false, }, // Inside the grace: a phone reposting the notification for a meeting // already under way must still store it. { name: "meeting already running", title: "Планёрка", text: "20:30-22:00", posted: evening, wantOK: true, wantDay: 3, wantSummary: "Планёрка", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ev, ok := EventFromNotificationIn(Notification{ Package: "com.google.android.calendar", Title: tt.title, Text: tt.text, Posted: tt.posted, }, tt.posted.Location()) if ok != tt.wantOK { t.Fatalf("ok = %v, want %v (event %+v)", ok, tt.wantOK, ev) } if !ok { return } if got := ev.Start.Day(); got != tt.wantDay { t.Errorf("start day = %d, want %d (start %v)", got, tt.wantDay, ev.Start) } if ev.Summary != tt.wantSummary { t.Errorf("summary = %q, want %q", ev.Summary, tt.wantSummary) } if ev.Start.Before(tt.posted.Add(-ambientPastGrace)) { t.Errorf("start %v is stale against posted %v", ev.Start, tt.posted) } }) } } // The day word named the date, which now lives in Start. Leaving it in the // summary makes "Завтра Планёрка" the name of the meeting, and FactKey folds // that into the key. func TestEventFromNotificationDropsDayWordFromSummary(t *testing.T) { ev, ok := EventFromNotificationIn(Notification{ Title: "Завтра Планёрка 09:00", Posted: time.Date(2026, 8, 3, 21, 0, 0, 0, time.UTC), }, time.UTC) if !ok { t.Fatal("expected an event") } if ev.Summary != "Планёрка" { t.Fatalf("summary = %q, want %q", ev.Summary, "Планёрка") } } // Vikunja #482. A relay that posts its instant as UTC used to hand the wall // clock inside the text the same zone, so "созвон в 14:30" was stored as 14:30Z // and read back as 18:30 on a UTC+4 box — late by exactly the deploy's offset, // and correct-looking on a UTC one. Nobody writes a notification meaning 14:30Z. func TestEventFromNotificationReadsTheClockAsLocalTime(t *testing.T) { ev, ok := EventFromNotification(Notification{ Package: "com.slack", Title: "Standup", Text: "созвон в 14:30", Posted: time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC), // 13:00 local }) if !ok { t.Fatal("expected an event") } if got := ev.Start.Format("15:04"); got != "14:30" { t.Errorf("start = %s, want 14:30 local", got) } if ev.Start.Location() != time.Local { t.Errorf("location = %v, want %v", ev.Start.Location(), time.Local) } if got, want := FactKey(ev), "calendar_event_20260802_Standup"; got != want { t.Errorf("fact key = %q, want %q", got, want) } } func TestEventFromNotificationNeedsPostedAt(t *testing.T) { if _, ok := EventFromNotification(Notification{Title: "Планёрка 10:00"}); ok { t.Error("a notification with no posted_at has no date to sit on") } } // An ambient event must never be indistinguishable from a calendar read. func TestAmbientEventsAreStoredAtReducedConfidence(t *testing.T) { ev, ok := EventFromNotificationIn(Notification{ Title: "Планёрка 10:00-10:30", Posted: time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC), }, time.UTC) if !ok { t.Fatal("expected an event") } if FactKey(ev) == "" || FactValue(ev) == "" { t.Fatal("ambient events must use the shared fact encoding") } if AmbientConfidence >= 1.0 { t.Fatal("ambient confidence must be below a calendar read's") } } func TestStripClock(t *testing.T) { tests := []struct{ in, want string }{ {"Встреча в 14:00", "Встреча"}, {"Планёрка 10:00-10:30", "Планёрка"}, {"с 11:30 до 12:15 Созвон", "Созвон"}, {"Ничего", "Ничего"}, } for _, tt := range tests { if got := stripClock(tt.in); got != tt.want { t.Errorf("stripClock(%q) = %q, want %q", tt.in, got, tt.want) } } } // The defect this file's zone handling exists for: a phone posts an RFC 3339 // instant ending in Z, and "созвон в 14:30" used to be resolved against that // Z, so on a UTC+4 box the meeting was stored at 18:30. A wall clock in a // notification is local by construction. func TestNotificationClockIsLocalNotUTC(t *testing.T) { samara := time.FixedZone("+04", 4*3600) n := Notification{ Package: "com.slack", Title: "Standup", Text: "созвон в 14:30", Posted: time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC), // 13:00 local } ev, ok := EventFromNotificationIn(n, samara) if !ok { t.Fatal("expected an event") } if got := ev.Start.Format("15:04"); got != "14:30" { t.Errorf("start = %s local, want 14:30", got) } if got := ev.Start.UTC().Format("15:04"); got != "10:30" { t.Errorf("start = %sZ, want 10:30Z (14:30 at UTC+4)", got) } if ev.Start.Location() != samara { t.Errorf("location = %v, want %v", ev.Start.Location(), samara) } // The stored key is the local day, so it files under the day he lived. if want, got := "calendar_event_20260802_Standup", FactKeyIn(ev, samara); got != want { t.Errorf("FactKey = %q, want %q", got, want) } } // A notification posted late in the UTC evening is already the next day where // he is standing. The day must come from the local clock, not from Posted's. func TestNotificationDayIsTheLocalDay(t *testing.T) { samara := time.FixedZone("+04", 4*3600) n := Notification{ Title: "Планёрка 09:00", Text: "завтра", Posted: time.Date(2026, 8, 2, 21, 0, 0, 0, time.UTC), // 03-08 01:00 local } ev, ok := EventFromNotificationIn(n, samara) if !ok { t.Fatal("expected an event") } if y, m, d := ev.Start.Date(); y != 2026 || m != time.August || d != 4 { t.Errorf("date = %d-%02d-%02d, want 2026-08-04 (tomorrow, locally)", y, m, d) } }