| 1 | package calendartest |
| 2 | |
| 3 | import ( |
| 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. |
| 14 | func w(d time.Weekday, from, to string) calendar.Window { |
| 15 | return calendar.Window{Weekday: d, StartMin: mins(from), EndMin: mins(to)} |
| 16 | } |
| 17 | |
| 18 | func 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 | |
| 26 | func 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 | |
| 34 | func 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. |
| 44 | func 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 | |
| 53 | func 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 | |
| 132 | func 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 | |
| 170 | func 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 | |
| 184 | func 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 | |
| 205 | func 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 | |