package calendar import ( "testing" "time" ) func TestEventFromNotification(t *testing.T) { posted := time.Date(2026, 8, 3, 9, 40, 0, 0, time.FixedZone("+04", 4*3600)) 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 := EventFromNotification(Notification{ Package: "com.google.android.gm", Title: tt.title, Text: tt.text, Posted: posted, }) 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 phone's // location — not 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) { loc := time.FixedZone("+04", 4*3600) evening := time.Date(2026, 8, 3, 21, 0, 0, 0, loc) 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 := EventFromNotification(Notification{ Package: "com.google.android.calendar", Title: tt.title, Text: tt.text, Posted: tt.posted, }) 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 := EventFromNotification(Notification{ Title: "Завтра Планёрка 09:00", Posted: time.Date(2026, 8, 3, 21, 0, 0, 0, time.UTC), }) if !ok { t.Fatal("expected an event") } if ev.Summary != "Планёрка" { t.Fatalf("summary = %q, want %q", ev.Summary, "Планёрка") } } 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 := EventFromNotification(Notification{ Title: "Планёрка 10:00-10:30", Posted: time.Date(2026, 8, 3, 9, 0, 0, 0, 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) } } }