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 "fmt"
5 "strings"
6 "testing"
7 "time"
8
9 calendar "amadan.net/oficina/calendar/internal/calendar"
10)
11
12// w builds one window from "HH:MM" strings, so a table reads the way
13// a host would write it.
14func w(d time.Weekday, from, to string) calendar.Window {
15 return calendar.Window{Weekday: d, StartMin: mins(from), EndMin: mins(to)}
16}
17
18func mins(hhmm string) int {
19 t, err := time.Parse("15:04", hhmm)
20 if err != nil {
21 panic(err)
22 }
23 return t.Hour()*60 + t.Minute()
24}
25
26func allDays(from, to string) []calendar.Window {
27 var out []calendar.Window
28 for d := time.Sunday; d <= time.Saturday; d++ {
29 out = append(out, w(d, from, to))
30 }
31 return out
32}
33
34func weekdaysOnly(from, to string) []calendar.Window {
35 var out []calendar.Window
36 for d := time.Monday; d <= time.Friday; d++ {
37 out = append(out, w(d, from, to))
38 }
39 return out
40}
41
42// windowsText prints windows one per line so a failing case shows the
43// whole shape rather than the first differing struct.
44func windowsText(ws []calendar.Window) string {
45 var b strings.Builder
46 for _, x := range ws {
47 fmt.Fprintf(&b, "%s %02d:%02d-%02d:%02d\n", x.Weekday.String()[:3],
48 x.StartMin/60, x.StartMin%60, x.EndMin/60, x.EndMin%60)
49 }
50 return b.String()
51}
52
53func TestAvailabilityReadsTheSentence(t *testing.T) {
54 cases := []struct {
55 text string
56 want []calendar.Window
57 }{
58 // Every example in spec §3.1.
59 {"mondays between 12–3, tuesdays 12–2, 5-7", []calendar.Window{
60 w(time.Monday, "12:00", "15:00"), w(time.Tuesday, "12:00", "14:00"), w(time.Tuesday, "17:00", "19:00")}},
61 {"mon-wed 9-5", []calendar.Window{
62 w(time.Monday, "09:00", "17:00"), w(time.Tuesday, "09:00", "17:00"), w(time.Wednesday, "09:00", "17:00")}},
63 {"weekdays 10am to 4pm", weekdaysOnly("10:00", "16:00")},
64 {"every day 8–6", allDays("08:00", "18:00")},
65 {"saturday 10:30–13:00; sunday 14-16", []calendar.Window{
66 w(time.Sunday, "14:00", "16:00"), w(time.Saturday, "10:30", "13:00")}},
67
68 // Day parts.
69 {"weekends 10-12", []calendar.Window{w(time.Sunday, "10:00", "12:00"), w(time.Saturday, "10:00", "12:00")}},
70 {"daily 9-10", allDays("09:00", "10:00")},
71 {"all days 9-10", allDays("09:00", "10:00")},
72 {"every tuesday 12-2", []calendar.Window{w(time.Tuesday, "12:00", "14:00")}},
73 {"tues 12 to 2", []calendar.Window{w(time.Tuesday, "12:00", "14:00")}},
74 {"Thurs 9-10", []calendar.Window{w(time.Thursday, "09:00", "10:00")}},
75 {"monday to friday 9-10", weekdaysOnly("09:00", "10:00")},
76 // A weekday range walks forward modulo 7, never backwards.
77 {"sun-tue 9-10", []calendar.Window{
78 w(time.Sunday, "09:00", "10:00"), w(time.Monday, "09:00", "10:00"), w(time.Tuesday, "09:00", "10:00")}},
79 {"fri-mon 9-10", []calendar.Window{
80 w(time.Sunday, "09:00", "10:00"), w(time.Monday, "09:00", "10:00"), w(time.Friday, "09:00", "10:00"), w(time.Saturday, "09:00", "10:00")}},
81
82 // Time ranges: every separator, filler, three ranges in a clause.
83 {"Friday 9am-12pm, 1pm-3pm and 4-5", []calendar.Window{
84 w(time.Friday, "09:00", "12:00"), w(time.Friday, "13:00", "15:00"), w(time.Friday, "16:00", "17:00")}},
85 {"monday from 9 until 10", []calendar.Window{w(time.Monday, "09:00", "10:00")}},
86 {"monday 9 till 10", []calendar.Window{w(time.Monday, "09:00", "10:00")}},
87 {"monday 9—10", []calendar.Window{w(time.Monday, "09:00", "10:00")}},
88 {"monday at 9-10 and 11-12", []calendar.Window{w(time.Monday, "09:00", "10:00"), w(time.Monday, "11:00", "12:00")}},
89
90 // Meridiem: one end's am/pm applies to both; none means the
91 // working-day rule on the HOUR component, whatever the minutes.
92 {"monday 9pm-11", []calendar.Window{w(time.Monday, "21:00", "23:00")}},
93 {"monday 9-11am", []calendar.Window{w(time.Monday, "09:00", "11:00")}},
94 {"tuesdays 12:30-2:30", []calendar.Window{w(time.Tuesday, "12:30", "14:30")}},
95 {"monday 7:30-8:30pm", []calendar.Window{w(time.Monday, "19:30", "20:30")}},
96 {"monday 7:45am-8:15", []calendar.Window{w(time.Monday, "07:45", "08:15")}},
97 {"monday 12am-1am", []calendar.Window{w(time.Monday, "00:00", "01:00")}},
98 {"monday 11am-12pm", []calendar.Window{w(time.Monday, "11:00", "12:00")}},
99
100 // Midnight, both ends.
101 {"monday 0-1am", []calendar.Window{w(time.Monday, "00:00", "01:00")}},
102 {"monday 22:00-24:00", []calendar.Window{{Weekday: time.Monday, StartMin: 1320, EndMin: 1440}}},
103 {"monday 13-24", []calendar.Window{{Weekday: time.Monday, StartMin: 780, EndMin: 1440}}},
104
105 // Merging: overlapping and touching become one; output is sorted
106 // Sunday-first, then by start.
107 {"monday 9-12, monday 10-13", []calendar.Window{w(time.Monday, "09:00", "13:00")}},
108 {"monday 9-12, 12-1", []calendar.Window{w(time.Monday, "09:00", "13:00")}},
109 {"monday 12-1, 9-10", []calendar.Window{w(time.Monday, "09:00", "10:00"), w(time.Monday, "12:00", "13:00")}},
110 {"tuesday 9-10, monday 9-10", []calendar.Window{w(time.Monday, "09:00", "10:00"), w(time.Tuesday, "09:00", "10:00")}},
111
112 // Inheritance runs across more than one clause.
113 {"weekdays 9-10; 11-12; 2-3", append(append(weekdaysOnly("09:00", "10:00"), weekdaysOnly("11:00", "12:00")...), weekdaysOnly("14:00", "15:00")...)},
114 }
115 for _, c := range cases {
116 t.Run(c.text, func(t *testing.T) {
117 got, problems := calendar.ParseAvailability(c.text)
118 if len(problems) != 0 {
119 t.Errorf("problems = %q, want none", problems)
120 }
121 // The tables above are written in reading order; the parser
122 // promises Sunday-first then by start, so normalise the
123 // expectation the same way. MergeWindows has its own test.
124 want := calendar.MergeWindows(c.want)
125 if windowsText(got) != windowsText(want) {
126 t.Errorf("windows:\n%s\nwant:\n%s", windowsText(got), windowsText(want))
127 }
128 })
129 }
130}
131
132func TestAvailabilityRefusesWhatItCannotRead(t *testing.T) {
133 cases := []struct{ text, quoted string }{
134 {"mondays 10pm-2", "10pm-2"}, // pm applies to both; 22:00–14:00 is inverted
135 {"mondays 12-3, whenever", "whenever"},
136 {"12-3", "12-3"}, // no day, nothing to inherit
137 {"9pm-11", "9pm-11"},
138 {"mondays 25-26", "25-26"},
139 {"mondays 9:60-10", "9:60-10"},
140 {"mondays 13pm-2pm", "13pm-2pm"},
141 {"mondays 0am-1am", "0am-1am"},
142 {"mondays 24:00-1", "24:00-1"}, // 24:00 only at the END
143 {"mondays 22-24:30", "22-24:30"},
144 {"mondays 9-9", "9-9"}, // empty
145 // The hour-component rule makes 7:45 the evening and leaves 8:15
146 // alone, so this is inverted. Not a bug: the spec's rule, and the
147 // pane quotes it so the host writes "7:45am".
148 {"mondays 7:45-8:15", "7:45-8:15"},
149 {"mondays 9-5 in the office", "in the office"},
150 {"mondays 9-5 mondays", "mondays"},
151 {"mondays", "mondays"}, // a day with no time
152 {"mondays 9-5 100-200", "100-200"},
153 }
154 for _, c := range cases {
155 t.Run(c.text, func(t *testing.T) {
156 _, problems := calendar.ParseAvailability(c.text)
157 if len(problems) == 0 {
158 t.Fatalf("no problems, want one quoting %q", c.quoted)
159 }
160 for _, p := range problems {
161 if strings.Contains(p, `"`+c.quoted+`"`) {
162 return
163 }
164 }
165 t.Errorf("problems = %q, none quotes %q", problems, c.quoted)
166 })
167 }
168}
169
170func TestAvailabilityReportsEveryProblem(t *testing.T) {
171 windows, problems := calendar.ParseAvailability("mondays whenever, tuesdays sometime, wednesdays 9-10")
172 if len(problems) != 2 {
173 t.Errorf("problems = %q, want two", problems)
174 }
175 // What could be read is still returned, so the pane shows both.
176 if want := []calendar.Window{w(time.Wednesday, "09:00", "10:00")}; windowsText(windows) != windowsText(want) {
177 t.Errorf("windows:\n%s\nwant:\n%s", windowsText(windows), windowsText(want))
178 }
179 if _, problems := calendar.ParseAvailability(" "); len(problems) != 0 {
180 t.Errorf("blank text: problems = %q, want none", problems)
181 }
182}
183
184func TestDescribeWindowsGroupsByDay(t *testing.T) {
185 ws, problems := calendar.ParseAvailability("tuesdays 12–2, 5-7, mondays 9-10")
186 if len(problems) != 0 {
187 t.Fatalf("problems = %q", problems)
188 }
189 got := strings.Join(calendar.DescribeWindows(ws), "|")
190 if want := "Monday 09:00–10:00|Tuesday 12:00–14:00, 17:00–19:00"; got != want {
191 t.Errorf("got %q\nwant %q", got, want)
192 }
193
194 // Sunday first, and 24:00 printed as 24:00 rather than wrapped.
195 ws, _ = calendar.ParseAvailability("saturday 22-24; sunday 9-10")
196 got = strings.Join(calendar.DescribeWindows(ws), "|")
197 if want := "Sunday 09:00–10:00|Saturday 22:00–24:00"; got != want {
198 t.Errorf("got %q\nwant %q", got, want)
199 }
200 if lines := calendar.DescribeWindows(nil); len(lines) != 0 {
201 t.Errorf("DescribeWindows(nil) = %q", lines)
202 }
203}
204
205func TestMergeWindowsJoinsOverlapAndTouch(t *testing.T) {
206 in := []calendar.Window{
207 w(time.Tuesday, "09:00", "10:00"),
208 w(time.Monday, "12:00", "13:00"),
209 w(time.Monday, "09:00", "12:00"),
210 w(time.Monday, "11:00", "11:30"),
211 w(time.Monday, "14:00", "15:00"),
212 }
213 got := calendar.MergeWindows(in)
214 want := []calendar.Window{
215 w(time.Monday, "09:00", "13:00"),
216 w(time.Monday, "14:00", "15:00"),
217 w(time.Tuesday, "09:00", "10:00"),
218 }
219 if windowsText(got) != windowsText(want) {
220 t.Errorf("merged:\n%s\nwant:\n%s", windowsText(got), windowsText(want))
221 }
222 if in[0].Weekday != time.Tuesday {
223 t.Error("MergeWindows reordered its input")
224 }
225}
226