49f089d8a6
Maven does not get a work credential. A corp mail or calendar session living on the homelab ties the box's blast radius to the employer's data, which is the thing this task exists to refuse. What she reads instead is the signal: an Android notification-listener on the phone relays meeting notifications over wg/LAN to POST /api/ambient, and the ones that clearly describe a meeting become calendar events at source=ambient:notif, confidence 0.6. The provenance is the point. A notification is evidence about a meeting, not a reading of a calendar, so it is never indistinguishable from one: it is stored below full confidence, store.CalendarEvents keeps the source and confidence on every row it returns, and the query path hedges — "похоже, Планёрка @ 14:00" for a relayed event, plain text for a CalDAV read. The parse is deliberately conservative (internal/calendar/ambient.go). It needs a real clock reading and a summary that is not just that clock reading; otherwise it stores nothing at all. A bare hour is not a time, an unread count is not a time, and "срок 2026.08.15" does not offer 08:15 as a meeting — loose digits in a notification are far more often a badge or a date, and a mailbox of noise rendered as invented meetings is worse than a gap. The ingest is off unless configured: no -ambient-token, no route registered. The token is a shared secret compared in constant time, because the poster is a background Android service and WebAuthn has no answer for one. The endpoint is write-only, accepts one shape of write, and cannot read anything back out. Reposts of the same notification dedupe against the latest fact for that key+source, the same append-only discipline cmd/mavcaldav follows. Not shipped: the Android relay app itself, which is a separate artifact and a device, not Go in this repo.
149 lines
4.5 KiB
Go
149 lines
4.5 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)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
}
|