package calendartest import ( "testing" "time" calendar "amadan.net/oficina/calendar/internal/calendar" ) // The slot arithmetic is pure, so it is tested as arithmetic: a rule, a // busy list, a clock, and the list that comes out. Everything here is // in Europe/Dublin, on Monday 7 September 2026 — a plain week with no // transition in it — except the two Sundays that are about nothing else. const dublin = "Europe/Dublin" func zoneOf(t *testing.T, name string) *time.Location { t.Helper() loc, err := calendar.Zone(name) if err != nil { t.Fatalf("Zone(%q): %v", name, err) } return loc } // mon is local midnight on Monday 7 September 2026 in Dublin. func mon(t *testing.T) time.Time { return time.Date(2026, 9, 7, 0, 0, 0, 0, zoneOf(t, dublin)) } // at is hh:mm on the same local date as day, in day's zone. func at(day time.Time, hh, mm int) time.Time { return time.Date(day.Year(), day.Month(), day.Day(), hh, mm, 0, 0, day.Location()) } // mondayRule offers 12:00–15:00 every Monday, in Dublin, a week ahead. func mondayRule(dur, buffer int) calendar.SlotRule { return calendar.SlotRule{ DurationMin: dur, BufferMin: buffer, NoticeMin: 0, HorizonDays: 7, Zone: dublin, Windows: []calendar.Window{{Weekday: time.Monday, StartMin: 12 * 60, EndMin: 15 * 60}}, } } func starts(slots []calendar.Slot) []string { out := make([]string, 0, len(slots)) for _, s := range slots { out = append(out, s.Local.Format("Mon 15:04")) } return out } func eq(t *testing.T, got []string, want ...string) { t.Helper() if len(got) != len(want) { t.Fatalf("slots = %v, want %v", got, want) } for i := range got { if got[i] != want[i] { t.Fatalf("slots = %v, want %v", got, want) } } } func busy(a, b time.Time) calendar.Busy { return calendar.Busy{StartUTC: a.Unix(), EndUTC: b.Unix()} } // compute runs ComputeSlots over one Monday, from a clock at 08:00. func compute(t *testing.T, rule calendar.SlotRule, b []calendar.Busy, now, from, to time.Time) []calendar.Slot { t.Helper() slots, err := calendar.ComputeSlots(rule, b, now, from, to) if err != nil { t.Fatalf("ComputeSlots: %v", err) } return slots } func TestSlotsStepByTheirOwnDuration(t *testing.T) { m := mon(t) got := compute(t, mondayRule(60, 0), nil, at(m, 8, 0), m, m.AddDate(0, 0, 1)) eq(t, starts(got), "Mon 12:00", "Mon 13:00", "Mon 14:00") for _, s := range got { if s.EndUTC-s.StartUTC != 3600 { t.Errorf("slot %v is %d seconds long, want 3600", s.Local, s.EndUTC-s.StartUTC) } } } func TestABusySpanRemovesEverySlotItTouches(t *testing.T) { m := mon(t) b := []calendar.Busy{busy(at(m, 13, 0), at(m, 13, 30))} got := compute(t, mondayRule(60, 0), b, at(m, 8, 0), m, m.AddDate(0, 0, 1)) eq(t, starts(got), "Mon 12:00", "Mon 14:00") } // A buffer pads the SLOT, on both sides, before the overlap test. func TestABufferPadsBothSidesOfTheSlot(t *testing.T) { m := mon(t) b := []calendar.Busy{busy(at(m, 13, 0), at(m, 13, 30))} got := compute(t, mondayRule(60, 10), b, at(m, 8, 0), m, m.AddDate(0, 0, 1)) eq(t, starts(got), "Mon 14:00") } // Busy time ending five minutes before `from` is outside the window and // still removes the first buffered slot: that is why BusyFor fetches a // day either side. func TestBusyEndingBeforeFromStillRemovesABufferedSlot(t *testing.T) { m := mon(t) b := []calendar.Busy{busy(at(m, 11, 50), at(m, 11, 55))} got := compute(t, mondayRule(60, 10), b, at(m, 8, 0), at(m, 12, 0), m.AddDate(0, 0, 1)) eq(t, starts(got), "Mon 13:00", "Mon 14:00") } // NoticeMin 0 does not mean "bookable this second": the floor is ten // minutes, so at 11:55 the 12:00 slot is gone. func TestNoticeHasATenMinuteFloor(t *testing.T) { if calendar.MinNotice != 10*time.Minute { t.Fatalf("MinNotice = %v, want 10m", calendar.MinNotice) } m := mon(t) got := compute(t, mondayRule(60, 0), nil, at(m, 11, 55), m, m.AddDate(0, 0, 1)) eq(t, starts(got), "Mon 13:00", "Mon 14:00") } // The horizon bounds the END of a slot, from `now` in the host's zone; // notice bounds the start. At Monday 11:00 with two hours' notice and a // seven-day horizon, this Monday keeps 13:00 and 14:00 and next Monday // offers nothing: its 12:00 slot would end at 13:00, past 11:00 + 7d. func TestNoticeAndHorizonBoundTheRun(t *testing.T) { m := mon(t) rule := mondayRule(60, 0) rule.NoticeMin = 120 got := compute(t, rule, nil, at(m, 11, 0), m, m.AddDate(0, 0, 14)) eq(t, starts(got), "Mon 13:00", "Mon 14:00") } // `to` bounds the START. A 23:00–24:00 window is offered when `to` is // the next local midnight, even though the slot ENDS exactly at `to`. func TestToBoundsTheStartNotTheEnd(t *testing.T) { m := mon(t) rule := mondayRule(60, 0) rule.Windows = []calendar.Window{{Weekday: time.Monday, StartMin: 23 * 60, EndMin: 24 * 60}} got := compute(t, rule, nil, at(m, 8, 0), m, m.AddDate(0, 0, 1)) eq(t, starts(got), "Mon 23:00") if end := time.Unix(got[0].EndUTC, 0).In(m.Location()); !end.Equal(m.AddDate(0, 0, 1)) { t.Errorf("the 23:00 slot ends at %v, want the next midnight", end) } } // Slots are computed in the HOST's zone and only rendered in the // booker's: a Tokyo Monday morning is a Los Angeles Sunday afternoon. func TestATokyoMondayIsALosAngelesSunday(t *testing.T) { tokyo := zoneOf(t, "Asia/Tokyo") day := time.Date(2026, 9, 7, 0, 0, 0, 0, tokyo) rule := calendar.SlotRule{DurationMin: 60, HorizonDays: 7, Zone: "Asia/Tokyo", Windows: []calendar.Window{{Weekday: time.Monday, StartMin: 9 * 60, EndMin: 10 * 60}}} got := compute(t, rule, nil, at(day, 1, 0), day, day.AddDate(0, 0, 1)) eq(t, starts(got), "Mon 09:00") la := time.Unix(got[0].StartUTC, 0).In(zoneOf(t, "America/Los_Angeles")) if la.Weekday() != time.Sunday || la.Hour() != 17 { t.Errorf("Tokyo Monday 09:00 renders in Los Angeles as %v, want Sunday 17:00", la) } } // Bad input is an error, never a list, and never a loop: a zero // duration is the one that would spin forever inside the hold mutex. func TestBadRulesAreErrorsNotLists(t *testing.T) { m := mon(t) for name, rule := range map[string]calendar.SlotRule{ "zero duration": {DurationMin: 0, HorizonDays: 7, Zone: dublin, Windows: mondayRule(60, 0).Windows}, "481 minutes": {DurationMin: 481, HorizonDays: 7, Zone: dublin, Windows: mondayRule(60, 0).Windows}, "unknown zone": {DurationMin: 60, HorizonDays: 7, Zone: "Mars/Olympus_Mons", Windows: mondayRule(60, 0).Windows}, "inverted window": {DurationMin: 60, HorizonDays: 7, Zone: dublin, Windows: []calendar.Window{{Weekday: time.Monday, StartMin: 15 * 60, EndMin: 12 * 60}}}, "empty window": {DurationMin: 60, HorizonDays: 7, Zone: dublin, Windows: []calendar.Window{{Weekday: time.Monday, StartMin: 12 * 60, EndMin: 12 * 60}}}, } { slots, err := calendar.ComputeSlots(rule, nil, at(m, 8, 0), m, m.AddDate(0, 0, 1)) if err == nil || slots != nil { t.Errorf("%s: got %v, %v; want an error and no list", name, slots, err) } } }