cron_test.gno
8.08 Kb · 288 lines
1package cron
2
3import (
4 "testing"
5 "time"
6)
7
8// at is shorthand for a UTC instant, so the tables below read as dates.
9func at(y int, mo time.Month, d, h, mi int) time.Time {
10 return time.Date(y, mo, d, h, mi, 0, 0, time.UTC)
11}
12
13func TestParseRejects(t *testing.T) {
14 cases := []struct {
15 name string
16 expr string
17 }{
18 {"empty", ""},
19 {"blank", " "},
20 {"four fields", "* * * *"},
21 {"six fields", "* * * * * *"},
22 {"minute out of range", "60 * * * *"},
23 {"hour out of range", "* 24 * * *"},
24 {"day zero", "* * 0 * *"},
25 {"month thirteen", "* * * 13 *"},
26 {"weekday eight", "* * * * 8"},
27 {"backwards range", "* * * * 5-1"},
28 {"zero step", "*/0 * * * *"},
29 {"negative step", "*/-2 * * * *"},
30 {"empty list item", "1,,2 * * * *"},
31 {"not a number", "abc * * * *"},
32 {"unknown name", "* * * smarch *"},
33 {"unknown macro", "@fortnightly"},
34 {"macro with fields", "@daily extra"},
35 }
36 for _, tc := range cases {
37 t.Run(tc.name, func(t *testing.T) {
38 if _, err := Parse(tc.expr); err == nil {
39 t.Fatalf("Parse(%q): want error, got nil", tc.expr)
40 }
41 })
42 }
43}
44
45func TestNext(t *testing.T) {
46 base := at(2026, time.March, 10, 9, 17)
47 cases := []struct {
48 name string
49 expr string
50 from time.Time
51 want time.Time
52 }{
53 {"every minute", "* * * * *", base, at(2026, time.March, 10, 9, 18)},
54 {"quarter hour", "*/15 * * * *", base, at(2026, time.March, 10, 9, 30)},
55 {"top of hour", "0 * * * *", base, at(2026, time.March, 10, 10, 0)},
56 {"list", "5,25,45 * * * *", base, at(2026, time.March, 10, 9, 25)},
57 {"step from value", "5/20 * * * *", base, at(2026, time.March, 10, 9, 25)},
58 {"range with step", "0-30/10 * * * *", base, at(2026, time.March, 10, 9, 20)},
59 {"workday morning", "30 8 * * 1-5", base, at(2026, time.March, 11, 8, 30)},
60 {"weekend only", "0 12 * * sat,sun", base, at(2026, time.March, 14, 12, 0)},
61 {"month list", "0 0 1 jan,jul *", base, at(2026, time.July, 1, 0, 0)},
62 {"end of month", "0 0 31 * *", base, at(2026, time.March, 31, 0, 0)},
63 {"leap day", "0 0 29 2 *", base, at(2028, time.February, 29, 0, 0)},
64 {"next minute is exclusive", "17 9 * * *", base, at(2026, time.March, 11, 9, 17)},
65 {"seconds are discarded", "* * * * *", base.Add(42 * time.Second), at(2026, time.March, 10, 9, 18)},
66 {"year rolls over", "0 0 1 1 *", base, at(2027, time.January, 1, 0, 0)},
67 }
68 for _, tc := range cases {
69 t.Run(tc.name, func(t *testing.T) {
70 s, err := Parse(tc.expr)
71 if err != nil {
72 t.Fatalf("Parse(%q): %v", tc.expr, err)
73 }
74 got, ok := s.Next(tc.from)
75 if !ok {
76 t.Fatalf("Next(%q): no match within the horizon", tc.expr)
77 }
78 if !got.Equal(tc.want) {
79 t.Errorf("Next(%q) from %v = %v, want %v", tc.expr, tc.from, got, tc.want)
80 }
81 })
82 }
83}
84
85// TestDayOrDay pins the one rule a cron parser is actually written for: when
86// both day fields are restricted the match is a UNION, not an intersection.
87func TestDayOrDay(t *testing.T) {
88 // The 13th of March 2026 is a Friday, so a naive AND would hide the bug.
89 // April 2026 separates them: Friday the 3rd comes before the 13th.
90 s, err := Parse("0 0 13 * 5")
91 if err != nil {
92 t.Fatal(err)
93 }
94 got, ok := s.Next(at(2026, time.March, 20, 0, 0))
95 if !ok {
96 t.Fatal("no match")
97 }
98 if want := at(2026, time.March, 27, 0, 0); !got.Equal(want) {
99 t.Errorf("first = %v, want %v (a Friday, not the 13th)", got, want)
100 }
101 got, ok = s.Next(at(2026, time.April, 8, 0, 0))
102 if !ok {
103 t.Fatal("no match")
104 }
105 if want := at(2026, time.April, 10, 0, 0); !got.Equal(want) {
106 t.Errorf("second = %v, want %v", got, want)
107 }
108}
109
110func TestDayFieldRestriction(t *testing.T) {
111 cases := []struct {
112 name string
113 expr string
114 from time.Time
115 want time.Time
116 }{
117 // Day-of-week alone: every day-of-month qualifies, so it is an AND
118 // with a field that matches everything.
119 {"dow only", "0 0 * * 3", at(2026, time.March, 10, 0, 0), at(2026, time.March, 11, 0, 0)},
120 // Day-of-month alone, same reasoning mirrored.
121 {"dom only", "0 0 15 * *", at(2026, time.March, 10, 0, 0), at(2026, time.March, 15, 0, 0)},
122 // Neither restricted: the next midnight.
123 {"neither", "0 0 * * *", at(2026, time.March, 10, 0, 0), at(2026, time.March, 11, 0, 0)},
124 }
125 for _, tc := range cases {
126 t.Run(tc.name, func(t *testing.T) {
127 s, err := Parse(tc.expr)
128 if err != nil {
129 t.Fatal(err)
130 }
131 got, ok := s.Next(tc.from)
132 if !ok {
133 t.Fatal("no match")
134 }
135 if !got.Equal(tc.want) {
136 t.Errorf("Next(%q) = %v, want %v", tc.expr, got, tc.want)
137 }
138 })
139 }
140}
141
142// TestSundayIsSevenToo pins the historical alias: 0 and 7 are both Sunday, and
143// 5-7 therefore has to mean Friday through Sunday rather than running backwards.
144func TestSundayIsSevenToo(t *testing.T) {
145 from := at(2026, time.March, 10, 0, 0) // a Tuesday
146 zero, err := Parse("0 0 * * 0")
147 if err != nil {
148 t.Fatal(err)
149 }
150 seven, err := Parse("0 0 * * 7")
151 if err != nil {
152 t.Fatal(err)
153 }
154 a, _ := zero.Next(from)
155 b, _ := seven.Next(from)
156 if !a.Equal(b) {
157 t.Errorf("dow 0 gave %v, dow 7 gave %v", a, b)
158 }
159 if want := at(2026, time.March, 15, 0, 0); !a.Equal(want) {
160 t.Errorf("Sunday = %v, want %v", a, want)
161 }
162
163 wrap, err := Parse("0 0 * * 5-7")
164 if err != nil {
165 t.Fatalf("5-7 must span Friday to Sunday: %v", err)
166 }
167 for _, d := range []int{13, 14, 15} {
168 if !wrap.Matches(at(2026, time.March, d, 0, 0)) {
169 t.Errorf("5-7 should match March %d", d)
170 }
171 }
172 if wrap.Matches(at(2026, time.March, 16, 0, 0)) {
173 t.Error("5-7 should not match Monday")
174 }
175}
176
177func TestMacros(t *testing.T) {
178 cases := []struct {
179 macro string
180 same string
181 }{
182 {"@yearly", "0 0 1 1 *"},
183 {"@annually", "0 0 1 1 *"},
184 {"@monthly", "0 0 1 * *"},
185 {"@weekly", "0 0 * * 0"},
186 {"@daily", "0 0 * * *"},
187 {"@midnight", "0 0 * * *"},
188 {"@hourly", "0 * * * *"},
189 }
190 from := at(2026, time.March, 10, 9, 17)
191 for _, tc := range cases {
192 t.Run(tc.macro, func(t *testing.T) {
193 m, err := Parse(tc.macro)
194 if err != nil {
195 t.Fatal(err)
196 }
197 plain, err := Parse(tc.same)
198 if err != nil {
199 t.Fatal(err)
200 }
201 a, _ := m.Next(from)
202 b, _ := plain.Next(from)
203 if !a.Equal(b) {
204 t.Errorf("%s = %v, %s = %v", tc.macro, a, tc.same, b)
205 }
206 if m.Expr() != tc.macro {
207 t.Errorf("Expr() = %q, want the original %q", m.Expr(), tc.macro)
208 }
209 })
210 }
211}
212
213// TestImpossible: a schedule with no instant at all must say so rather than
214// loop. February never has a 30th.
215func TestImpossible(t *testing.T) {
216 s, err := Parse("0 0 30 2 *")
217 if err != nil {
218 t.Fatalf("Parse must accept it, only Next can know: %v", err)
219 }
220 if got, ok := s.Next(at(2026, time.March, 10, 0, 0)); ok {
221 t.Errorf("want no match, got %v", got)
222 }
223}
224
225func TestMatches(t *testing.T) {
226 s, err := Parse("30 8 * * 1-5")
227 if err != nil {
228 t.Fatal(err)
229 }
230 cases := []struct {
231 name string
232 at time.Time
233 want bool
234 }{
235 {"wednesday 8:30", at(2026, time.March, 11, 8, 30), true},
236 {"wednesday 8:31", at(2026, time.March, 11, 8, 31), false},
237 {"saturday 8:30", at(2026, time.March, 14, 8, 30), false},
238 {"seconds ignored", at(2026, time.March, 11, 8, 30).Add(59 * time.Second), true},
239 }
240 for _, tc := range cases {
241 t.Run(tc.name, func(t *testing.T) {
242 if got := s.Matches(tc.at); got != tc.want {
243 t.Errorf("Matches(%v) = %v, want %v", tc.at, got, tc.want)
244 }
245 })
246 }
247}
248
249func TestNextN(t *testing.T) {
250 s, err := Parse("0 */6 * * *")
251 if err != nil {
252 t.Fatal(err)
253 }
254 got := s.NextN(at(2026, time.March, 10, 9, 17), 4)
255 want := []time.Time{
256 at(2026, time.March, 10, 12, 0),
257 at(2026, time.March, 10, 18, 0),
258 at(2026, time.March, 11, 0, 0),
259 at(2026, time.March, 11, 6, 0),
260 }
261 if len(got) != len(want) {
262 t.Fatalf("got %d times, want %d", len(got), len(want))
263 }
264 for i := range want {
265 if !got[i].Equal(want[i]) {
266 t.Errorf("[%d] = %v, want %v", i, got[i], want[i])
267 }
268 }
269 if n := len(s.NextN(at(2026, time.March, 10, 9, 17), 0)); n != 0 {
270 t.Errorf("NextN(_, 0) returned %d times, want none", n)
271 }
272 impossible, err := Parse("0 0 30 2 *")
273 if err != nil {
274 t.Fatal(err)
275 }
276 if n := len(impossible.NextN(at(2026, time.March, 10, 0, 0), 3)); n != 0 {
277 t.Errorf("an impossible schedule yielded %d times", n)
278 }
279}
280
281func TestMustParsePanics(t *testing.T) {
282 defer func() {
283 if recover() == nil {
284 t.Error("MustParse must panic on a bad expression")
285 }
286 }()
287 MustParse("nonsense")
288}