| 1 | package calendartest |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | "testing" |
| 6 | "time" |
| 7 | |
| 8 | calendar "amadan.net/oficina/calendar/internal/calendar" |
| 9 | ) |
| 10 | |
| 11 | // expandLocal is the shape every case here wants: give it a rule and a |
| 12 | // window in local dates, get back the occurrence keys. |
| 13 | func expandLocal(t *testing.T, startLocal, endLocal, tz, rrule, exdates, fromDate, toDate string) []string { |
| 14 | t.Helper() |
| 15 | r, err := calendar.ParseRule(rrule) |
| 16 | if err != nil { |
| 17 | t.Fatalf("ParseRule(%q): %v", rrule, err) |
| 18 | } |
| 19 | from, err := calendar.ToUTC(fromDate, tz, true) |
| 20 | if err != nil { |
| 21 | t.Fatalf("from: %v", err) |
| 22 | } |
| 23 | to, err := calendar.ToUTC(toDate, tz, true) |
| 24 | if err != nil { |
| 25 | t.Fatalf("to: %v", err) |
| 26 | } |
| 27 | occs := calendar.Expand(startLocal, endLocal, tz, false, r, exdates, from, to) |
| 28 | keys := make([]string, 0, len(occs)) |
| 29 | for _, o := range occs { |
| 30 | keys = append(keys, o.Local) |
| 31 | } |
| 32 | return keys |
| 33 | } |
| 34 | |
| 35 | func TestExpandDailyAndWeekly(t *testing.T) { |
| 36 | const tz = "Europe/Dublin" |
| 37 | |
| 38 | t.Run("daily", func(t *testing.T) { |
| 39 | got := expandLocal(t, "2026-09-01T09:00", "2026-09-01T09:30", tz, |
| 40 | "FREQ=DAILY", "", "2026-09-01", "2026-09-05") |
| 41 | want := []string{ |
| 42 | "2026-09-01T09:00", "2026-09-02T09:00", |
| 43 | "2026-09-03T09:00", "2026-09-04T09:00", |
| 44 | } |
| 45 | assertKeys(t, got, want) |
| 46 | }) |
| 47 | |
| 48 | t.Run("weekly on chosen days", func(t *testing.T) { |
| 49 | // 1 September 2026 is a Tuesday. |
| 50 | got := expandLocal(t, "2026-09-01T09:00", "2026-09-01T09:30", tz, |
| 51 | "FREQ=WEEKLY;BYDAY=TU,TH", "", "2026-09-01", "2026-09-15") |
| 52 | want := []string{ |
| 53 | "2026-09-01T09:00", "2026-09-03T09:00", |
| 54 | "2026-09-08T09:00", "2026-09-10T09:00", |
| 55 | } |
| 56 | assertKeys(t, got, want) |
| 57 | }) |
| 58 | |
| 59 | t.Run("interval skips weeks", func(t *testing.T) { |
| 60 | got := expandLocal(t, "2026-09-01T09:00", "2026-09-01T09:30", tz, |
| 61 | "FREQ=WEEKLY;INTERVAL=2;BYDAY=TU", "", "2026-09-01", "2026-10-01") |
| 62 | want := []string{"2026-09-01T09:00", "2026-09-15T09:00", "2026-09-29T09:00"} |
| 63 | assertKeys(t, got, want) |
| 64 | }) |
| 65 | } |
| 66 | |
| 67 | // The 31st of a month that has no 31st is NO OCCURRENCE — never the |
| 68 | // 30th, never the 28th. Clamping is the bug that puts a "last day of |
| 69 | // the month" payment reminder on the wrong day eight times a year. |
| 70 | func TestMonthlyOnThe31stSkipsShortMonths(t *testing.T) { |
| 71 | got := expandLocal(t, "2026-01-31T09:00", "2026-01-31T09:30", "UTC", |
| 72 | "FREQ=MONTHLY;BYMONTHDAY=31", "", "2026-01-01", "2026-08-01") |
| 73 | // January, March, May and July have a 31st. February, April and |
| 74 | // June do not, and are absent rather than clamped. |
| 75 | want := []string{ |
| 76 | "2026-01-31T09:00", "2026-03-31T09:00", |
| 77 | "2026-05-31T09:00", "2026-07-31T09:00", |
| 78 | } |
| 79 | assertKeys(t, got, want) |
| 80 | } |
| 81 | |
| 82 | // A fifth Friday exists in some months and not others, and "the last |
| 83 | // Friday" is a different rule from "the fourth Friday". |
| 84 | func TestMonthlyOrdinalWeekday(t *testing.T) { |
| 85 | t.Run("second Friday", func(t *testing.T) { |
| 86 | got := expandLocal(t, "2026-09-11T09:00", "2026-09-11T09:30", "UTC", |
| 87 | "FREQ=MONTHLY;BYDAY=2FR", "", "2026-09-01", "2026-12-01") |
| 88 | want := []string{"2026-09-11T09:00", "2026-10-09T09:00", "2026-11-13T09:00"} |
| 89 | assertKeys(t, got, want) |
| 90 | }) |
| 91 | |
| 92 | t.Run("last Friday is not the fourth", func(t *testing.T) { |
| 93 | last := expandLocal(t, "2026-01-30T09:00", "2026-01-30T09:30", "UTC", |
| 94 | "FREQ=MONTHLY;BYDAY=-1FR", "", "2026-01-01", "2026-04-01") |
| 95 | fourth := expandLocal(t, "2026-01-23T09:00", "2026-01-23T09:30", "UTC", |
| 96 | "FREQ=MONTHLY;BYDAY=4FR", "", "2026-01-01", "2026-04-01") |
| 97 | if len(last) == 0 || len(fourth) == 0 { |
| 98 | t.Fatalf("expected occurrences: last=%v fourth=%v", last, fourth) |
| 99 | } |
| 100 | // January 2026 has five Fridays, so the two rules disagree in |
| 101 | // that month — which is the whole point of having both. |
| 102 | if last[0] == fourth[0] { |
| 103 | t.Errorf("last Friday and fourth Friday agree in January (%s); one of the rules is wrong", last[0]) |
| 104 | } |
| 105 | }) |
| 106 | } |
| 107 | |
| 108 | // 29 February happens in leap years only. Rolling it into 1 March is a |
| 109 | // different day, and a birthday on the wrong day is the bug. |
| 110 | func TestYearlyOnLeapDaySkipsNonLeapYears(t *testing.T) { |
| 111 | got := expandLocal(t, "2028-02-29T09:00", "2028-02-29T09:30", "UTC", |
| 112 | "FREQ=YEARLY", "", "2028-01-01", "2037-01-01") |
| 113 | want := []string{"2028-02-29T09:00", "2032-02-29T09:00", "2036-02-29T09:00"} |
| 114 | assertKeys(t, got, want) |
| 115 | } |
| 116 | |
| 117 | // COUNT bounds the GENERATED set, and EXDATE removes from it |
| 118 | // afterwards. Filtering first and counting second hands a series more |
| 119 | // occurrences than it was asked for — here, five instead of four. |
| 120 | func TestCountIsAppliedBeforeExdate(t *testing.T) { |
| 121 | got := expandLocal(t, "2026-09-01T09:00", "2026-09-01T09:30", "UTC", |
| 122 | "FREQ=DAILY;COUNT=4", "2026-09-02T09:00", "2026-09-01", "2026-10-01") |
| 123 | want := []string{"2026-09-01T09:00", "2026-09-03T09:00", "2026-09-04T09:00"} |
| 124 | assertKeys(t, got, want) |
| 125 | if len(got) == 4 { |
| 126 | t.Error("EXDATE was filtered before COUNT was counted: the series produced a fifth day to replace the excluded one") |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | // A daily 09:00 standup is at 09:00 on both sides of a DST boundary. |
| 131 | // Stepping the UTC instant by 24 hours instead would move it by an |
| 132 | // hour for half the year. |
| 133 | func TestWallClockSurvivesDST(t *testing.T) { |
| 134 | const tz = "Europe/Dublin" |
| 135 | occs := expandLocal(t, "2026-10-23T09:00", "2026-10-23T09:30", tz, |
| 136 | "FREQ=DAILY", "", "2026-10-23", "2026-10-29") |
| 137 | for _, key := range occs { |
| 138 | if !strings.HasSuffix(key, "T09:00") { |
| 139 | t.Errorf("occurrence %q drifted off 09:00 across the DST boundary", key) |
| 140 | } |
| 141 | } |
| 142 | if len(occs) != 6 { |
| 143 | t.Errorf("got %d occurrences, want 6: %v", len(occs), occs) |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | // A rule outside the subset is a signal to store it verbatim, never an |
| 148 | // error that loses it. |
| 149 | func TestForeignRulesAreReportedNotSwallowed(t *testing.T) { |
| 150 | for _, rule := range []string{ |
| 151 | "FREQ=MONTHLY;BYSETPOS=3;BYDAY=MO,TU,WE,TH,FR", |
| 152 | "FREQ=YEARLY;BYWEEKNO=20", |
| 153 | "FREQ=HOURLY", |
| 154 | "FREQ=MONTHLY;BYMONTHDAY=-1", |
| 155 | } { |
| 156 | _, err := calendar.ParseRule(rule) |
| 157 | if err == nil { |
| 158 | t.Errorf("ParseRule(%q) accepted a rule outside the subset", rule) |
| 159 | continue |
| 160 | } |
| 161 | if !calendar.IsForeignRule(err) { |
| 162 | t.Errorf("ParseRule(%q) = %v, want a foreign-rule signal", rule, err) |
| 163 | } |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | func TestUntilBoundsTheSeries(t *testing.T) { |
| 168 | got := expandLocal(t, "2026-09-01T09:00", "2026-09-01T09:30", "UTC", |
| 169 | "FREQ=DAILY;UNTIL=20260903T235959Z", "", "2026-09-01", "2026-10-01") |
| 170 | want := []string{"2026-09-01T09:00", "2026-09-02T09:00", "2026-09-03T09:00"} |
| 171 | assertKeys(t, got, want) |
| 172 | } |
| 173 | |
| 174 | // A window that starts mid-series must not renumber or shift anything: |
| 175 | // the occurrence keys are the series' own, not the window's. |
| 176 | func TestWindowDoesNotShiftOccurrences(t *testing.T) { |
| 177 | got := expandLocal(t, "2026-09-01T09:00", "2026-09-01T09:30", "UTC", |
| 178 | "FREQ=DAILY", "", "2026-09-10", "2026-09-13") |
| 179 | want := []string{"2026-09-10T09:00", "2026-09-11T09:00", "2026-09-12T09:00"} |
| 180 | assertKeys(t, got, want) |
| 181 | } |
| 182 | |
| 183 | func TestExpansionIsCapped(t *testing.T) { |
| 184 | r, err := calendar.ParseRule("FREQ=DAILY") |
| 185 | if err != nil { |
| 186 | t.Fatalf("ParseRule: %v", err) |
| 187 | } |
| 188 | from := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC).Unix() |
| 189 | to := time.Date(2060, 1, 1, 0, 0, 0, 0, time.UTC).Unix() |
| 190 | occs := calendar.Expand("2026-01-01T09:00", "2026-01-01T09:30", "UTC", false, r, "", from, to) |
| 191 | if len(occs) > calendar.MaxOccurrencesPerSeries { |
| 192 | t.Errorf("expanded %d occurrences, cap is %d", len(occs), calendar.MaxOccurrencesPerSeries) |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | func assertKeys(t *testing.T, got, want []string) { |
| 197 | t.Helper() |
| 198 | if len(got) != len(want) { |
| 199 | t.Fatalf("got %d occurrences %v, want %d %v", len(got), got, len(want), want) |
| 200 | } |
| 201 | for i := range want { |
| 202 | if got[i] != want[i] { |
| 203 | t.Errorf("occurrence %d = %q, want %q (all: %v)", i, got[i], want[i], got) |
| 204 | } |
| 205 | } |
| 206 | } |
| 207 | |