| 1 | package calendartest |
| 2 | |
| 3 | import ( |
| 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. |
| 14 | var quickNow = time.Date(2026, 9, 2, 10, 0, 0, 0, time.UTC) |
| 15 | |
| 16 | func 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". |
| 240 | func 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. |
| 252 | func 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. |
| 268 | func 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. |
| 280 | func 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 | |