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