Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}