package calendar import ( "strings" "testing" "time" ) func TestParseICalDayKeepsOnlyToday(t *testing.T) { now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC) body := []byte(`BEGIN:VCALENDAR BEGIN:VEVENT UID:a@example DTSTART:20260703T090000Z DTEND:20260703T100000Z SUMMARY:Morning standup END:VEVENT BEGIN:VEVENT DTSTART:20260703T140000Z DTEND:20260703T150000Z SUMMARY:Team sync END:VEVENT BEGIN:VEVENT DTSTART:20260702T140000Z DTEND:20260702T150000Z SUMMARY:Yesterday retro END:VEVENT BEGIN:VEVENT DTSTART:20260704T090000Z DTEND:20260704T100000Z SUMMARY:Tomorrow standup END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20260704 DTEND;VALUE=DATE:20260705 SUMMARY:All-day event END:VEVENT END:VCALENDAR`) events := ParseICalDay(body, now) if len(events) != 2 { t.Fatalf("got %d events, want 2 (today only, no all-day/past/future)", len(events)) } if events[0].Summary != "Morning standup" || events[0].UID != "a@example" { t.Errorf("events[0] = %+v", events[0]) } if !events[0].Start.Equal(time.Date(2026, 7, 3, 9, 0, 0, 0, time.UTC)) { t.Errorf("events[0].Start = %v", events[0].Start) } if !events[0].End.Equal(time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC)) { t.Errorf("events[0].End = %v", events[0].End) } if events[1].Summary != "Team sync" { t.Errorf("events[1].Summary = %q", events[1].Summary) } } // Regression: "today" is the owner's day, in the owner's location. Taking the // day number off a local clock but building the boundaries in UTC made the // evening fall outside the window on any box east of Greenwich. func TestParseICalDayUsesOwnersDay(t *testing.T) { plus4 := time.FixedZone("+04", 4*60*60) // 01:00 on Aug 1 local is 21:00 on Jul 31 UTC. now := time.Date(2026, 8, 1, 1, 0, 0, 0, plus4) body := []byte("BEGIN:VCALENDAR\nBEGIN:VEVENT\n" + "DTSTART:20260731T195406Z\nDTEND:20260731T235406Z\nSUMMARY:Current meeting\n" + "END:VEVENT\nEND:VCALENDAR") events := ParseICalDay(body, now) if len(events) != 1 { t.Fatalf("got %d events, want the in-progress one", len(events)) } if !Busy(events, now.UTC()) { t.Error("an event in progress right now must read as busy") } } func TestParseVEVENT(t *testing.T) { block := "DTSTART;TZID=Europe/Moscow:20260703T130000\nDTEND:20260703T140000Z\nSUMMARY:Stand up meeting" e, ok := parseVEVENT(block) if !ok { t.Fatal("expected a parsed event") } if !e.Start.Equal(time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC)) { t.Errorf("start = %v", e.Start) } if !e.End.Equal(time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC)) { t.Errorf("end = %v", e.End) } if e.Summary != "Stand up meeting" { t.Errorf("summary = %q", e.Summary) } allDay := "DTSTART;VALUE=DATE:20260703\nDTEND;VALUE=DATE:20260704\nSUMMARY:All-day" if _, ok := parseVEVENT(allDay); ok { t.Error("all-day event should be rejected") } } func TestParseDT(t *testing.T) { tests := []struct { name string line string want time.Time wantOK bool }{ {"UTC", "DTEND:20260703T100000Z", time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC), true}, {"local", "DTSTART;TZID=Europe/Moscow:20260703T130000", time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC), true}, {"all-day", "DTSTART;VALUE=DATE:20260703", time.Time{}, false}, {"garbage", "DTSTART:garbage", time.Time{}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, ok := parseDT(tt.line) if ok != tt.wantOK { t.Errorf("ok = %v, want %v", ok, tt.wantOK) } if !got.Equal(tt.want) { t.Errorf("got %v, want %v", got, tt.want) } }) } } func TestSafeKey(t *testing.T) { tests := []struct{ in, want string }{ {"Stand up meeting", "Stand-up-meeting"}, {"Hello_World", "Hello-World"}, {"special@#$chars!!", "specialchars"}, {"ALL_CAPS_123", "ALL-CAPS-123"}, } for _, tt := range tests { if got := safeKey(tt.in); got != tt.want { t.Errorf("safeKey(%q) = %q, want %q", tt.in, got, tt.want) } } } func TestFactKeyAndValue(t *testing.T) { e := Event{ Summary: "Team sync", Start: time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC), End: time.Date(2026, 7, 3, 15, 0, 0, 0, time.UTC), } if got, want := FactKey(e), "calendar_event_20260703_Team-sync"; got != want { t.Errorf("FactKey = %q, want %q", got, want) } if got, want := FactValue(e), "Team sync @ 14:00-15:00"; got != want { t.Errorf("FactValue = %q, want %q", got, want) } if got, want := KeyPrefixForDay(e.Start), "calendar_event_20260703"; got != want { t.Errorf("KeyPrefixForDay = %q, want %q", got, want) } if !strings.HasPrefix(FactKey(e), KeyPrefixForDay(e.Start)) { t.Error("FactKey must start with the day prefix the store range-scans on") } } func TestBusyAndOverlapping(t *testing.T) { base := time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC) events := []Event{ {Summary: "late", Start: base.Add(15 * time.Hour), End: base.Add(16 * time.Hour)}, {Summary: "early", Start: base.Add(9 * time.Hour), End: base.Add(10 * time.Hour)}, } if !Busy(events, base.Add(9*time.Hour+30*time.Minute)) { t.Error("should be busy inside the early event") } if Busy(events, base.Add(12*time.Hour)) { t.Error("should be free at noon") } // Half-open: the end instant is free. if Busy(events, base.Add(10*time.Hour)) { t.Error("the end instant should not count as busy") } got := Overlapping(events, base.Add(8*time.Hour), base.Add(11*time.Hour)) if len(got) != 1 || got[0].Summary != "early" { t.Fatalf("Overlapping = %+v", got) } all := Overlapping(events, base, base.AddDate(0, 0, 1)) if len(all) != 2 || all[0].Summary != "early" { t.Fatalf("Overlapping must sort by start: %+v", all) } } func TestSourceTrust(t *testing.T) { if ReadOnlySource(SourcePersonal) { t.Error("the personal calendar is the one maven may render to") } if !ReadOnlySource(SourceWork) { t.Error("the work calendar must be read-only") } if !ReadOnlySource(SourceAmbient) { t.Error("an ambient notification is not a writable calendar") } if AmbientConfidence >= 1.0 { t.Error("ambient events must be less trusted than a calendar read") } if len(Sources()) != 3 { t.Errorf("Sources() = %v", Sources()) } }