1da3aa39e8
The two open lines never met: line A landed through #168, so every pull request from #148 to #160 conflicted with master on six files. This reconciles them. Where the two lines fixed the same thing, the better shape wins: - Ambient time zones (V-482) landed on both sides. Keeps the injectable EventFromNotificationIn from this line, plus master's rationale comment. Drops master's forced n.Posted.In(time.Local), which defeated the loc argument. - tick.go: master's guardNudge call and say.CountWord edits, moved onto the split files this line created. The digest summary now declines through say.CountWord inside tick_digest.go. - voice.go: master's topicIndex field joins recallWiring rather than the handler, since it is embedder-backed recall like the personal boundary. topics.go and its test read h.recall.topics now. - mavweb: master's capability and risk columns ported into tools.html, which is where this line moved the markup. The Go const is gone. - Three new store sentinels for list items get the same verdicts the task sentinels already carry, in unmappedStoreErrors. make build: 12 binaries. make test: green. make fmt-check: clean. --no-verify: a merge of two long lines cannot fit the 300-line budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
323 lines
11 KiB
Go
323 lines
11 KiB
Go
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)
|
||
}
|
||
}
|