package calendartest import ( "testing" "time" calendar "amadan.net/oficina/calendar/internal/calendar" ) // Six defects in the recurrence engine, found by review of the booking // spec (its §4.1) and owned by it: a booking page decides FREE time from // what this engine says is busy, so a dropped occurrence is a // double-booking, not a cosmetic gap. // A daily 01:30 Dublin meeting whose 29 March instance falls in the // spring gap resolves to 02:30 that day. It must NOT start at 02:30 for // the rest of its life: advancement carries the ORIGINAL civil clock // (booking spec §4.1, r7 f1). func TestTheSpringGapDoesNotShiftLaterOccurrences(t *testing.T) { got := expandLocal(t, "2026-03-28T01:30", "2026-03-28T03:00", "Europe/Dublin", "FREQ=DAILY", "", "2026-03-28", "2026-04-01") want := []string{ "2026-03-28T01:30", "2026-03-29T02:30", // the gap day ALONE resolves forward, as clock.go documents "2026-03-30T01:30", "2026-03-31T01:30", } assertKeys(t, got, want) // And in September the instant is 01:30 IST, not 02:30. sept := expandLocal(t, "2026-03-28T01:30", "2026-03-28T03:00", "Europe/Dublin", "FREQ=DAILY", "", "2026-09-10", "2026-09-11") assertKeys(t, sept, []string{"2026-09-10T01:30"}) } // A two-day event across the spring transition is 47 elapsed hours and // TWO civil days. Dividing hours by 24 made it one, so every later // occurrence ended a day early (r4 B2). func TestAMultiDaySpanIsCountedInCivilDays(t *testing.T) { r, err := calendar.ParseRule("FREQ=WEEKLY") if err != nil { t.Fatal(err) } from, _ := calendar.ToUTC("2026-04-04", "Europe/Dublin", true) to, _ := calendar.ToUTC("2026-04-07", "Europe/Dublin", true) occs := calendar.Expand("2026-03-28T09:00", "2026-03-30T09:00", "Europe/Dublin", false, r, "", from, to) if len(occs) != 1 { t.Fatalf("got %d occurrences, want 1: %+v", len(occs), occs) } dublin, _ := time.LoadLocation("Europe/Dublin") end := time.Unix(occs[0].EndUTC, 0).In(dublin) if end.Format("2006-01-02T15:04") != "2026-04-06T09:00" { t.Errorf("the second occurrence ends %s, want 2026-04-06T09:00 (two civil days later)", end.Format("2006-01-02T15:04")) } } // Fortnightly on Monday and Wednesday from a Wednesday: the next active // week has BOTH days. The old search looked only seven days ahead, found // the intervening week ineligible, and jumped straight to the Wednesday // (r8 f1). func TestAFortnightlyRuleKeepsEveryWeekdayOfAnEligibleWeek(t *testing.T) { got := expandLocal(t, "2026-09-02T09:00", "2026-09-02T09:30", "UTC", "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE", "", "2026-09-01", "2026-10-01") want := []string{ "2026-09-02T09:00", "2026-09-14T09:00", "2026-09-16T09:00", "2026-09-28T09:00", "2026-09-30T09:00", } assertKeys(t, got, want) } // A rule the parser can read but the expander cannot faithfully run is // FOREIGN, not a silent misreading (r8 f2). Every Monday of the month is // a real rule; this subset does not carry it, and must say so rather than // expand it as "the 7th of every month". func TestCombinationsTheExpanderCannotRunAreForeign(t *testing.T) { for _, rule := range []string{ "FREQ=MONTHLY;BYDAY=MO", "FREQ=DAILY;BYDAY=MO", "FREQ=YEARLY;BYMONTHDAY=3", "FREQ=WEEKLY;BYMONTHDAY=3", "FREQ=DAILY;BYMONTHDAY=3", } { _, err := calendar.ParseRule(rule) if err == nil || !calendar.IsForeignRule(err) { t.Errorf("ParseRule(%q) = %v, want a foreign-rule signal", rule, err) } } // And the shapes the subset DOES carry still parse. for _, rule := range []string{ "FREQ=WEEKLY;BYDAY=MO,WE", "FREQ=MONTHLY;BYMONTHDAY=15", "FREQ=MONTHLY;BYDAY=2FR", "FREQ=MONTHLY", "FREQ=DAILY;INTERVAL=3", "FREQ=YEARLY", } { if _, err := calendar.ParseRule(rule); err != nil { t.Errorf("ParseRule(%q) refused a supported rule: %v", rule, err) } } } // Expand says when it did not finish. A forever-daily rule from 1700 // runs out of steps before 2026 and contributes nothing to a window it // should fill; the tolerant reader keeps rendering, the flag is for // anyone deciding free time (r4 B5). func TestExpandReportsWhenItRanOutOfSteps(t *testing.T) { r, _ := calendar.ParseRule("FREQ=DAILY") from := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC).Unix() to := time.Date(2026, 9, 2, 0, 0, 0, 0, time.UTC).Unix() _, complete := calendar.ExpandComplete("1700-01-01T09:00", "1700-01-01T09:30", "UTC", false, r, "", from, to) if complete { t.Error("a 1700 daily rule expanded to 2026 reported complete") } _, complete = calendar.ExpandComplete("2026-01-01T09:00", "2026-01-01T09:30", "UTC", false, r, "", from, to) if !complete { t.Error("an ordinary daily rule reported incomplete") } } // WKST decides which week a Sunday and a Monday share. Fortnightly on // Sunday and Monday from a Sunday: under the RFC's Monday default, that // Sunday's Monday is in the NEXT week, so the next eligible pair is 20 // and 21 September, and 7 September is not an occurrence (code review // finding 2). func TestWKSTDecidesTheWeekAnIntervalCounts(t *testing.T) { got := expandLocal(t, "2026-09-06T09:00", "2026-09-06T09:30", "UTC", "FREQ=WEEKLY;INTERVAL=2;BYDAY=SU,MO;WKST=MO", "", "2026-09-01", "2026-09-30") assertKeys(t, got, []string{"2026-09-06T09:00", "2026-09-14T09:00", "2026-09-20T09:00", "2026-09-28T09:00"}) // The same rule with Sunday weeks keeps Sunday and Monday together. got = expandLocal(t, "2026-09-06T09:00", "2026-09-06T09:30", "UTC", "FREQ=WEEKLY;INTERVAL=2;BYDAY=SU,MO;WKST=SU", "", "2026-09-01", "2026-09-30") assertKeys(t, got, []string{"2026-09-06T09:00", "2026-09-07T09:00", "2026-09-20T09:00", "2026-09-21T09:00"}) } // The output cap is not completion: a window that needs more than the // cap is reported incomplete, so a strict reader does not take a // truncated month as the whole of it. func TestTheOutputCapIsNotCompletion(t *testing.T) { r, _ := calendar.ParseRule("FREQ=DAILY") from := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC).Unix() to := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC).Unix() _, complete := calendar.ExpandComplete("2026-01-01T09:00", "2026-01-01T09:30", "UTC", false, r, "", from, to) if complete { t.Error("a window past the output cap reported complete") } }