4f012e350c
parseDT stamped a zoned or floating DTSTART as UTC while every window around it is built in local time, so the two sides of every comparison were in different frames. On a +03 box a 22:00 local event parsed as 22:00Z, past the end of the local day, and the whole evening dropped out of the busy gate and the day plan. A 13:00 Moscow meeting read on a +04 box was recited at 17:00 next to its own printed 13:00. DTSTART now resolves three ways: a Z suffix is UTC, a TZID is loaded from the zone database, and a floating value is read in the caller's location. tzdata is embedded because the deploy image carries none, and a silent fallback to the box offset is the bug being fixed. FactKey and FactValue stamp the owner's clock, so the key date the store range-scans is the same day the plan asks for. FactSummary drops the time tail for callers that print the hour themselves. Found in review of #56 and #58.
266 lines
9.0 KiB
Go
266 lines
9.0 KiB
Go
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, time.UTC)
|
|
if !ok {
|
|
t.Fatal("expected a parsed event")
|
|
}
|
|
// 13:00 Moscow is 10:00Z. Reading it as 13:00Z is the bug that put the
|
|
// event three hours late in the day plan.
|
|
if !e.Start.Equal(time.Date(2026, 7, 3, 10, 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, time.UTC); ok {
|
|
t.Error("all-day event should be rejected")
|
|
}
|
|
}
|
|
|
|
func TestParseDT(t *testing.T) {
|
|
plus4 := time.FixedZone("+04", 4*60*60)
|
|
tests := []struct {
|
|
name string
|
|
line string
|
|
loc *time.Location
|
|
want time.Time
|
|
wantOK bool
|
|
}{
|
|
{"UTC", "DTEND:20260703T100000Z", plus4, time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC), true},
|
|
{"tzid", "DTSTART;TZID=Europe/Moscow:20260703T130000", plus4, time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC), true},
|
|
{"tzid quoted", `DTSTART;TZID="Europe/Moscow":20260703T130000`, plus4, time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC), true},
|
|
{"tzid with other params", "DTSTART;VALUE=DATE-TIME;TZID=Asia/Tokyo:20260703T130000", plus4, time.Date(2026, 7, 3, 4, 0, 0, 0, time.UTC), true},
|
|
// An unloadable zone falls back to the reader's own clock, not to UTC.
|
|
{"unknown tzid", "DTSTART;TZID=Mars/Olympus:20260703T130000", plus4, time.Date(2026, 7, 3, 13, 0, 0, 0, plus4), true},
|
|
// Floating: no Z, no TZID. Local to whoever reads it.
|
|
{"floating", "DTSTART:20260703T130000", plus4, time.Date(2026, 7, 3, 13, 0, 0, 0, plus4), true},
|
|
{"all-day", "DTSTART;VALUE=DATE:20260703", plus4, time.Time{}, false},
|
|
{"garbage", "DTSTART:garbage", plus4, time.Time{}, false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, ok := parseDT(tt.line, tt.loc)
|
|
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 := FactKeyIn(e, time.UTC), "calendar_event_20260703_Team-sync"; got != want {
|
|
t.Errorf("FactKey = %q, want %q", got, want)
|
|
}
|
|
if got, want := FactValueIn(e, time.UTC), "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(FactKeyIn(e, time.UTC), KeyPrefixForDay(e.Start)) {
|
|
t.Error("FactKey must start with the day prefix the store range-scans on")
|
|
}
|
|
}
|
|
|
|
// The key date and the printed time are the owner's, not the calendar
|
|
// server's. A 23:00 Moscow event read on a +04 box belongs to the next local
|
|
// day, and filing it under the Moscow day would hide it from the day plan the
|
|
// store range-scans for.
|
|
func TestFactKeyAndValueUseTheOwnersClock(t *testing.T) {
|
|
msk := time.FixedZone("MSK", 3*60*60)
|
|
plus4 := time.FixedZone("+04", 4*60*60)
|
|
e := Event{
|
|
Summary: "Late sync",
|
|
Start: time.Date(2026, 7, 3, 23, 30, 0, 0, msk),
|
|
End: time.Date(2026, 7, 4, 0, 30, 0, 0, msk),
|
|
}
|
|
if got, want := FactKeyIn(e, plus4), "calendar_event_20260704_Late-sync"; got != want {
|
|
t.Errorf("FactKeyIn = %q, want %q", got, want)
|
|
}
|
|
if got, want := FactValueIn(e, plus4), "Late sync @ 00:30-01:30"; got != want {
|
|
t.Errorf("FactValueIn = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestFactSummaryDropsTheTimeTail(t *testing.T) {
|
|
if got, want := FactSummary("Standup @ 14:00-14:30"), "Standup"; got != want {
|
|
t.Errorf("FactSummary = %q, want %q", got, want)
|
|
}
|
|
// Nothing that is not the exact tail FactValue writes is touched.
|
|
for _, in := range []string{"Coffee @ home", "Standup", "Standup @ 14:00-14:3", "Standup @ 1a:00-14:30"} {
|
|
if got := FactSummary(in); got != in {
|
|
t.Errorf("FactSummary(%q) = %q, want it unchanged", in, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Regression for the mirror image of the window bug: the window is local, so
|
|
// the event must be a real instant too. A 22:00 event stated in the poller's
|
|
// own zone used to parse as 22:00Z, which on a +03 box is past the end of the
|
|
// local day, and the whole evening dropped out of both the busy gate and the
|
|
// day plan.
|
|
func TestParseICalDayKeepsTheEveningInAZonedCalendar(t *testing.T) {
|
|
plus3 := time.FixedZone("+03", 3*60*60)
|
|
now := time.Date(2026, 8, 1, 12, 0, 0, 0, plus3)
|
|
body := []byte("BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
|
|
"DTSTART;TZID=Europe/Moscow:20260801T220000\nDTEND;TZID=Europe/Moscow:20260801T230000\n" +
|
|
"SUMMARY:Evening call\nEND:VEVENT\nEND:VCALENDAR")
|
|
|
|
events := ParseICalDay(body, now)
|
|
if len(events) != 1 {
|
|
t.Fatalf("got %d events, want the evening one", len(events))
|
|
}
|
|
if got := events[0].Start.In(plus3).Format("15:04"); got != "22:00" {
|
|
t.Errorf("start reads %s locally, want 22:00", got)
|
|
}
|
|
}
|
|
|
|
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())
|
|
}
|
|
}
|