| 1 | package calendartest |
| 2 | |
| 3 | import ( |
| 4 | "testing" |
| 5 | "time" |
| 6 | |
| 7 | calendar "amadan.net/oficina/calendar/internal/calendar" |
| 8 | ) |
| 9 | |
| 10 | // The slot arithmetic is pure, so it is tested as arithmetic: a rule, a |
| 11 | // busy list, a clock, and the list that comes out. Everything here is |
| 12 | // in Europe/Dublin, on Monday 7 September 2026 — a plain week with no |
| 13 | // transition in it — except the two Sundays that are about nothing else. |
| 14 | const dublin = "Europe/Dublin" |
| 15 | |
| 16 | func zoneOf(t *testing.T, name string) *time.Location { |
| 17 | t.Helper() |
| 18 | loc, err := calendar.Zone(name) |
| 19 | if err != nil { |
| 20 | t.Fatalf("Zone(%q): %v", name, err) |
| 21 | } |
| 22 | return loc |
| 23 | } |
| 24 | |
| 25 | // mon is local midnight on Monday 7 September 2026 in Dublin. |
| 26 | func mon(t *testing.T) time.Time { return time.Date(2026, 9, 7, 0, 0, 0, 0, zoneOf(t, dublin)) } |
| 27 | |
| 28 | // at is hh:mm on the same local date as day, in day's zone. |
| 29 | func at(day time.Time, hh, mm int) time.Time { |
| 30 | return time.Date(day.Year(), day.Month(), day.Day(), hh, mm, 0, 0, day.Location()) |
| 31 | } |
| 32 | |
| 33 | // mondayRule offers 12:00–15:00 every Monday, in Dublin, a week ahead. |
| 34 | func mondayRule(dur, buffer int) calendar.SlotRule { |
| 35 | return calendar.SlotRule{ |
| 36 | DurationMin: dur, BufferMin: buffer, NoticeMin: 0, HorizonDays: 7, Zone: dublin, |
| 37 | Windows: []calendar.Window{{Weekday: time.Monday, StartMin: 12 * 60, EndMin: 15 * 60}}, |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | func starts(slots []calendar.Slot) []string { |
| 42 | out := make([]string, 0, len(slots)) |
| 43 | for _, s := range slots { |
| 44 | out = append(out, s.Local.Format("Mon 15:04")) |
| 45 | } |
| 46 | return out |
| 47 | } |
| 48 | |
| 49 | func eq(t *testing.T, got []string, want ...string) { |
| 50 | t.Helper() |
| 51 | if len(got) != len(want) { |
| 52 | t.Fatalf("slots = %v, want %v", got, want) |
| 53 | } |
| 54 | for i := range got { |
| 55 | if got[i] != want[i] { |
| 56 | t.Fatalf("slots = %v, want %v", got, want) |
| 57 | } |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | func busy(a, b time.Time) calendar.Busy { return calendar.Busy{StartUTC: a.Unix(), EndUTC: b.Unix()} } |
| 62 | |
| 63 | // compute runs ComputeSlots over one Monday, from a clock at 08:00. |
| 64 | func compute(t *testing.T, rule calendar.SlotRule, b []calendar.Busy, now, from, to time.Time) []calendar.Slot { |
| 65 | t.Helper() |
| 66 | slots, err := calendar.ComputeSlots(rule, b, now, from, to) |
| 67 | if err != nil { |
| 68 | t.Fatalf("ComputeSlots: %v", err) |
| 69 | } |
| 70 | return slots |
| 71 | } |
| 72 | |
| 73 | func TestSlotsStepByTheirOwnDuration(t *testing.T) { |
| 74 | m := mon(t) |
| 75 | got := compute(t, mondayRule(60, 0), nil, at(m, 8, 0), m, m.AddDate(0, 0, 1)) |
| 76 | eq(t, starts(got), "Mon 12:00", "Mon 13:00", "Mon 14:00") |
| 77 | for _, s := range got { |
| 78 | if s.EndUTC-s.StartUTC != 3600 { |
| 79 | t.Errorf("slot %v is %d seconds long, want 3600", s.Local, s.EndUTC-s.StartUTC) |
| 80 | } |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | func TestABusySpanRemovesEverySlotItTouches(t *testing.T) { |
| 85 | m := mon(t) |
| 86 | b := []calendar.Busy{busy(at(m, 13, 0), at(m, 13, 30))} |
| 87 | got := compute(t, mondayRule(60, 0), b, at(m, 8, 0), m, m.AddDate(0, 0, 1)) |
| 88 | eq(t, starts(got), "Mon 12:00", "Mon 14:00") |
| 89 | } |
| 90 | |
| 91 | // A buffer pads the SLOT, on both sides, before the overlap test. |
| 92 | func TestABufferPadsBothSidesOfTheSlot(t *testing.T) { |
| 93 | m := mon(t) |
| 94 | b := []calendar.Busy{busy(at(m, 13, 0), at(m, 13, 30))} |
| 95 | got := compute(t, mondayRule(60, 10), b, at(m, 8, 0), m, m.AddDate(0, 0, 1)) |
| 96 | eq(t, starts(got), "Mon 14:00") |
| 97 | } |
| 98 | |
| 99 | // Busy time ending five minutes before `from` is outside the window and |
| 100 | // still removes the first buffered slot: that is why BusyFor fetches a |
| 101 | // day either side. |
| 102 | func TestBusyEndingBeforeFromStillRemovesABufferedSlot(t *testing.T) { |
| 103 | m := mon(t) |
| 104 | b := []calendar.Busy{busy(at(m, 11, 50), at(m, 11, 55))} |
| 105 | got := compute(t, mondayRule(60, 10), b, at(m, 8, 0), at(m, 12, 0), m.AddDate(0, 0, 1)) |
| 106 | eq(t, starts(got), "Mon 13:00", "Mon 14:00") |
| 107 | } |
| 108 | |
| 109 | // NoticeMin 0 does not mean "bookable this second": the floor is ten |
| 110 | // minutes, so at 11:55 the 12:00 slot is gone. |
| 111 | func TestNoticeHasATenMinuteFloor(t *testing.T) { |
| 112 | if calendar.MinNotice != 10*time.Minute { |
| 113 | t.Fatalf("MinNotice = %v, want 10m", calendar.MinNotice) |
| 114 | } |
| 115 | m := mon(t) |
| 116 | got := compute(t, mondayRule(60, 0), nil, at(m, 11, 55), m, m.AddDate(0, 0, 1)) |
| 117 | eq(t, starts(got), "Mon 13:00", "Mon 14:00") |
| 118 | } |
| 119 | |
| 120 | // The horizon bounds the END of a slot, from `now` in the host's zone; |
| 121 | // notice bounds the start. At Monday 11:00 with two hours' notice and a |
| 122 | // seven-day horizon, this Monday keeps 13:00 and 14:00 and next Monday |
| 123 | // offers nothing: its 12:00 slot would end at 13:00, past 11:00 + 7d. |
| 124 | func TestNoticeAndHorizonBoundTheRun(t *testing.T) { |
| 125 | m := mon(t) |
| 126 | rule := mondayRule(60, 0) |
| 127 | rule.NoticeMin = 120 |
| 128 | got := compute(t, rule, nil, at(m, 11, 0), m, m.AddDate(0, 0, 14)) |
| 129 | eq(t, starts(got), "Mon 13:00", "Mon 14:00") |
| 130 | } |
| 131 | |
| 132 | // `to` bounds the START. A 23:00–24:00 window is offered when `to` is |
| 133 | // the next local midnight, even though the slot ENDS exactly at `to`. |
| 134 | func TestToBoundsTheStartNotTheEnd(t *testing.T) { |
| 135 | m := mon(t) |
| 136 | rule := mondayRule(60, 0) |
| 137 | rule.Windows = []calendar.Window{{Weekday: time.Monday, StartMin: 23 * 60, EndMin: 24 * 60}} |
| 138 | got := compute(t, rule, nil, at(m, 8, 0), m, m.AddDate(0, 0, 1)) |
| 139 | eq(t, starts(got), "Mon 23:00") |
| 140 | if end := time.Unix(got[0].EndUTC, 0).In(m.Location()); !end.Equal(m.AddDate(0, 0, 1)) { |
| 141 | t.Errorf("the 23:00 slot ends at %v, want the next midnight", end) |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | // Slots are computed in the HOST's zone and only rendered in the |
| 146 | // booker's: a Tokyo Monday morning is a Los Angeles Sunday afternoon. |
| 147 | func TestATokyoMondayIsALosAngelesSunday(t *testing.T) { |
| 148 | tokyo := zoneOf(t, "Asia/Tokyo") |
| 149 | day := time.Date(2026, 9, 7, 0, 0, 0, 0, tokyo) |
| 150 | rule := calendar.SlotRule{DurationMin: 60, HorizonDays: 7, Zone: "Asia/Tokyo", |
| 151 | Windows: []calendar.Window{{Weekday: time.Monday, StartMin: 9 * 60, EndMin: 10 * 60}}} |
| 152 | got := compute(t, rule, nil, at(day, 1, 0), day, day.AddDate(0, 0, 1)) |
| 153 | eq(t, starts(got), "Mon 09:00") |
| 154 | la := time.Unix(got[0].StartUTC, 0).In(zoneOf(t, "America/Los_Angeles")) |
| 155 | if la.Weekday() != time.Sunday || la.Hour() != 17 { |
| 156 | t.Errorf("Tokyo Monday 09:00 renders in Los Angeles as %v, want Sunday 17:00", la) |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | // Bad input is an error, never a list, and never a loop: a zero |
| 161 | // duration is the one that would spin forever inside the hold mutex. |
| 162 | func TestBadRulesAreErrorsNotLists(t *testing.T) { |
| 163 | m := mon(t) |
| 164 | for name, rule := range map[string]calendar.SlotRule{ |
| 165 | "zero duration": {DurationMin: 0, HorizonDays: 7, Zone: dublin, Windows: mondayRule(60, 0).Windows}, |
| 166 | "481 minutes": {DurationMin: 481, HorizonDays: 7, Zone: dublin, Windows: mondayRule(60, 0).Windows}, |
| 167 | "unknown zone": {DurationMin: 60, HorizonDays: 7, Zone: "Mars/Olympus_Mons", Windows: mondayRule(60, 0).Windows}, |
| 168 | "inverted window": {DurationMin: 60, HorizonDays: 7, Zone: dublin, Windows: []calendar.Window{{Weekday: time.Monday, StartMin: 15 * 60, EndMin: 12 * 60}}}, |
| 169 | "empty window": {DurationMin: 60, HorizonDays: 7, Zone: dublin, Windows: []calendar.Window{{Weekday: time.Monday, StartMin: 12 * 60, EndMin: 12 * 60}}}, |
| 170 | } { |
| 171 | slots, err := calendar.ComputeSlots(rule, nil, at(m, 8, 0), m, m.AddDate(0, 0, 1)) |
| 172 | if err == nil || slots != nil { |
| 173 | t.Errorf("%s: got %v, %v; want an error and no list", name, slots, err) |
| 174 | } |
| 175 | } |
| 176 | } |
| 177 | |