4e4c9170e3
EventFromNotification took the date from the notification's own day, on the grounds that a meeting notification is about today or it would not be firing. Calendar apps break that. A 21:00 reminder reading "Tomorrow at 09:00" became an event at 09:00 today, twelve hours in the past, and FactKey filed that wrong meeting under today's date. Storing a wrong meeting is the one outcome this parse works to avoid. An explicit day word now moves the date: завтра, tomorrow, послезавтра, сегодня, today, tonight. Matched whole, so послезавтра is not read as завтра, and stripped from the summary so the meeting is not named after the day. Anything still landing more than two hours before the notification is refused, which covers the cases with no day word at all. The grace keeps a repost for a meeting already under way. Also matches the bearer scheme with EqualFold. A phone sending "bearer <tok>" fell through to the X-Maven-Token branch and got a 401 that looked like a wrong token. A bare token with no scheme in Authorization is now rejected rather than silently accepted. The route table in mavweb gains its /api/ambient row, and the missing calendar_busy write is recorded as a known gap. Found in review of #57.
240 lines
7.8 KiB
Go
240 lines
7.8 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|