package calendartest import ( "strings" "testing" "time" calendar "amadan.net/oficina/calendar/internal/calendar" ) // quickNow is the clock every case below is read against: Wednesday // 2 September 2026, 10:00. Fixed, because "tomorrow" and "next Tuesday" // are only testable against a known today. var quickNow = time.Date(2026, 9, 2, 10, 0, 0, 0, time.UTC) func TestQuickAddReadsASentence(t *testing.T) { cases := []struct { name string text string title string location string start string end string allDay bool repeat string attendees []string }{ { name: "the one from the request", text: "Meeting with Dermot Lacey at 3pm in McCloskeys", title: "Meeting with Dermot Lacey", location: "McCloskeys", start: "2026-09-02T15:00", end: "2026-09-02T16:00", repeat: "none", }, { name: "an address becomes an attendee", text: "Lunch with doc@tito.io tomorrow at 1", title: "Lunch", start: "2026-09-03T13:00", end: "2026-09-03T14:00", repeat: "none", attendees: []string{"doc@tito.io"}, }, { name: "a range gives both ends", text: "Retro 3-4pm", title: "Retro", start: "2026-09-02T15:00", end: "2026-09-02T16:00", repeat: "none", }, { name: "a duration sets the end", text: "Dentist on Friday at 11am for 90 minutes", title: "Dentist", start: "2026-09-04T11:00", end: "2026-09-04T12:30", repeat: "none", }, { name: "an hour in words", text: "Review tomorrow at 2 for an hour", title: "Review", start: "2026-09-03T14:00", end: "2026-09-03T15:00", repeat: "none", }, { name: "crossing midnight rolls the end over", text: "Party 9pm to 1am", title: "Party", start: "2026-09-02T21:00", end: "2026-09-03T01:00", repeat: "none", }, { // A DATE on both ends, and the end is EXCLUSIVE like every // other range in this app. It used to write // "2027-03-12T00:00" twice, which is neither a date nor a // non-empty range: the validator refused it and quick add // silently bounced every all-day sentence to the long form. name: "all day takes a date and no clock", text: "Team offsite all day on 12 March", title: "Team offsite", start: "2027-03-12", end: "2027-03-13", allDay: true, repeat: "none", }, { name: "a run of weekdays is an all-day span", text: "london tues-thurs", title: "london", start: "2026-09-08", end: "2026-09-11", allDay: true, repeat: "none", }, { name: "an en dash reads the same as a hyphen", text: "Berlin mon–wed", title: "Berlin", start: "2026-09-07", end: "2026-09-10", allDay: true, repeat: "none", }, { name: "two words instead of a dash", text: "Conference Monday to Wednesday", title: "Conference", start: "2026-09-07", end: "2026-09-10", allDay: true, repeat: "none", }, { name: "a run of weekdays crossing the week boundary", text: "Cover sunday to tuesday", title: "Cover", start: "2026-09-06", end: "2026-09-09", allDay: true, repeat: "none", }, { name: "days sharing a month", text: "Holidays 12-14 March", title: "Holidays", start: "2027-03-12", end: "2027-03-15", allDay: true, repeat: "none", }, { name: "the month first", text: "Holidays March 12-14", title: "Holidays", start: "2027-03-12", end: "2027-03-15", allDay: true, repeat: "none", }, { name: "two whole dates, crossing a month", text: "Tour 30 September to 2 October", title: "Tour", start: "2026-09-30", end: "2026-10-03", allDay: true, repeat: "none", }, { name: "a numeric range", text: "Leave 12/10-14/10", title: "Leave", start: "2026-10-12", end: "2026-10-15", allDay: true, repeat: "none", }, { // A range with a clock in it is NOT all day. It is one // continuous block: the start time on the first day, the end // time on the last. name: "a range with times is one continuous block", text: "Summit Monday to Wednesday 9am-5pm", title: "Summit", start: "2026-09-07T09:00", end: "2026-09-09T17:00", repeat: "none", }, { name: "every weekday is the weekdays choice", text: "Standup every weekday at 9:30", title: "Standup", start: "2026-09-02T09:30", end: "2026-09-02T10:30", repeat: "weekdays", }, { name: "every Tuesday is weekly, on that Tuesday", text: "Sync every Tuesday at 4pm", title: "Sync", start: "2026-09-08T16:00", end: "2026-09-08T17:00", repeat: "weekly", }, { name: "a place with no time still reads as a place", text: "Coffee in the Fumbally at 11", title: "Coffee", location: "the Fumbally", start: "2026-09-02T11:00", end: "2026-09-02T12:00", repeat: "none", }, { name: "24 hour time needs no meridiem", text: "Deploy at 15:45", title: "Deploy", start: "2026-09-02T15:45", end: "2026-09-02T16:45", repeat: "none", }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got := calendar.ParseQuick(c.text, quickNow, "UTC") if got.Title != c.title { t.Errorf("title = %q, want %q", got.Title, c.title) } if got.Location != c.location { t.Errorf("location = %q, want %q", got.Location, c.location) } if got.StartLocal != c.start { t.Errorf("start = %q, want %q", got.StartLocal, c.start) } if got.EndLocal != c.end { t.Errorf("end = %q, want %q", got.EndLocal, c.end) } if got.AllDay != c.allDay { t.Errorf("allDay = %v, want %v", got.AllDay, c.allDay) } if got.Repeat != c.repeat { t.Errorf("repeat = %q, want %q", got.Repeat, c.repeat) } if strings.Join(got.Attendees, ",") != strings.Join(c.attendees, ",") { t.Errorf("attendees = %v, want %v", got.Attendees, c.attendees) } }) } } // "at" is ambiguous and time has to win it. Read places first and every // event anybody types lands in a place called "3pm". func TestQuickAddPrefersATimeOverAPlaceForAt(t *testing.T) { got := calendar.ParseQuick("Standup at 9am", quickNow, "UTC") if got.Location != "" { t.Errorf("location = %q, want empty — the time was read as a place", got.Location) } if got.StartLocal != "2026-09-02T09:00" { t.Errorf("start = %q, want 2026-09-02T09:00", got.StartLocal) } } // Every default is said out loud. A guess the person cannot see is a // guess they will not catch. func TestQuickAddNamesWhatItAssumed(t *testing.T) { got := calendar.ParseQuick("Coffee", quickNow, "UTC") notes := strings.Join(got.Notes, " ") for _, want := range []string{"next half hour", "an hour", "today"} { if !strings.Contains(notes, want) { t.Errorf("the pane does not mention %q:\n%s", want, notes) } } if got.StartLocal != "2026-09-02T10:30" { t.Errorf("start = %q, want the next half hour 2026-09-02T10:30", got.StartLocal) } } // Attendees are recorded, never emailed, and the pane has to say so — // somebody who reads "invitee" and assumes a mail went out has been // misled by us, not by their own carelessness. func TestQuickAddSaysNobodyIsEmailed(t *testing.T) { got := calendar.ParseQuick("Lunch with doc@tito.io at 1pm", quickNow, "UTC") if len(got.Attendees) != 1 { t.Fatalf("attendees = %v, want one", got.Attendees) } if !strings.Contains(strings.Join(got.Notes, " "), "Nobody is emailed") { t.Errorf("the pane does not say nobody is emailed:\n%v", got.Notes) } } // A rule the picker cannot offer is NOT invented. It stays in the text // so it is visible, rather than becoming an RRULE nothing can re-edit. func TestQuickAddRefusesARepeatThePickerCannotShow(t *testing.T) { got := calendar.ParseQuick("Board every second Tuesday of the month at 10am", quickNow, "UTC") if got.Repeat != "none" && got.Repeat != "weekly" { t.Errorf("repeat = %q; an unsupported rule must not become something else", got.Repeat) } }