package calendartest import ( "strings" "testing" "time" calendar "amadan.net/oficina/calendar/internal/calendar" ) // expandLocal is the shape every case here wants: give it a rule and a // window in local dates, get back the occurrence keys. func expandLocal(t *testing.T, startLocal, endLocal, tz, rrule, exdates, fromDate, toDate string) []string { t.Helper() r, err := calendar.ParseRule(rrule) if err != nil { t.Fatalf("ParseRule(%q): %v", rrule, err) } from, err := calendar.ToUTC(fromDate, tz, true) if err != nil { t.Fatalf("from: %v", err) } to, err := calendar.ToUTC(toDate, tz, true) if err != nil { t.Fatalf("to: %v", err) } occs := calendar.Expand(startLocal, endLocal, tz, false, r, exdates, from, to) keys := make([]string, 0, len(occs)) for _, o := range occs { keys = append(keys, o.Local) } return keys } func TestExpandDailyAndWeekly(t *testing.T) { const tz = "Europe/Dublin" t.Run("daily", func(t *testing.T) { got := expandLocal(t, "2026-09-01T09:00", "2026-09-01T09:30", tz, "FREQ=DAILY", "", "2026-09-01", "2026-09-05") want := []string{ "2026-09-01T09:00", "2026-09-02T09:00", "2026-09-03T09:00", "2026-09-04T09:00", } assertKeys(t, got, want) }) t.Run("weekly on chosen days", func(t *testing.T) { // 1 September 2026 is a Tuesday. got := expandLocal(t, "2026-09-01T09:00", "2026-09-01T09:30", tz, "FREQ=WEEKLY;BYDAY=TU,TH", "", "2026-09-01", "2026-09-15") want := []string{ "2026-09-01T09:00", "2026-09-03T09:00", "2026-09-08T09:00", "2026-09-10T09:00", } assertKeys(t, got, want) }) t.Run("interval skips weeks", func(t *testing.T) { got := expandLocal(t, "2026-09-01T09:00", "2026-09-01T09:30", tz, "FREQ=WEEKLY;INTERVAL=2;BYDAY=TU", "", "2026-09-01", "2026-10-01") want := []string{"2026-09-01T09:00", "2026-09-15T09:00", "2026-09-29T09:00"} assertKeys(t, got, want) }) } // The 31st of a month that has no 31st is NO OCCURRENCE — never the // 30th, never the 28th. Clamping is the bug that puts a "last day of // the month" payment reminder on the wrong day eight times a year. func TestMonthlyOnThe31stSkipsShortMonths(t *testing.T) { got := expandLocal(t, "2026-01-31T09:00", "2026-01-31T09:30", "UTC", "FREQ=MONTHLY;BYMONTHDAY=31", "", "2026-01-01", "2026-08-01") // January, March, May and July have a 31st. February, April and // June do not, and are absent rather than clamped. want := []string{ "2026-01-31T09:00", "2026-03-31T09:00", "2026-05-31T09:00", "2026-07-31T09:00", } assertKeys(t, got, want) } // A fifth Friday exists in some months and not others, and "the last // Friday" is a different rule from "the fourth Friday". func TestMonthlyOrdinalWeekday(t *testing.T) { t.Run("second Friday", func(t *testing.T) { got := expandLocal(t, "2026-09-11T09:00", "2026-09-11T09:30", "UTC", "FREQ=MONTHLY;BYDAY=2FR", "", "2026-09-01", "2026-12-01") want := []string{"2026-09-11T09:00", "2026-10-09T09:00", "2026-11-13T09:00"} assertKeys(t, got, want) }) t.Run("last Friday is not the fourth", func(t *testing.T) { last := expandLocal(t, "2026-01-30T09:00", "2026-01-30T09:30", "UTC", "FREQ=MONTHLY;BYDAY=-1FR", "", "2026-01-01", "2026-04-01") fourth := expandLocal(t, "2026-01-23T09:00", "2026-01-23T09:30", "UTC", "FREQ=MONTHLY;BYDAY=4FR", "", "2026-01-01", "2026-04-01") if len(last) == 0 || len(fourth) == 0 { t.Fatalf("expected occurrences: last=%v fourth=%v", last, fourth) } // January 2026 has five Fridays, so the two rules disagree in // that month — which is the whole point of having both. if last[0] == fourth[0] { t.Errorf("last Friday and fourth Friday agree in January (%s); one of the rules is wrong", last[0]) } }) } // 29 February happens in leap years only. Rolling it into 1 March is a // different day, and a birthday on the wrong day is the bug. func TestYearlyOnLeapDaySkipsNonLeapYears(t *testing.T) { got := expandLocal(t, "2028-02-29T09:00", "2028-02-29T09:30", "UTC", "FREQ=YEARLY", "", "2028-01-01", "2037-01-01") want := []string{"2028-02-29T09:00", "2032-02-29T09:00", "2036-02-29T09:00"} assertKeys(t, got, want) } // COUNT bounds the GENERATED set, and EXDATE removes from it // afterwards. Filtering first and counting second hands a series more // occurrences than it was asked for — here, five instead of four. func TestCountIsAppliedBeforeExdate(t *testing.T) { got := expandLocal(t, "2026-09-01T09:00", "2026-09-01T09:30", "UTC", "FREQ=DAILY;COUNT=4", "2026-09-02T09:00", "2026-09-01", "2026-10-01") want := []string{"2026-09-01T09:00", "2026-09-03T09:00", "2026-09-04T09:00"} assertKeys(t, got, want) if len(got) == 4 { t.Error("EXDATE was filtered before COUNT was counted: the series produced a fifth day to replace the excluded one") } } // A daily 09:00 standup is at 09:00 on both sides of a DST boundary. // Stepping the UTC instant by 24 hours instead would move it by an // hour for half the year. func TestWallClockSurvivesDST(t *testing.T) { const tz = "Europe/Dublin" occs := expandLocal(t, "2026-10-23T09:00", "2026-10-23T09:30", tz, "FREQ=DAILY", "", "2026-10-23", "2026-10-29") for _, key := range occs { if !strings.HasSuffix(key, "T09:00") { t.Errorf("occurrence %q drifted off 09:00 across the DST boundary", key) } } if len(occs) != 6 { t.Errorf("got %d occurrences, want 6: %v", len(occs), occs) } } // A rule outside the subset is a signal to store it verbatim, never an // error that loses it. func TestForeignRulesAreReportedNotSwallowed(t *testing.T) { for _, rule := range []string{ "FREQ=MONTHLY;BYSETPOS=3;BYDAY=MO,TU,WE,TH,FR", "FREQ=YEARLY;BYWEEKNO=20", "FREQ=HOURLY", "FREQ=MONTHLY;BYMONTHDAY=-1", } { _, err := calendar.ParseRule(rule) if err == nil { t.Errorf("ParseRule(%q) accepted a rule outside the subset", rule) continue } if !calendar.IsForeignRule(err) { t.Errorf("ParseRule(%q) = %v, want a foreign-rule signal", rule, err) } } } func TestUntilBoundsTheSeries(t *testing.T) { got := expandLocal(t, "2026-09-01T09:00", "2026-09-01T09:30", "UTC", "FREQ=DAILY;UNTIL=20260903T235959Z", "", "2026-09-01", "2026-10-01") want := []string{"2026-09-01T09:00", "2026-09-02T09:00", "2026-09-03T09:00"} assertKeys(t, got, want) } // A window that starts mid-series must not renumber or shift anything: // the occurrence keys are the series' own, not the window's. func TestWindowDoesNotShiftOccurrences(t *testing.T) { got := expandLocal(t, "2026-09-01T09:00", "2026-09-01T09:30", "UTC", "FREQ=DAILY", "", "2026-09-10", "2026-09-13") want := []string{"2026-09-10T09:00", "2026-09-11T09:00", "2026-09-12T09:00"} assertKeys(t, got, want) } func TestExpansionIsCapped(t *testing.T) { r, err := calendar.ParseRule("FREQ=DAILY") if err != nil { t.Fatalf("ParseRule: %v", err) } from := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC).Unix() to := time.Date(2060, 1, 1, 0, 0, 0, 0, time.UTC).Unix() occs := calendar.Expand("2026-01-01T09:00", "2026-01-01T09:30", "UTC", false, r, "", from, to) if len(occs) > calendar.MaxOccurrencesPerSeries { t.Errorf("expanded %d occurrences, cap is %d", len(occs), calendar.MaxOccurrencesPerSeries) } } func assertKeys(t *testing.T, got, want []string) { t.Helper() if len(got) != len(want) { t.Fatalf("got %d occurrences %v, want %d %v", len(got), got, len(want), want) } for i := range want { if got[i] != want[i] { t.Errorf("occurrence %d = %q, want %q (all: %v)", i, got[i], want[i], got) } } }