oficina / calendar Public

Clone
git clone https://amadan.net/oficina/calendar

Plain git — no account needed to clone.

Download

Download this file

1package calendartest
2
3import (
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.
13func 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
35func 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.
70func 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".
84func 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.
110func 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.
120func 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.
133func 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.
149func 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
167func 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.
176func 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
183func 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
196func 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