oficina / calendar Public

Clone
git clone https://amadan.net/oficina/calendar

Plain git — no account needed to clone.

Download

Download this file

1package calendartest
2
3import (
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.
14const dublin = "Europe/Dublin"
15
16func 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.
26func 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.
29func 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.
34func 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
41func 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
49func 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
61func 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.
64func 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
73func 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
84func 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.
92func 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.
102func 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.
111func 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.
124func 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`.
134func 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.
147func 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.
162func 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