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 "strings"
5 "testing"
6 "time"
7
8 calendar "amadan.net/oficina/calendar/internal/calendar"
9)
10
11// quickNow is the clock every case below is read against: Wednesday
12// 2 September 2026, 10:00. Fixed, because "tomorrow" and "next Tuesday"
13// are only testable against a known today.
14var quickNow = time.Date(2026, 9, 2, 10, 0, 0, 0, time.UTC)
15
16func TestQuickAddReadsASentence(t *testing.T) {
17 cases := []struct {
18 name string
19 text string
20 title string
21 location string
22 start string
23 end string
24 allDay bool
25 repeat string
26 attendees []string
27 }{
28 {
29 name: "the one from the request",
30 text: "Meeting with Dermot Lacey at 3pm in McCloskeys",
31 title: "Meeting with Dermot Lacey",
32 location: "McCloskeys",
33 start: "2026-09-02T15:00",
34 end: "2026-09-02T16:00",
35 repeat: "none",
36 },
37 {
38 name: "an address becomes an attendee",
39 text: "Lunch with doc@tito.io tomorrow at 1",
40 title: "Lunch",
41 start: "2026-09-03T13:00",
42 end: "2026-09-03T14:00",
43 repeat: "none",
44 attendees: []string{"doc@tito.io"},
45 },
46 {
47 name: "a range gives both ends",
48 text: "Retro 3-4pm",
49 title: "Retro",
50 start: "2026-09-02T15:00",
51 end: "2026-09-02T16:00",
52 repeat: "none",
53 },
54 {
55 name: "a duration sets the end",
56 text: "Dentist on Friday at 11am for 90 minutes",
57 title: "Dentist",
58 start: "2026-09-04T11:00",
59 end: "2026-09-04T12:30",
60 repeat: "none",
61 },
62 {
63 name: "an hour in words",
64 text: "Review tomorrow at 2 for an hour",
65 title: "Review",
66 start: "2026-09-03T14:00",
67 end: "2026-09-03T15:00",
68 repeat: "none",
69 },
70 {
71 name: "crossing midnight rolls the end over",
72 text: "Party 9pm to 1am",
73 title: "Party",
74 start: "2026-09-02T21:00",
75 end: "2026-09-03T01:00",
76 repeat: "none",
77 },
78 {
79 // A DATE on both ends, and the end is EXCLUSIVE like every
80 // other range in this app. It used to write
81 // "2027-03-12T00:00" twice, which is neither a date nor a
82 // non-empty range: the validator refused it and quick add
83 // silently bounced every all-day sentence to the long form.
84 name: "all day takes a date and no clock",
85 text: "Team offsite all day on 12 March",
86 title: "Team offsite",
87 start: "2027-03-12",
88 end: "2027-03-13",
89 allDay: true,
90 repeat: "none",
91 },
92 {
93 name: "a run of weekdays is an all-day span",
94 text: "london tues-thurs",
95 title: "london",
96 start: "2026-09-08",
97 end: "2026-09-11",
98 allDay: true,
99 repeat: "none",
100 },
101 {
102 name: "an en dash reads the same as a hyphen",
103 text: "Berlin mon–wed",
104 title: "Berlin",
105 start: "2026-09-07",
106 end: "2026-09-10",
107 allDay: true,
108 repeat: "none",
109 },
110 {
111 name: "two words instead of a dash",
112 text: "Conference Monday to Wednesday",
113 title: "Conference",
114 start: "2026-09-07",
115 end: "2026-09-10",
116 allDay: true,
117 repeat: "none",
118 },
119 {
120 name: "a run of weekdays crossing the week boundary",
121 text: "Cover sunday to tuesday",
122 title: "Cover",
123 start: "2026-09-06",
124 end: "2026-09-09",
125 allDay: true,
126 repeat: "none",
127 },
128 {
129 name: "days sharing a month",
130 text: "Holidays 12-14 March",
131 title: "Holidays",
132 start: "2027-03-12",
133 end: "2027-03-15",
134 allDay: true,
135 repeat: "none",
136 },
137 {
138 name: "the month first",
139 text: "Holidays March 12-14",
140 title: "Holidays",
141 start: "2027-03-12",
142 end: "2027-03-15",
143 allDay: true,
144 repeat: "none",
145 },
146 {
147 name: "two whole dates, crossing a month",
148 text: "Tour 30 September to 2 October",
149 title: "Tour",
150 start: "2026-09-30",
151 end: "2026-10-03",
152 allDay: true,
153 repeat: "none",
154 },
155 {
156 name: "a numeric range",
157 text: "Leave 12/10-14/10",
158 title: "Leave",
159 start: "2026-10-12",
160 end: "2026-10-15",
161 allDay: true,
162 repeat: "none",
163 },
164 {
165 // A range with a clock in it is NOT all day. It is one
166 // continuous block: the start time on the first day, the end
167 // time on the last.
168 name: "a range with times is one continuous block",
169 text: "Summit Monday to Wednesday 9am-5pm",
170 title: "Summit",
171 start: "2026-09-07T09:00",
172 end: "2026-09-09T17:00",
173 repeat: "none",
174 },
175 {
176 name: "every weekday is the weekdays choice",
177 text: "Standup every weekday at 9:30",
178 title: "Standup",
179 start: "2026-09-02T09:30",
180 end: "2026-09-02T10:30",
181 repeat: "weekdays",
182 },
183 {
184 name: "every Tuesday is weekly, on that Tuesday",
185 text: "Sync every Tuesday at 4pm",
186 title: "Sync",
187 start: "2026-09-08T16:00",
188 end: "2026-09-08T17:00",
189 repeat: "weekly",
190 },
191 {
192 name: "a place with no time still reads as a place",
193 text: "Coffee in the Fumbally at 11",
194 title: "Coffee",
195 location: "the Fumbally",
196 start: "2026-09-02T11:00",
197 end: "2026-09-02T12:00",
198 repeat: "none",
199 },
200 {
201 name: "24 hour time needs no meridiem",
202 text: "Deploy at 15:45",
203 title: "Deploy",
204 start: "2026-09-02T15:45",
205 end: "2026-09-02T16:45",
206 repeat: "none",
207 },
208 }
209
210 for _, c := range cases {
211 t.Run(c.name, func(t *testing.T) {
212 got := calendar.ParseQuick(c.text, quickNow, "UTC")
213 if got.Title != c.title {
214 t.Errorf("title = %q, want %q", got.Title, c.title)
215 }
216 if got.Location != c.location {
217 t.Errorf("location = %q, want %q", got.Location, c.location)
218 }
219 if got.StartLocal != c.start {
220 t.Errorf("start = %q, want %q", got.StartLocal, c.start)
221 }
222 if got.EndLocal != c.end {
223 t.Errorf("end = %q, want %q", got.EndLocal, c.end)
224 }
225 if got.AllDay != c.allDay {
226 t.Errorf("allDay = %v, want %v", got.AllDay, c.allDay)
227 }
228 if got.Repeat != c.repeat {
229 t.Errorf("repeat = %q, want %q", got.Repeat, c.repeat)
230 }
231 if strings.Join(got.Attendees, ",") != strings.Join(c.attendees, ",") {
232 t.Errorf("attendees = %v, want %v", got.Attendees, c.attendees)
233 }
234 })
235 }
236}
237
238// "at" is ambiguous and time has to win it. Read places first and every
239// event anybody types lands in a place called "3pm".
240func TestQuickAddPrefersATimeOverAPlaceForAt(t *testing.T) {
241 got := calendar.ParseQuick("Standup at 9am", quickNow, "UTC")
242 if got.Location != "" {
243 t.Errorf("location = %q, want empty — the time was read as a place", got.Location)
244 }
245 if got.StartLocal != "2026-09-02T09:00" {
246 t.Errorf("start = %q, want 2026-09-02T09:00", got.StartLocal)
247 }
248}
249
250// Every default is said out loud. A guess the person cannot see is a
251// guess they will not catch.
252func TestQuickAddNamesWhatItAssumed(t *testing.T) {
253 got := calendar.ParseQuick("Coffee", quickNow, "UTC")
254 notes := strings.Join(got.Notes, " ")
255 for _, want := range []string{"next half hour", "an hour", "today"} {
256 if !strings.Contains(notes, want) {
257 t.Errorf("the pane does not mention %q:\n%s", want, notes)
258 }
259 }
260 if got.StartLocal != "2026-09-02T10:30" {
261 t.Errorf("start = %q, want the next half hour 2026-09-02T10:30", got.StartLocal)
262 }
263}
264
265// Attendees are recorded, never emailed, and the pane has to say so —
266// somebody who reads "invitee" and assumes a mail went out has been
267// misled by us, not by their own carelessness.
268func TestQuickAddSaysNobodyIsEmailed(t *testing.T) {
269 got := calendar.ParseQuick("Lunch with doc@tito.io at 1pm", quickNow, "UTC")
270 if len(got.Attendees) != 1 {
271 t.Fatalf("attendees = %v, want one", got.Attendees)
272 }
273 if !strings.Contains(strings.Join(got.Notes, " "), "Nobody is emailed") {
274 t.Errorf("the pane does not say nobody is emailed:\n%v", got.Notes)
275 }
276}
277
278// A rule the picker cannot offer is NOT invented. It stays in the text
279// so it is visible, rather than becoming an RRULE nothing can re-edit.
280func TestQuickAddRefusesARepeatThePickerCannotShow(t *testing.T) {
281 got := calendar.ParseQuick("Board every second Tuesday of the month at 10am", quickNow, "UTC")
282 if got.Repeat != "none" && got.Repeat != "weekly" {
283 t.Errorf("repeat = %q; an unsupported rule must not become something else", got.Repeat)
284 }
285}
286