1import { describe, it } from "@effect/vitest"
2import { assertFalse, assertTrue, deepStrictEqual, strictEqual, throws } from "@effect/vitest/utils"
3import { Cron, DateTime, Equal, Hash, HashSet, Option, Result } from "effect"
4
5const match = (input: Cron.Cron | string, date: DateTime.DateTime.Input) =>
6 Cron.match(Cron.isCron(input) ? input : Cron.parseUnsafe(input), date)
7
8const next = (input: Cron.Cron | string, after?: DateTime.DateTime.Input) =>
9 Cron.next(Cron.isCron(input) ? input : Cron.parseUnsafe(input), after)
10
11const prev = (input: Cron.Cron | string, after?: DateTime.DateTime.Input) =>
12 Cron.prev(Cron.isCron(input) ? input : Cron.parseUnsafe(input), after)
13
14const allDays = Array.from({ length: 31 }, (_, i) => i + 1)
15const allWeekdays = Array.from({ length: 7 }, (_, i) => i)
16
17describe("Cron", () => {
18 it("isCronParseError", () => {
19 const result = Cron.parse("")
20 assertTrue(Result.isFailure(result))
21 assertTrue(Cron.isCronParseError(result.failure))
22 assertFalse(Cron.isCronParseError(new Error("regular error")))
23 assertFalse(Cron.isCronParseError("not an error"))
24 })
25
26 it("CronParseError constructor", () => {
27 const error = new Cron.CronParseError({ message: "boom", input: "0 0 * * *" })
28 assertTrue(error instanceof Error)
29 assertTrue(Cron.isCronParseError(error))
30 deepStrictEqual(error._tag, "CronParseError")
31 deepStrictEqual(error.message, "boom")
32 deepStrictEqual(error.input, "0 0 * * *")
33 })
34
35 it("parse", () => {
36 // Explicit full weekday ranges are preserved for day-of-month / weekday OR semantics.
37 deepStrictEqual(
38 Cron.parse("0 4 8-14 * 0-6"),
39 Result.succeed(Cron.make({
40 minutes: [0],
41 hours: [4],
42 days: [8, 9, 10, 11, 12, 13, 14],
43 months: [],
44 weekdays: allWeekdays
45 }))
46 )
47 // At 00:00 on day-of-month 1 and 15 and on Wednesday.
48 deepStrictEqual(
49 Cron.parse("0 0 1,15 * 3"),
50 Result.succeed(Cron.make({
51 minutes: [0],
52 hours: [0],
53 days: [1, 15],
54 months: [],
55 weekdays: [3]
56 }))
57 )
58 // At 00:00 on day-of-month 1 and 15 and on Wednesday.
59 deepStrictEqual(
60 Cron.parse("23 0-20/2 * * *"),
61 Result.succeed(Cron.make({
62 minutes: [23],
63 hours: [0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20],
64 days: [],
65 months: [],
66 weekdays: []
67 }))
68 )
69
70 // Starting at minute 5 and then every 15 minutes.
71 deepStrictEqual(
72 Cron.parse("5/15 * * * *"),
73 Result.succeed(Cron.make({
74 minutes: [5, 20, 35, 50],
75 hours: [],
76 days: [],
77 months: [],
78 weekdays: []
79 }))
80 )
81
82 // At 00:00 on every day-of-month from 1 through 31 and on Monday.
83 deepStrictEqual(
84 Cron.parse("0 0 1/1 * 1"),
85 Result.succeed(Cron.make({
86 minutes: [0],
87 hours: [0],
88 days: allDays,
89 months: [],
90 weekdays: [1]
91 }))
92 )
93
94 deepStrictEqual(
95 Cron.parse("0 0 */1 * 1"),
96 Result.succeed(Cron.make({
97 minutes: [0],
98 hours: [0],
99 days: [],
100 months: [],
101 weekdays: [1]
102 }))
103 )
104
105 deepStrictEqual(
106 Cron.parse("0 0 1-31 * MON"),
107 Result.succeed(Cron.make({
108 minutes: [0],
109 hours: [0],
110 days: allDays,
111 months: [],
112 weekdays: [1]
113 }))
114 )
115
116 deepStrictEqual(
117 Cron.parse("0 0 1-31/1 * MON"),
118 Result.succeed(Cron.make({
119 minutes: [0],
120 hours: [0],
121 days: allDays,
122 months: [],
123 weekdays: [1]
124 }))
125 )
126
127 deepStrictEqual(
128 Cron.parse("0 0 1 * 0-6"),
129 Result.succeed(Cron.make({
130 minutes: [0],
131 hours: [0],
132 days: [1],
133 months: [],
134 weekdays: allWeekdays
135 }))
136 )
137
138 deepStrictEqual(
139 Cron.parse("0 0 1 * SUN-SAT"),
140 Result.succeed(Cron.make({
141 minutes: [0],
142 hours: [0],
143 days: [1],
144 months: [],
145 weekdays: allWeekdays
146 }))
147 )
148
149 deepStrictEqual(
150 Cron.parse("0\t0\t*\t*\t7"),
151 Result.succeed(Cron.make({
152 minutes: [0],
153 hours: [0],
154 days: [],
155 months: [],
156 weekdays: [0]
157 }))
158 )
159
160 const invalidMinuteTail = Cron.parse("0/1,60 * * * *")
161 assertTrue(Result.isFailure(invalidMinuteTail))
162 deepStrictEqual(invalidMinuteTail.failure.message, "Expected a value between 0 and 59")
163
164 const invalidDayTail = Cron.parse("0 0 1/1,32 * *")
165 assertTrue(Result.isFailure(invalidDayTail))
166 deepStrictEqual(invalidDayTail.failure.message, "Expected a value between 1 and 31")
167
168 const invalidMinuteStep = Cron.parse("*/60 * * * *")
169 assertTrue(Result.isFailure(invalidMinuteStep))
170 deepStrictEqual(invalidMinuteStep.failure.message, "Expected step value to be less than or equal to 59")
171
172 for (
173 const invalid of [
174 "0,,5 * * * *",
175 ",5 * * * *",
176 "-5 * * * *",
177 "0- * * * *",
178 "1e1 * * * *",
179 "0x10 * * * *",
180 "+5 * * * *"
181 ]
182 ) {
183 assertTrue(Result.isFailure(Cron.parse(invalid)))
184 }
185
186 const withNamedTimeZone = Cron.parseUnsafe("23 0-20/2 * * *", "Europe/Berlin")
187 assertTrue(Option.isSome(withNamedTimeZone.tz))
188 deepStrictEqual(DateTime.zoneToString(withNamedTimeZone.tz.value), "Europe/Berlin")
189
190 const withOffsetTimeZone = Cron.parseUnsafe("23 0-20/2 * * *", "+02:00")
191 assertTrue(Option.isSome(withOffsetTimeZone.tz))
192 deepStrictEqual(DateTime.zoneToString(withOffsetTimeZone.tz.value), "+02:00")
193
194 const withoutTimeZone = Cron.parseUnsafe("23 0-20/2 * * *")
195 assertTrue(Option.isNone(withoutTimeZone.tz))
196 })
197
198 it("parseUnsafe", () => {
199 throws(
200 () => Cron.parseUnsafe(""),
201 (error) => {
202 if (!Cron.isCronParseError(error)) {
203 throw new Error("Expected CronParseError")
204 }
205 deepStrictEqual(error.message, "Invalid number of segments in cron expression")
206 deepStrictEqual(error.input, "")
207 }
208 )
209 throws(
210 () => Cron.parseUnsafe("0 0 4 8-14 * *", ""),
211 (error) => {
212 if (!Cron.isCronParseError(error)) {
213 throw new Error("Expected CronParseError")
214 }
215 deepStrictEqual(error.message, "Invalid time zone in cron expression")
216 deepStrictEqual(error.input, "")
217 }
218 )
219 })
220
221 it("format preserves compact cron syntax", () => {
222 strictEqual(
223 Cron.format(Cron.parseUnsafe("23 0-20/2 * * 0", "Europe/Berlin")),
224 "23 0-20/2 * * 0"
225 )
226 })
227
228 it("format can include the default seconds field", () => {
229 strictEqual(
230 Cron.format(Cron.parseUnsafe("23 0-20/2 * * 0"), { includeSeconds: true }),
231 "0 23 0-20/2 * * 0"
232 )
233 })
234
235 it("format compacts multiple runs within a field", () => {
236 strictEqual(
237 Cron.format(Cron.make({
238 minutes: [0, 1, 2, 10, 20, 30],
239 hours: [],
240 days: [],
241 months: [],
242 weekdays: []
243 })),
244 "0-2,10-30/10 * * * *"
245 )
246 })
247
248 it("format preserves non-uniform values", () => {
249 strictEqual(
250 Cron.format(Cron.make({
251 minutes: [1, 5, 11],
252 hours: [],
253 days: [],
254 months: [],
255 weekdays: []
256 })),
257 "1,5,11 * * * *"
258 )
259 })
260
261 it("format handles default, non-default, and unrestricted seconds", () => {
262 const format = (seconds?: Iterable<number>) =>
263 Cron.format(Cron.make({
264 seconds,
265 minutes: [],
266 hours: [],
267 days: [],
268 months: [],
269 weekdays: []
270 }))
271
272 strictEqual(format(), "* * * * *")
273 strictEqual(format([15, 30]), "15,30 * * * * *")
274 strictEqual(format([]), "* * * * * *")
275 })
276
277 it("make supports requiring both days and weekdays", () => {
278 const utc = DateTime.zoneMakeNamedUnsafe("UTC")
279 const values = {
280 minutes: [0],
281 hours: [0],
282 days: [1],
283 months: [],
284 weekdays: [1],
285 tz: utc
286 }
287 const orCron = Cron.make(values)
288 const andCron = Cron.make({ ...values, and: true })
289
290 assertTrue(Cron.match(orCron, new Date("2024-01-08T00:00:00.000Z")))
291 assertFalse(Cron.match(andCron, new Date("2024-01-08T00:00:00.000Z")))
292 assertTrue(Cron.match(andCron, new Date("2024-01-01T00:00:00.000Z")))
293 assertFalse(Equal.equals(orCron, andCron))
294 deepStrictEqual(Cron.next(orCron, new Date("2024-01-02T00:00:00.000Z")), new Date("2024-01-08T00:00:00.000Z"))
295 deepStrictEqual(Cron.next(andCron, new Date("2024-01-02T00:00:00.000Z")), new Date("2024-04-01T00:00:00.000Z"))
296 })
297
298 it("make validates field values and normalizes weekday 7", () => {
299 const make = (field: string, value: number) =>
300 Cron.make({
301 minutes: field === "minutes" ? [value] : [],
302 hours: field === "hours" ? [value] : [],
303 days: field === "days" ? [value] : [],
304 months: field === "months" ? [value] : [],
305 weekdays: field === "weekdays" ? [value] : [],
306 seconds: field === "seconds" ? [value] : []
307 })
308
309 for (
310 const [field, value] of [
311 ["seconds", 60],
312 ["minutes", -1],
313 ["hours", 24],
314 ["days", 0],
315 ["months", 13],
316 ["weekdays", 8],
317 ["seconds", 0.5],
318 ["minutes", NaN]
319 ] as const
320 ) {
321 throws(() => make(field, value), (error) => {
322 assertTrue(error instanceof RangeError)
323 return undefined
324 })
325 }
326
327 const normalized = Cron.make({
328 minutes: [],
329 hours: [],
330 days: [],
331 months: [],
332 weekdays: [7]
333 })
334 deepStrictEqual(normalized.seconds, new Set([0]))
335 deepStrictEqual(normalized.weekdays, new Set([0]))
336 })
337
338 it("make treats weekday 7 as Sunday when matching", () => {
339 const cron = Cron.make({
340 minutes: [0],
341 hours: [0],
342 days: [],
343 months: [],
344 weekdays: [7],
345 tz: DateTime.zoneMakeNamedUnsafe("UTC")
346 })
347
348 assertTrue(Cron.match(cron, new Date("2024-01-07T00:00:00.000Z")))
349 assertFalse(Cron.match(cron, new Date("2024-01-08T00:00:00.000Z")))
350 })
351
352 it("make treats weekday 7 as Sunday when stepping", () => {
353 const cron = Cron.make({
354 minutes: [0],
355 hours: [0],
356 days: [],
357 months: [],
358 weekdays: [7],
359 tz: DateTime.zoneMakeNamedUnsafe("UTC")
360 })
361
362 deepStrictEqual(Cron.next(cron, new Date("2024-01-05T00:00:00.000Z")), new Date("2024-01-07T00:00:00.000Z"))
363 deepStrictEqual(Cron.prev(cron, new Date("2024-01-08T00:00:00.000Z")), new Date("2024-01-07T00:00:00.000Z"))
364 })
365
366 it("match", () => {
367 assertTrue(match("5 0 * 8 *", new Date("2024-08-01 00:05:00")))
368 assertFalse(match("5 0 * 8 *", new Date("2024-09-01 00:05:00")))
369 assertFalse(match("5 0 * 8 *", new Date("2024-08-01 01:05:00")))
370
371 assertTrue(match("15 14 1 * *", new Date("2024-02-01 14:15:00")))
372 assertFalse(match("15 14 1 * *", new Date("2024-02-01 15:15:00")))
373 assertFalse(match("15 14 1 * *", new Date("2024-02-02 14:15:00")))
374
375 assertTrue(match("23 0-20/2 * * 0", new Date("2024-01-07 00:23:00")))
376 assertFalse(match("23 0-20/2 * * 0", new Date("2024-01-07 03:23:00")))
377 assertFalse(match("23 0-20/2 * * 0", new Date("2024-01-08 00:23:00")))
378
379 assertTrue(match("5 4 * * SUN", new Date("2024-01-07 04:05:00")))
380 assertFalse(match("5 4 * * SUN", new Date("2024-01-08 04:05:00")))
381 assertFalse(match("5 4 * * SUN", new Date("2025-01-07 04:05:00")))
382
383 assertTrue(match("5 4 * DEC SUN", new Date("2024-12-01 04:05:00")))
384 assertFalse(match("5 4 * DEC SUN", new Date("2024-12-01 04:06:00")))
385 assertFalse(match("5 4 * DEC SUN", new Date("2024-12-02 04:05:00")))
386
387 assertTrue(match("5 4 * * SUN", new Date("2024-01-07 04:05:00")))
388 assertFalse(match("5 4 * * SUN", new Date("2024-01-08 04:05:00")))
389 assertFalse(match("5 4 * * SUN", new Date("2025-01-07 04:05:00")))
390
391 assertTrue(match("0 0 1/1 * 1", new Date("2024-01-02 00:00:00")))
392 assertTrue(match("0 0 1-31 * 1", new Date("2024-01-02 00:00:00")))
393 assertTrue(match("0 0 1 * SUN-SAT", new Date("2024-01-02 00:00:00")))
394 assertTrue(match("0 0 * * 7", new Date("2024-01-07 00:00:00")))
395 assertFalse(match("0 0 */2 * MON", new Date("2024-01-03 00:00:00")))
396 assertTrue(match("0 0 */2 * MON", new Date("2024-01-15 00:00:00")))
397 assertTrue(match("0 0 1-31/2 * MON", new Date("2024-01-03 00:00:00")))
398 assertFalse(match("0 0 *,5 * MON", new Date("2024-01-03 00:00:00")))
399 assertTrue(match("0 0 5,* * MON", new Date("2024-01-03 00:00:00")))
400
401 assertTrue(match("42 5 0 * 8 *", new Date("2024-08-01 00:05:42")))
402 assertFalse(match("42 5 0 * 8 *", new Date("2024-09-01 00:05:42")))
403 assertFalse(match("42 5 0 * 8 *", new Date("2024-08-01 01:05:42")))
404
405 const london = DateTime.zoneMakeNamedUnsafe("Europe/London")
406 const londonTime = DateTime.makeZonedUnsafe("2024-06-01 14:15:00Z", {
407 timeZone: london,
408 adjustForTimeZone: true
409 })
410
411 const amsterdam = DateTime.zoneMakeNamedUnsafe("Europe/Amsterdam")
412 const amsterdamTime = DateTime.makeZonedUnsafe("2024-06-01 15:15:00Z", {
413 timeZone: amsterdam,
414 adjustForTimeZone: true
415 })
416
417 assertTrue(match(Cron.parseUnsafe("15 14 1 * *", london), londonTime))
418 assertTrue(match(Cron.parseUnsafe("15 14 1 * *", london), amsterdamTime))
419 })
420
421 it("next", () => {
422 const after = new Date("2024-01-04 16:21:00")
423 deepStrictEqual(next("5 0 8 2 *", after), new Date("2024-02-08 00:05:00"))
424 deepStrictEqual(next("15 14 1 * *", after), new Date("2024-02-01 14:15:00"))
425 deepStrictEqual(next("23 0-20/2 * * 0", after), new Date("2024-01-07 00:23:00"))
426 deepStrictEqual(next("5 4 * * SUN", after), new Date("2024-01-07 04:05:00"))
427 deepStrictEqual(next("5 4 * DEC SUN", after), new Date("2024-12-01 04:05:00"))
428 deepStrictEqual(next("30 5 0 8 2 *", after), new Date("2024-02-08 00:05:30"))
429
430 const london = DateTime.zoneMakeNamedUnsafe("Europe/London")
431 const londonTime = DateTime.makeZonedUnsafe("2024-02-08 00:05:00Z", {
432 timeZone: london,
433 adjustForTimeZone: true
434 })
435
436 const amsterdam = DateTime.zoneMakeNamedUnsafe("Europe/Amsterdam")
437 const amsterdamTime = DateTime.makeZonedUnsafe("2024-02-08 01:05:00Z", {
438 timeZone: amsterdam,
439 adjustForTimeZone: true
440 })
441
442 deepStrictEqual(next(Cron.parseUnsafe("5 0 8 2 *", london), after), DateTime.toDateUtc(londonTime))
443 deepStrictEqual(next(Cron.parseUnsafe("5 0 8 2 *", london), after), DateTime.toDateUtc(amsterdamTime))
444 })
445
446 it("next adheres to day-of-month and weekday semantics", () => {
447 const utc = DateTime.zoneMakeNamedUnsafe("UTC")
448 const after = new Date("2024-01-02T12:00:00.000Z")
449
450 deepStrictEqual(next(Cron.parseUnsafe("0 0 */2 * MON", utc), after), new Date("2024-01-15T00:00:00.000Z"))
451 deepStrictEqual(next(Cron.parseUnsafe("0 0 1-31/2 * MON", utc), after), new Date("2024-01-03T00:00:00.000Z"))
452 deepStrictEqual(next(Cron.parseUnsafe("0 0 *,5 * MON", utc), after), new Date("2024-01-08T00:00:00.000Z"))
453 deepStrictEqual(next(Cron.parseUnsafe("0 0 5,* * MON", utc), after), new Date("2024-01-03T00:00:00.000Z"))
454 })
455
456 it("next does not skip earlier days when the upcoming day is missing from the month", () => {
457 const tz = DateTime.zoneMakeNamedUnsafe("UTC")
458 const cron = Cron.parseUnsafe("0 0 1,16,31 * *", tz)
459 // From Feb 18 the next matching day in February would be the 31st, which does
460 // not exist. Rolling onto it must not overshoot past March 1 (also a match).
461 deepStrictEqual(next(cron, new Date("2020-02-18T00:00:00.000Z")), new Date("2020-03-01T00:00:00.000Z"))
462 // `*/15` expands to days [1, 16, 31] and exhibits the same wrap.
463 deepStrictEqual(
464 next(Cron.parseUnsafe("0 0 */15 * *", tz), new Date("2020-02-18T00:00:00.000Z")),
465 new Date("2020-03-01T00:00:00.000Z")
466 )
467 // 30-day month: from the 20th the next day is the 31st (missing) -> July 1.
468 deepStrictEqual(next(cron, new Date("2024-06-20T00:00:00.000Z")), new Date("2024-07-01T00:00:00.000Z"))
469 })
470
471 it("next respects day-of-month or weekday constraints when entering a restricted month", () => {
472 const tz = DateTime.zoneMakeNamedUnsafe("UTC")
473 const after = new Date("2024-01-01T00:00:00.000Z")
474
475 deepStrictEqual(next(Cron.parseUnsafe("0 0 29 2 MON", tz), after), new Date("2024-02-05T00:00:00.000Z"))
476 deepStrictEqual(next(Cron.parseUnsafe("0 0 30 2 MON", tz), after), new Date("2024-02-05T00:00:00.000Z"))
477 deepStrictEqual(next(Cron.parseUnsafe("0 0 31 2 MON", tz), after), new Date("2024-02-05T00:00:00.000Z"))
478 })
479
480 it("prev", () => {
481 const utc = DateTime.zoneMakeNamedUnsafe("UTC")
482 const before = new Date("2024-01-04T16:21:00Z")
483 deepStrictEqual(prev(Cron.parseUnsafe("5 0 8 2 *", utc), before), new Date("2023-02-08T00:05:00.000Z"))
484 deepStrictEqual(prev(Cron.parseUnsafe("15 14 1 * *", utc), before), new Date("2024-01-01T14:15:00.000Z"))
485 deepStrictEqual(prev(Cron.parseUnsafe("23 0-20/2 * * * 0", utc), before), new Date("2023-12-31T23:20:23.000Z"))
486 deepStrictEqual(prev(Cron.parseUnsafe("5 4 * * SUN", utc), before), new Date("2023-12-31T04:05:00.000Z"))
487 deepStrictEqual(prev(Cron.parseUnsafe("5 4 * DEC SUN", utc), before), new Date("2023-12-31T04:05:00.000Z"))
488 deepStrictEqual(prev(Cron.parseUnsafe("30 5 0 8 2 *", utc), before), new Date("2023-02-08T00:05:30.000Z"))
489
490 const wednesday = new Date("2025-10-22T01:00:00.000Z")
491 deepStrictEqual(prev(Cron.parseUnsafe("0 1 * * MON", utc), wednesday), new Date("2025-10-20T01:00:00.000Z"))
492 deepStrictEqual(next(Cron.parseUnsafe("0 1 * * MON", utc), wednesday), new Date("2025-10-27T01:00:00.000Z"))
493 deepStrictEqual(prev(Cron.parseUnsafe("0 1 * * TUE", utc), wednesday), new Date("2025-10-21T01:00:00.000Z"))
494 deepStrictEqual(next(Cron.parseUnsafe("0 1 * * TUE", utc), wednesday), new Date("2025-10-28T01:00:00.000Z"))
495 })
496
497 it("returns the latest second when rolling back a minute", () => {
498 const utc = DateTime.zoneMakeNamedUnsafe("UTC")
499 const expr = Cron.parseUnsafe("10,30 * * * * *", utc)
500 const before = new Date("2024-01-01T00:00:05.000Z")
501 deepStrictEqual(prev(expr, before), new Date("2023-12-31T23:59:30.000Z"))
502 })
503
504 it("forward and reverse sequences stay aligned", () => {
505 const cases = [
506 ["5 2 * * 1", "2020-01-01T00:00:01Z", "2021-01-01T00:00:01Z"],
507 ["0 12 1 * *", "2020-01-01T00:00:01Z", "2021-01-01T00:00:01Z"],
508 ["10,30 * * * * *", "2024-01-01T00:00:00Z", "2024-01-02T00:00:00Z"]
509 ] as const
510
511 const gatherForward = (cron: Cron.Cron, lower: Date, upper: Date) => {
512 const dates: Array<Date> = []
513 let current = lower
514 while (true) {
515 current = Cron.next(cron, current)
516 if (current >= upper) {
517 return dates
518 }
519 dates.push(current)
520 }
521 }
522
523 const gatherReverse = (cron: Cron.Cron, lower: Date, upper: Date) => {
524 const dates: Array<Date> = []
525 let current = upper
526 while (true) {
527 current = Cron.prev(cron, current)
528 if (current <= lower) {
529 return dates.reverse()
530 }
531 dates.push(current)
532 }
533 }
534
535 for (const [expr, lowerStr, upperStr] of cases) {
536 const lower = new Date(lowerStr)
537 const upper = new Date(upperStr)
538 const cron = Cron.parseUnsafe(expr, DateTime.zoneMakeNamedUnsafe("UTC"))
539
540 deepStrictEqual(gatherForward(cron, lower, upper), gatherReverse(cron, lower, upper))
541 }
542 })
543
544 it("prev prefers the latest matching day within the previous month", () => {
545 const cron = Cron.parseUnsafe("0 0 8 5,20 * *", DateTime.zoneMakeNamedUnsafe("UTC"))
546 const before = new Date("2024-06-03T00:00:00.000Z")
547 deepStrictEqual(prev(cron, before), new Date("2024-05-20T08:00:00.000Z"))
548 })
549
550 it("prev wraps weekday using the last allowed value", () => {
551 const cron = Cron.parseUnsafe("0 1 * * MON,FRI", DateTime.zoneMakeNamedUnsafe("UTC"))
552 const sunday = new Date("2025-10-19T12:00:00.000Z")
553 deepStrictEqual(prev(cron, sunday), new Date("2025-10-17T01:00:00.000Z"))
554 })
555
556 it("prev wraps a later weekday into the previous week", () => {
557 const cron = Cron.parseUnsafe("0 0 * * SAT", DateTime.zoneMakeNamedUnsafe("UTC"))
558 const thursday = new Date("2025-10-23T12:00:00.000Z")
559 deepStrictEqual(prev(cron, thursday), new Date("2025-10-18T00:00:00.000Z"))
560 })
561
562 it("prev weekday wrapping returns matching instants strictly before the input", () => {
563 const cron = Cron.parseUnsafe("0 0 * * SAT", DateTime.zoneMakeNamedUnsafe("UTC"))
564 for (
565 const input of [
566 new Date("2025-10-19T12:00:00.000Z"),
567 new Date("2025-10-20T12:00:00.000Z"),
568 new Date("2025-10-21T12:00:00.000Z"),
569 new Date("2025-10-22T12:00:00.000Z"),
570 new Date("2025-10-23T12:00:00.000Z"),
571 new Date("2025-10-24T12:00:00.000Z")
572 ]
573 ) {
574 const result = prev(cron, input)
575 assertTrue(result < input)
576 assertTrue(Cron.match(cron, result))
577 }
578 })
579
580 it("prev chooses the preferred occurrence in DST fall-back", () => {
581 const make = (s: string): DateTime.Zoned => Option.getOrThrow(DateTime.makeZonedFromString(s))
582 const cron = Cron.parseUnsafe("0 30 2 * * *", "Europe/Berlin")
583 const before = make("2024-10-27T03:30:00.000+01:00[Europe/Berlin]")
584 const result = DateTime.makeZonedUnsafe(prev(cron, before), { timeZone: "Europe/Berlin" })
585 deepStrictEqual(result.pipe(DateTime.formatIsoZoned), "2024-10-27T02:30:00.000+02:00[Europe/Berlin]")
586 })
587
588 it("prev respects combined day-of-month and weekday constraints", () => {
589 const tz = DateTime.zoneMakeNamedUnsafe("UTC")
590 const cron = Cron.parseUnsafe("0 0 9 1,15 * MON", tz)
591 const before = new Date("2024-04-02T12:00:00.000Z")
592 deepStrictEqual(prev(cron, before), new Date("2024-04-01T09:00:00.000Z"))
593 })
594
595 it("prev handles step expressions across day boundary", () => {
596 const tz = DateTime.zoneMakeNamedUnsafe("UTC")
597 const cron = Cron.parseUnsafe("0 */7 8-10 * * *", tz)
598 const before = new Date("2024-01-01T08:01:00.000Z")
599 deepStrictEqual(prev(cron, before), new Date("2024-01-01T08:00:00.000Z"))
600 })
601
602 it("prev works with fixed offset time zones", () => {
603 const offset = DateTime.zoneMakeOffset(2 * 60 * 60 * 1000)
604 const cron = Cron.parseUnsafe("0 0 10 * * *", offset)
605 const before = new Date("2024-05-01T07:00:00.000Z")
606 deepStrictEqual(prev(cron, before), new Date("2024-04-30T08:00:00.000Z"))
607 })
608
609 it("prev wraps across year boundary", () => {
610 const tz = DateTime.zoneMakeNamedUnsafe("UTC")
611 const cron = Cron.parseUnsafe("0 0 1 1 *", tz)
612 const from = new Date("2024-01-01T00:00:00.000Z")
613 deepStrictEqual(prev(cron, from), new Date("2023-01-01T00:00:00.000Z"))
614 })
615
616 it("prev handles day 31 skipping months without it", () => {
617 const tz = DateTime.zoneMakeNamedUnsafe("UTC")
618 const cron = Cron.parseUnsafe("0 0 31 * *", tz)
619 const from = new Date("2024-03-01T00:00:00.000Z")
620 deepStrictEqual(prev(cron, from), new Date("2024-01-31T00:00:00.000Z"))
621 })
622
623 it("prev skips an invalid day in the immediately preceding month", () => {
624 const tz = DateTime.zoneMakeNamedUnsafe("UTC")
625 const cron = Cron.parseUnsafe("0 0 31 * *", tz)
626 const from = new Date("2024-03-15T00:00:00.000Z")
627 deepStrictEqual(prev(cron, from), new Date("2024-01-31T00:00:00.000Z"))
628 })
629
630 it("prev finds the previous leap day", () => {
631 const tz = DateTime.zoneMakeNamedUnsafe("UTC")
632 const cron = Cron.parseUnsafe("0 0 29 2 *", tz)
633 const from = new Date("2024-01-01T00:00:00.000Z")
634 deepStrictEqual(prev(cron, from), new Date("2020-02-29T00:00:00.000Z"))
635 })
636
637 it("prev day-of-month rollovers preserve match and ordering invariants", () => {
638 const tz = DateTime.zoneMakeNamedUnsafe("UTC")
639 const cases = [
640 [Cron.parseUnsafe("0 0 31 * *", tz), new Date("2024-03-15T00:00:00.000Z")],
641 [Cron.parseUnsafe("0 0 29 2 *", tz), new Date("2024-01-01T00:00:00.000Z")]
642 ] as const
643
644 for (const [cron, from] of cases) {
645 const result = prev(cron, from)
646 assertTrue(Cron.match(cron, result))
647 assertTrue(result < from)
648 }
649 })
650
651 it("prev terminates for an impossible day and month combination", () => {
652 const tz = DateTime.zoneMakeNamedUnsafe("UTC")
653 const cron = Cron.parseUnsafe("0 0 30 2 *", tz)
654 throws(() => prev(cron, new Date("2024-03-01T00:00:00.000Z")))
655 })
656
657 it("prev clamps to the last valid day when rolling back a month with only month constraints", () => {
658 const tz = DateTime.zoneMakeNamedUnsafe("UTC")
659 const cron = Cron.parseUnsafe("0 0 0 * FEB *", tz)
660 const from = new Date("2024-03-31T12:00:00.000Z")
661 deepStrictEqual(prev(cron, from), new Date("2024-02-29T00:00:00.000Z"))
662 })
663
664 it("prev with multiple months specified", () => {
665 const tz = DateTime.zoneMakeNamedUnsafe("UTC")
666 const cron = Cron.parseUnsafe("0 0 15 1,4,7,10 *", tz)
667 const from = new Date("2024-05-01T00:00:00.000Z")
668 deepStrictEqual(prev(cron, from), new Date("2024-04-15T00:00:00.000Z"))
669 })
670
671 it("sequence", () => {
672 const start = new Date("2024-01-01 00:00:00")
673 const generator = Cron.sequence(Cron.parseUnsafe("23 0-20/2 * * 0"), start)
674 deepStrictEqual(generator.next().value, new Date("2024-01-07 00:23:00"))
675 deepStrictEqual(generator.next().value, new Date("2024-01-07 02:23:00"))
676 deepStrictEqual(generator.next().value, new Date("2024-01-07 04:23:00"))
677 deepStrictEqual(generator.next().value, new Date("2024-01-07 06:23:00"))
678 deepStrictEqual(generator.next().value, new Date("2024-01-07 08:23:00"))
679 })
680
681 it("equal", () => {
682 const cron = Cron.parseUnsafe("23 0-20/2 * * 0")
683 const utc = Cron.parseUnsafe("23 0-20/2 * * 0", "UTC")
684 const anotherUtc = Cron.parseUnsafe("23 0-20/2 * * 0", "UTC")
685 const berlin = Cron.parseUnsafe("23 0-20/2 * * 0", "Europe/Berlin")
686 assertTrue(Equal.equals(cron, cron))
687 assertTrue(Equal.equals(cron, Cron.parseUnsafe("23 0-20/2 * * 0")))
688 assertTrue(Cron.equals(utc, anotherUtc))
689 assertTrue(Cron.Equivalence(utc, anotherUtc))
690 assertTrue(Equal.equals(utc, anotherUtc))
691 strictEqual(Hash.hash(utc), Hash.hash(anotherUtc))
692 strictEqual(HashSet.size(HashSet.make(cron, utc, anotherUtc, berlin)), 3)
693 assertFalse(Cron.equals(cron, utc))
694 assertFalse(Cron.Equivalence(utc, berlin))
695 assertFalse(Equal.equals(cron, utc))
696 assertFalse(Equal.equals(utc, berlin))
697 assertFalse(Equal.equals(cron, Cron.parseUnsafe("23 0-20/2 * * 1")))
698 assertFalse(Equal.equals(cron, Cron.parseUnsafe("23 0-20/2 * * 0-6")))
699 assertFalse(Equal.equals(cron, Cron.parseUnsafe("23 0-20/2 1 * 0")))
700 })
701
702 it("handles leap years", () => {
703 assertTrue(match("0 0 29 2 *", new Date("2024-02-29 00:00:00")))
704 assertFalse(match("0 0 29 2 *", new Date("2025-02-29 00:00:00")))
705 assertFalse(match("0 0 29 2 *", new Date("2026-02-29 00:00:00")))
706 assertFalse(match("0 0 29 2 *", new Date("2027-02-29 00:00:00")))
707 assertTrue(match("0 0 29 2 *", new Date("2028-02-29 00:00:00")))
708
709 deepStrictEqual(next("0 0 29 2 *", new Date("2024-01-01 00:00:00")), new Date("2024-02-29 00:00:00"))
710 deepStrictEqual(next("0 0 29 2 *", new Date("2025-01-01 00:00:00")), new Date("2028-02-29 00:00:00"))
711 })
712
713 it("handles transition into daylight savings time", () => {
714 const make = (date: string): DateTime.Zoned => Option.getOrThrow(DateTime.makeZonedFromString(date))
715 const sequence = Cron.sequence(
716 Cron.parseUnsafe("30 * * * *", "Europe/Berlin"),
717 make("2024-03-31T00:00:00.000+01:00[Europe/Berlin]")
718 )
719 const next = (): DateTime.Zoned => DateTime.makeZonedUnsafe(sequence.next().value, { timeZone: "Europe/Berlin" })
720
721 const a = make("2024-03-31T00:30:00.000+01:00[Europe/Berlin]")
722 const b = make("2024-03-31T01:30:00.000+01:00[Europe/Berlin]")
723 const c = make("2024-03-31T03:30:00.000+02:00[Europe/Berlin]")
724 const d = make("2024-03-31T04:30:00.000+02:00[Europe/Berlin]")
725
726 deepStrictEqual(next().pipe(DateTime.formatIsoZoned), a.pipe(DateTime.formatIsoZoned))
727 deepStrictEqual(next().pipe(DateTime.formatIsoZoned), b.pipe(DateTime.formatIsoZoned))
728 deepStrictEqual(next().pipe(DateTime.formatIsoZoned), c.pipe(DateTime.formatIsoZoned))
729 deepStrictEqual(next().pipe(DateTime.formatIsoZoned), d.pipe(DateTime.formatIsoZoned))
730 })
731
732 it("handles transition out of daylight savings time", () => {
733 // DST fall-back transition in Europe/Berlin on 2024-10-27:
734 // At 3:00 AM +02:00, clocks "fall back" to 2:00 AM +01:00
735 // This means times from 2:00-2:59 AM occur twice (ambiguous period)
736 //
737 // Correct "once" mode behavior for cron:
738 // - Include all normal times (00:30, 01:30)
739 // - Return first occurrence only of ambiguous times (02:30 +02:00)
740 // - Skip second occurrence of ambiguous times (02:30 +01:00)
741 // - Continue normally after transition (03:30 +01:00)
742
743 const make = (date: string): DateTime.Zoned => Option.getOrThrow(DateTime.makeZonedFromString(date))
744 const sequence = Cron.sequence(
745 Cron.parseUnsafe("30 * * * *", "Europe/Berlin"),
746 make("2024-10-27T00:00:00.000+02:00[Europe/Berlin]")
747 )
748 const next = (): DateTime.Zoned => DateTime.makeZonedUnsafe(sequence.next().value, { timeZone: "Europe/Berlin" })
749
750 const a = make("2024-10-27T00:30:00.000+02:00[Europe/Berlin]") // Normal time
751 const b = make("2024-10-27T01:30:00.000+02:00[Europe/Berlin]") // Normal time (not ambiguous)
752 const c = make("2024-10-27T02:30:00.000+02:00[Europe/Berlin]") // First occurrence during DST
753 const d = make("2024-10-27T03:30:00.000+01:00[Europe/Berlin]") // Standard time (skips 2nd 02:30)
754 const e = make("2024-10-27T04:30:00.000+01:00[Europe/Berlin]") // Standard time
755
756 deepStrictEqual(next().pipe(DateTime.formatIsoZoned), a.pipe(DateTime.formatIsoZoned))
757 deepStrictEqual(next().pipe(DateTime.formatIsoZoned), b.pipe(DateTime.formatIsoZoned))
758 deepStrictEqual(next().pipe(DateTime.formatIsoZoned), c.pipe(DateTime.formatIsoZoned))
759 deepStrictEqual(next().pipe(DateTime.formatIsoZoned), d.pipe(DateTime.formatIsoZoned))
760 deepStrictEqual(next().pipe(DateTime.formatIsoZoned), e.pipe(DateTime.formatIsoZoned))
761 })
762
763 it("handles utc timezone", () => {
764 const utc = DateTime.zoneMakeNamedUnsafe("UTC")
765 const make = (date: string): DateTime.Zoned => Option.getOrThrow(DateTime.makeZonedFromString(date))
766 const sequence = Cron.sequence(Cron.parseUnsafe("30 * * * *", utc), make("2024-10-27T00:00:00.000+00:00[UTC]"))
767 const next = (): DateTime.Zoned => DateTime.makeZonedUnsafe(sequence.next().value, { timeZone: utc })
768
769 const a = make("2024-10-27T00:30:00.000+00:00[UTC]")
770 const b = make("2024-10-27T01:30:00.000+00:00[UTC]")
771 const c = make("2024-10-27T02:30:00.000+00:00[UTC]")
772 const d = make("2024-10-27T03:30:00.000+00:00[UTC]")
773
774 deepStrictEqual(next().pipe(DateTime.formatIsoZoned), a.pipe(DateTime.formatIsoZoned))
775 deepStrictEqual(next().pipe(DateTime.formatIsoZoned), b.pipe(DateTime.formatIsoZoned))
776 deepStrictEqual(next().pipe(DateTime.formatIsoZoned), c.pipe(DateTime.formatIsoZoned))
777 deepStrictEqual(next().pipe(DateTime.formatIsoZoned), d.pipe(DateTime.formatIsoZoned))
778 })
779})