package calendartest import ( "fmt" "strings" "testing" "time" calendar "amadan.net/oficina/calendar/internal/calendar" ) // w builds one window from "HH:MM" strings, so a table reads the way // a host would write it. func w(d time.Weekday, from, to string) calendar.Window { return calendar.Window{Weekday: d, StartMin: mins(from), EndMin: mins(to)} } func mins(hhmm string) int { t, err := time.Parse("15:04", hhmm) if err != nil { panic(err) } return t.Hour()*60 + t.Minute() } func allDays(from, to string) []calendar.Window { var out []calendar.Window for d := time.Sunday; d <= time.Saturday; d++ { out = append(out, w(d, from, to)) } return out } func weekdaysOnly(from, to string) []calendar.Window { var out []calendar.Window for d := time.Monday; d <= time.Friday; d++ { out = append(out, w(d, from, to)) } return out } // windowsText prints windows one per line so a failing case shows the // whole shape rather than the first differing struct. func windowsText(ws []calendar.Window) string { var b strings.Builder for _, x := range ws { fmt.Fprintf(&b, "%s %02d:%02d-%02d:%02d\n", x.Weekday.String()[:3], x.StartMin/60, x.StartMin%60, x.EndMin/60, x.EndMin%60) } return b.String() } func TestAvailabilityReadsTheSentence(t *testing.T) { cases := []struct { text string want []calendar.Window }{ // Every example in spec §3.1. {"mondays between 12–3, tuesdays 12–2, 5-7", []calendar.Window{ w(time.Monday, "12:00", "15:00"), w(time.Tuesday, "12:00", "14:00"), w(time.Tuesday, "17:00", "19:00")}}, {"mon-wed 9-5", []calendar.Window{ w(time.Monday, "09:00", "17:00"), w(time.Tuesday, "09:00", "17:00"), w(time.Wednesday, "09:00", "17:00")}}, {"weekdays 10am to 4pm", weekdaysOnly("10:00", "16:00")}, {"every day 8–6", allDays("08:00", "18:00")}, {"saturday 10:30–13:00; sunday 14-16", []calendar.Window{ w(time.Sunday, "14:00", "16:00"), w(time.Saturday, "10:30", "13:00")}}, // Day parts. {"weekends 10-12", []calendar.Window{w(time.Sunday, "10:00", "12:00"), w(time.Saturday, "10:00", "12:00")}}, {"daily 9-10", allDays("09:00", "10:00")}, {"all days 9-10", allDays("09:00", "10:00")}, {"every tuesday 12-2", []calendar.Window{w(time.Tuesday, "12:00", "14:00")}}, {"tues 12 to 2", []calendar.Window{w(time.Tuesday, "12:00", "14:00")}}, {"Thurs 9-10", []calendar.Window{w(time.Thursday, "09:00", "10:00")}}, {"monday to friday 9-10", weekdaysOnly("09:00", "10:00")}, // A weekday range walks forward modulo 7, never backwards. {"sun-tue 9-10", []calendar.Window{ w(time.Sunday, "09:00", "10:00"), w(time.Monday, "09:00", "10:00"), w(time.Tuesday, "09:00", "10:00")}}, {"fri-mon 9-10", []calendar.Window{ 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")}}, // Time ranges: every separator, filler, three ranges in a clause. {"Friday 9am-12pm, 1pm-3pm and 4-5", []calendar.Window{ w(time.Friday, "09:00", "12:00"), w(time.Friday, "13:00", "15:00"), w(time.Friday, "16:00", "17:00")}}, {"monday from 9 until 10", []calendar.Window{w(time.Monday, "09:00", "10:00")}}, {"monday 9 till 10", []calendar.Window{w(time.Monday, "09:00", "10:00")}}, {"monday 9—10", []calendar.Window{w(time.Monday, "09:00", "10:00")}}, {"monday at 9-10 and 11-12", []calendar.Window{w(time.Monday, "09:00", "10:00"), w(time.Monday, "11:00", "12:00")}}, // Meridiem: one end's am/pm applies to both; none means the // working-day rule on the HOUR component, whatever the minutes. {"monday 9pm-11", []calendar.Window{w(time.Monday, "21:00", "23:00")}}, {"monday 9-11am", []calendar.Window{w(time.Monday, "09:00", "11:00")}}, {"tuesdays 12:30-2:30", []calendar.Window{w(time.Tuesday, "12:30", "14:30")}}, {"monday 7:30-8:30pm", []calendar.Window{w(time.Monday, "19:30", "20:30")}}, {"monday 7:45am-8:15", []calendar.Window{w(time.Monday, "07:45", "08:15")}}, {"monday 12am-1am", []calendar.Window{w(time.Monday, "00:00", "01:00")}}, {"monday 11am-12pm", []calendar.Window{w(time.Monday, "11:00", "12:00")}}, // Midnight, both ends. {"monday 0-1am", []calendar.Window{w(time.Monday, "00:00", "01:00")}}, {"monday 22:00-24:00", []calendar.Window{{Weekday: time.Monday, StartMin: 1320, EndMin: 1440}}}, {"monday 13-24", []calendar.Window{{Weekday: time.Monday, StartMin: 780, EndMin: 1440}}}, // Merging: overlapping and touching become one; output is sorted // Sunday-first, then by start. {"monday 9-12, monday 10-13", []calendar.Window{w(time.Monday, "09:00", "13:00")}}, {"monday 9-12, 12-1", []calendar.Window{w(time.Monday, "09:00", "13:00")}}, {"monday 12-1, 9-10", []calendar.Window{w(time.Monday, "09:00", "10:00"), w(time.Monday, "12:00", "13:00")}}, {"tuesday 9-10, monday 9-10", []calendar.Window{w(time.Monday, "09:00", "10:00"), w(time.Tuesday, "09:00", "10:00")}}, // Inheritance runs across more than one clause. {"weekdays 9-10; 11-12; 2-3", append(append(weekdaysOnly("09:00", "10:00"), weekdaysOnly("11:00", "12:00")...), weekdaysOnly("14:00", "15:00")...)}, } for _, c := range cases { t.Run(c.text, func(t *testing.T) { got, problems := calendar.ParseAvailability(c.text) if len(problems) != 0 { t.Errorf("problems = %q, want none", problems) } // The tables above are written in reading order; the parser // promises Sunday-first then by start, so normalise the // expectation the same way. MergeWindows has its own test. want := calendar.MergeWindows(c.want) if windowsText(got) != windowsText(want) { t.Errorf("windows:\n%s\nwant:\n%s", windowsText(got), windowsText(want)) } }) } } func TestAvailabilityRefusesWhatItCannotRead(t *testing.T) { cases := []struct{ text, quoted string }{ {"mondays 10pm-2", "10pm-2"}, // pm applies to both; 22:00–14:00 is inverted {"mondays 12-3, whenever", "whenever"}, {"12-3", "12-3"}, // no day, nothing to inherit {"9pm-11", "9pm-11"}, {"mondays 25-26", "25-26"}, {"mondays 9:60-10", "9:60-10"}, {"mondays 13pm-2pm", "13pm-2pm"}, {"mondays 0am-1am", "0am-1am"}, {"mondays 24:00-1", "24:00-1"}, // 24:00 only at the END {"mondays 22-24:30", "22-24:30"}, {"mondays 9-9", "9-9"}, // empty // The hour-component rule makes 7:45 the evening and leaves 8:15 // alone, so this is inverted. Not a bug: the spec's rule, and the // pane quotes it so the host writes "7:45am". {"mondays 7:45-8:15", "7:45-8:15"}, {"mondays 9-5 in the office", "in the office"}, {"mondays 9-5 mondays", "mondays"}, {"mondays", "mondays"}, // a day with no time {"mondays 9-5 100-200", "100-200"}, } for _, c := range cases { t.Run(c.text, func(t *testing.T) { _, problems := calendar.ParseAvailability(c.text) if len(problems) == 0 { t.Fatalf("no problems, want one quoting %q", c.quoted) } for _, p := range problems { if strings.Contains(p, `"`+c.quoted+`"`) { return } } t.Errorf("problems = %q, none quotes %q", problems, c.quoted) }) } } func TestAvailabilityReportsEveryProblem(t *testing.T) { windows, problems := calendar.ParseAvailability("mondays whenever, tuesdays sometime, wednesdays 9-10") if len(problems) != 2 { t.Errorf("problems = %q, want two", problems) } // What could be read is still returned, so the pane shows both. if want := []calendar.Window{w(time.Wednesday, "09:00", "10:00")}; windowsText(windows) != windowsText(want) { t.Errorf("windows:\n%s\nwant:\n%s", windowsText(windows), windowsText(want)) } if _, problems := calendar.ParseAvailability(" "); len(problems) != 0 { t.Errorf("blank text: problems = %q, want none", problems) } } func TestDescribeWindowsGroupsByDay(t *testing.T) { ws, problems := calendar.ParseAvailability("tuesdays 12–2, 5-7, mondays 9-10") if len(problems) != 0 { t.Fatalf("problems = %q", problems) } got := strings.Join(calendar.DescribeWindows(ws), "|") if want := "Monday 09:00–10:00|Tuesday 12:00–14:00, 17:00–19:00"; got != want { t.Errorf("got %q\nwant %q", got, want) } // Sunday first, and 24:00 printed as 24:00 rather than wrapped. ws, _ = calendar.ParseAvailability("saturday 22-24; sunday 9-10") got = strings.Join(calendar.DescribeWindows(ws), "|") if want := "Sunday 09:00–10:00|Saturday 22:00–24:00"; got != want { t.Errorf("got %q\nwant %q", got, want) } if lines := calendar.DescribeWindows(nil); len(lines) != 0 { t.Errorf("DescribeWindows(nil) = %q", lines) } } func TestMergeWindowsJoinsOverlapAndTouch(t *testing.T) { in := []calendar.Window{ w(time.Tuesday, "09:00", "10:00"), w(time.Monday, "12:00", "13:00"), w(time.Monday, "09:00", "12:00"), w(time.Monday, "11:00", "11:30"), w(time.Monday, "14:00", "15:00"), } got := calendar.MergeWindows(in) want := []calendar.Window{ w(time.Monday, "09:00", "13:00"), w(time.Monday, "14:00", "15:00"), w(time.Tuesday, "09:00", "10:00"), } if windowsText(got) != windowsText(want) { t.Errorf("merged:\n%s\nwant:\n%s", windowsText(got), windowsText(want)) } if in[0].Weekday != time.Tuesday { t.Error("MergeWindows reordered its input") } }