582 lines
24 KiB
TypeScript
582 lines
24 KiB
TypeScript
import { describe, it } from "@effect/vitest"
|
|
import { assertFalse, assertTrue, deepStrictEqual, throws } from "@effect/vitest/utils"
|
|
import { Cron, DateTime, Equal, Option, Result } from "effect"
|
|
|
|
const match = (input: Cron.Cron | string, date: DateTime.DateTime.Input) =>
|
|
Cron.match(Cron.isCron(input) ? input : Cron.parseUnsafe(input), date)
|
|
|
|
const next = (input: Cron.Cron | string, after?: DateTime.DateTime.Input) =>
|
|
Cron.next(Cron.isCron(input) ? input : Cron.parseUnsafe(input), after)
|
|
|
|
const prev = (input: Cron.Cron | string, after?: DateTime.DateTime.Input) =>
|
|
Cron.prev(Cron.isCron(input) ? input : Cron.parseUnsafe(input), after)
|
|
|
|
const allDays = Array.from({ length: 31 }, (_, i) => i + 1)
|
|
const allWeekdays = Array.from({ length: 7 }, (_, i) => i)
|
|
|
|
describe("Cron", () => {
|
|
it("isCronParseError", () => {
|
|
const result = Cron.parse("")
|
|
assertTrue(Result.isFailure(result))
|
|
assertTrue(Cron.isCronParseError(result.failure))
|
|
assertFalse(Cron.isCronParseError(new Error("regular error")))
|
|
assertFalse(Cron.isCronParseError("not an error"))
|
|
})
|
|
|
|
it("CronParseError constructor", () => {
|
|
const error = new Cron.CronParseError({ message: "boom", input: "0 0 * * *" })
|
|
assertTrue(error instanceof Error)
|
|
assertTrue(Cron.isCronParseError(error))
|
|
deepStrictEqual(error._tag, "CronParseError")
|
|
deepStrictEqual(error.message, "boom")
|
|
deepStrictEqual(error.input, "0 0 * * *")
|
|
})
|
|
|
|
it("parse", () => {
|
|
// Explicit full weekday ranges are preserved for day-of-month / weekday OR semantics.
|
|
deepStrictEqual(
|
|
Cron.parse("0 4 8-14 * 0-6"),
|
|
Result.succeed(Cron.make({
|
|
minutes: [0],
|
|
hours: [4],
|
|
days: [8, 9, 10, 11, 12, 13, 14],
|
|
months: [],
|
|
weekdays: allWeekdays
|
|
}))
|
|
)
|
|
// At 00:00 on day-of-month 1 and 15 and on Wednesday.
|
|
deepStrictEqual(
|
|
Cron.parse("0 0 1,15 * 3"),
|
|
Result.succeed(Cron.make({
|
|
minutes: [0],
|
|
hours: [0],
|
|
days: [1, 15],
|
|
months: [],
|
|
weekdays: [3]
|
|
}))
|
|
)
|
|
// At 00:00 on day-of-month 1 and 15 and on Wednesday.
|
|
deepStrictEqual(
|
|
Cron.parse("23 0-20/2 * * *"),
|
|
Result.succeed(Cron.make({
|
|
minutes: [23],
|
|
hours: [0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20],
|
|
days: [],
|
|
months: [],
|
|
weekdays: []
|
|
}))
|
|
)
|
|
|
|
// Starting at minute 5 and then every 15 minutes.
|
|
deepStrictEqual(
|
|
Cron.parse("5/15 * * * *"),
|
|
Result.succeed(Cron.make({
|
|
minutes: [5, 20, 35, 50],
|
|
hours: [],
|
|
days: [],
|
|
months: [],
|
|
weekdays: []
|
|
}))
|
|
)
|
|
|
|
// At 00:00 on every day-of-month from 1 through 31 and on Monday.
|
|
deepStrictEqual(
|
|
Cron.parse("0 0 1/1 * 1"),
|
|
Result.succeed(Cron.make({
|
|
minutes: [0],
|
|
hours: [0],
|
|
days: allDays,
|
|
months: [],
|
|
weekdays: [1]
|
|
}))
|
|
)
|
|
|
|
deepStrictEqual(
|
|
Cron.parse("0 0 */1 * 1"),
|
|
Result.succeed(Cron.make({
|
|
minutes: [0],
|
|
hours: [0],
|
|
days: [],
|
|
months: [],
|
|
weekdays: [1]
|
|
}))
|
|
)
|
|
|
|
deepStrictEqual(
|
|
Cron.parse("0 0 1-31 * MON"),
|
|
Result.succeed(Cron.make({
|
|
minutes: [0],
|
|
hours: [0],
|
|
days: allDays,
|
|
months: [],
|
|
weekdays: [1]
|
|
}))
|
|
)
|
|
|
|
deepStrictEqual(
|
|
Cron.parse("0 0 1-31/1 * MON"),
|
|
Result.succeed(Cron.make({
|
|
minutes: [0],
|
|
hours: [0],
|
|
days: allDays,
|
|
months: [],
|
|
weekdays: [1]
|
|
}))
|
|
)
|
|
|
|
deepStrictEqual(
|
|
Cron.parse("0 0 1 * 0-6"),
|
|
Result.succeed(Cron.make({
|
|
minutes: [0],
|
|
hours: [0],
|
|
days: [1],
|
|
months: [],
|
|
weekdays: allWeekdays
|
|
}))
|
|
)
|
|
|
|
deepStrictEqual(
|
|
Cron.parse("0 0 1 * SUN-SAT"),
|
|
Result.succeed(Cron.make({
|
|
minutes: [0],
|
|
hours: [0],
|
|
days: [1],
|
|
months: [],
|
|
weekdays: allWeekdays
|
|
}))
|
|
)
|
|
|
|
deepStrictEqual(
|
|
Cron.parse("0\t0\t*\t*\t7"),
|
|
Result.succeed(Cron.make({
|
|
minutes: [0],
|
|
hours: [0],
|
|
days: [],
|
|
months: [],
|
|
weekdays: [0]
|
|
}))
|
|
)
|
|
|
|
const invalidMinuteTail = Cron.parse("0/1,60 * * * *")
|
|
assertTrue(Result.isFailure(invalidMinuteTail))
|
|
deepStrictEqual(invalidMinuteTail.failure.message, "Expected a value between 0 and 59")
|
|
|
|
const invalidDayTail = Cron.parse("0 0 1/1,32 * *")
|
|
assertTrue(Result.isFailure(invalidDayTail))
|
|
deepStrictEqual(invalidDayTail.failure.message, "Expected a value between 1 and 31")
|
|
|
|
for (
|
|
const invalid of [
|
|
"0,,5 * * * *",
|
|
",5 * * * *",
|
|
"-5 * * * *",
|
|
"0- * * * *",
|
|
"1e1 * * * *",
|
|
"0x10 * * * *",
|
|
"+5 * * * *"
|
|
]
|
|
) {
|
|
assertTrue(Result.isFailure(Cron.parse(invalid)))
|
|
}
|
|
|
|
const withNamedTimeZone = Cron.parseUnsafe("23 0-20/2 * * *", "Europe/Berlin")
|
|
assertTrue(Option.isSome(withNamedTimeZone.tz))
|
|
deepStrictEqual(DateTime.zoneToString(withNamedTimeZone.tz.value), "Europe/Berlin")
|
|
|
|
const withOffsetTimeZone = Cron.parseUnsafe("23 0-20/2 * * *", "+02:00")
|
|
assertTrue(Option.isSome(withOffsetTimeZone.tz))
|
|
deepStrictEqual(DateTime.zoneToString(withOffsetTimeZone.tz.value), "+02:00")
|
|
|
|
const withoutTimeZone = Cron.parseUnsafe("23 0-20/2 * * *")
|
|
assertTrue(Option.isNone(withoutTimeZone.tz))
|
|
})
|
|
|
|
it("parseUnsafe", () => {
|
|
throws(
|
|
() => Cron.parseUnsafe(""),
|
|
(error) => {
|
|
if (!Cron.isCronParseError(error)) {
|
|
throw new Error("Expected CronParseError")
|
|
}
|
|
deepStrictEqual(error.message, "Invalid number of segments in cron expression")
|
|
deepStrictEqual(error.input, "")
|
|
}
|
|
)
|
|
throws(
|
|
() => Cron.parseUnsafe("0 0 4 8-14 * *", ""),
|
|
(error) => {
|
|
if (!Cron.isCronParseError(error)) {
|
|
throw new Error("Expected CronParseError")
|
|
}
|
|
deepStrictEqual(error.message, "Invalid time zone in cron expression")
|
|
deepStrictEqual(error.input, "")
|
|
}
|
|
)
|
|
})
|
|
|
|
it("make supports requiring both days and weekdays", () => {
|
|
const utc = DateTime.zoneMakeNamedUnsafe("UTC")
|
|
const values = {
|
|
minutes: [0],
|
|
hours: [0],
|
|
days: [1],
|
|
months: [],
|
|
weekdays: [1],
|
|
tz: utc
|
|
}
|
|
const orCron = Cron.make(values)
|
|
const andCron = Cron.make({ ...values, and: true })
|
|
|
|
assertTrue(Cron.match(orCron, new Date("2024-01-08T00:00:00.000Z")))
|
|
assertFalse(Cron.match(andCron, new Date("2024-01-08T00:00:00.000Z")))
|
|
assertTrue(Cron.match(andCron, new Date("2024-01-01T00:00:00.000Z")))
|
|
assertFalse(Equal.equals(orCron, andCron))
|
|
deepStrictEqual(Cron.next(orCron, new Date("2024-01-02T00:00:00.000Z")), new Date("2024-01-08T00:00:00.000Z"))
|
|
deepStrictEqual(Cron.next(andCron, new Date("2024-01-02T00:00:00.000Z")), new Date("2024-04-01T00:00:00.000Z"))
|
|
})
|
|
|
|
it("match", () => {
|
|
assertTrue(match("5 0 * 8 *", new Date("2024-08-01 00:05:00")))
|
|
assertFalse(match("5 0 * 8 *", new Date("2024-09-01 00:05:00")))
|
|
assertFalse(match("5 0 * 8 *", new Date("2024-08-01 01:05:00")))
|
|
|
|
assertTrue(match("15 14 1 * *", new Date("2024-02-01 14:15:00")))
|
|
assertFalse(match("15 14 1 * *", new Date("2024-02-01 15:15:00")))
|
|
assertFalse(match("15 14 1 * *", new Date("2024-02-02 14:15:00")))
|
|
|
|
assertTrue(match("23 0-20/2 * * 0", new Date("2024-01-07 00:23:00")))
|
|
assertFalse(match("23 0-20/2 * * 0", new Date("2024-01-07 03:23:00")))
|
|
assertFalse(match("23 0-20/2 * * 0", new Date("2024-01-08 00:23:00")))
|
|
|
|
assertTrue(match("5 4 * * SUN", new Date("2024-01-07 04:05:00")))
|
|
assertFalse(match("5 4 * * SUN", new Date("2024-01-08 04:05:00")))
|
|
assertFalse(match("5 4 * * SUN", new Date("2025-01-07 04:05:00")))
|
|
|
|
assertTrue(match("5 4 * DEC SUN", new Date("2024-12-01 04:05:00")))
|
|
assertFalse(match("5 4 * DEC SUN", new Date("2024-12-01 04:06:00")))
|
|
assertFalse(match("5 4 * DEC SUN", new Date("2024-12-02 04:05:00")))
|
|
|
|
assertTrue(match("5 4 * * SUN", new Date("2024-01-07 04:05:00")))
|
|
assertFalse(match("5 4 * * SUN", new Date("2024-01-08 04:05:00")))
|
|
assertFalse(match("5 4 * * SUN", new Date("2025-01-07 04:05:00")))
|
|
|
|
assertTrue(match("0 0 1/1 * 1", new Date("2024-01-02 00:00:00")))
|
|
assertTrue(match("0 0 1-31 * 1", new Date("2024-01-02 00:00:00")))
|
|
assertTrue(match("0 0 1 * SUN-SAT", new Date("2024-01-02 00:00:00")))
|
|
assertTrue(match("0 0 * * 7", new Date("2024-01-07 00:00:00")))
|
|
assertFalse(match("0 0 */2 * MON", new Date("2024-01-03 00:00:00")))
|
|
assertTrue(match("0 0 */2 * MON", new Date("2024-01-15 00:00:00")))
|
|
assertTrue(match("0 0 1-31/2 * MON", new Date("2024-01-03 00:00:00")))
|
|
assertFalse(match("0 0 *,5 * MON", new Date("2024-01-03 00:00:00")))
|
|
assertTrue(match("0 0 5,* * MON", new Date("2024-01-03 00:00:00")))
|
|
|
|
assertTrue(match("42 5 0 * 8 *", new Date("2024-08-01 00:05:42")))
|
|
assertFalse(match("42 5 0 * 8 *", new Date("2024-09-01 00:05:42")))
|
|
assertFalse(match("42 5 0 * 8 *", new Date("2024-08-01 01:05:42")))
|
|
|
|
const london = DateTime.zoneMakeNamedUnsafe("Europe/London")
|
|
const londonTime = DateTime.makeZonedUnsafe("2024-06-01 14:15:00Z", {
|
|
timeZone: london,
|
|
adjustForTimeZone: true
|
|
})
|
|
|
|
const amsterdam = DateTime.zoneMakeNamedUnsafe("Europe/Amsterdam")
|
|
const amsterdamTime = DateTime.makeZonedUnsafe("2024-06-01 15:15:00Z", {
|
|
timeZone: amsterdam,
|
|
adjustForTimeZone: true
|
|
})
|
|
|
|
assertTrue(match(Cron.parseUnsafe("15 14 1 * *", london), londonTime))
|
|
assertTrue(match(Cron.parseUnsafe("15 14 1 * *", london), amsterdamTime))
|
|
})
|
|
|
|
it("next", () => {
|
|
const after = new Date("2024-01-04 16:21:00")
|
|
deepStrictEqual(next("5 0 8 2 *", after), new Date("2024-02-08 00:05:00"))
|
|
deepStrictEqual(next("15 14 1 * *", after), new Date("2024-02-01 14:15:00"))
|
|
deepStrictEqual(next("23 0-20/2 * * 0", after), new Date("2024-01-07 00:23:00"))
|
|
deepStrictEqual(next("5 4 * * SUN", after), new Date("2024-01-07 04:05:00"))
|
|
deepStrictEqual(next("5 4 * DEC SUN", after), new Date("2024-12-01 04:05:00"))
|
|
deepStrictEqual(next("30 5 0 8 2 *", after), new Date("2024-02-08 00:05:30"))
|
|
|
|
const london = DateTime.zoneMakeNamedUnsafe("Europe/London")
|
|
const londonTime = DateTime.makeZonedUnsafe("2024-02-08 00:05:00Z", {
|
|
timeZone: london,
|
|
adjustForTimeZone: true
|
|
})
|
|
|
|
const amsterdam = DateTime.zoneMakeNamedUnsafe("Europe/Amsterdam")
|
|
const amsterdamTime = DateTime.makeZonedUnsafe("2024-02-08 01:05:00Z", {
|
|
timeZone: amsterdam,
|
|
adjustForTimeZone: true
|
|
})
|
|
|
|
deepStrictEqual(next(Cron.parseUnsafe("5 0 8 2 *", london), after), DateTime.toDateUtc(londonTime))
|
|
deepStrictEqual(next(Cron.parseUnsafe("5 0 8 2 *", london), after), DateTime.toDateUtc(amsterdamTime))
|
|
})
|
|
|
|
it("next adheres to day-of-month and weekday semantics", () => {
|
|
const utc = DateTime.zoneMakeNamedUnsafe("UTC")
|
|
const after = new Date("2024-01-02T12:00:00.000Z")
|
|
|
|
deepStrictEqual(next(Cron.parseUnsafe("0 0 */2 * MON", utc), after), new Date("2024-01-15T00:00:00.000Z"))
|
|
deepStrictEqual(next(Cron.parseUnsafe("0 0 1-31/2 * MON", utc), after), new Date("2024-01-03T00:00:00.000Z"))
|
|
deepStrictEqual(next(Cron.parseUnsafe("0 0 *,5 * MON", utc), after), new Date("2024-01-08T00:00:00.000Z"))
|
|
deepStrictEqual(next(Cron.parseUnsafe("0 0 5,* * MON", utc), after), new Date("2024-01-03T00:00:00.000Z"))
|
|
})
|
|
|
|
it("next does not skip earlier days when the upcoming day is missing from the month", () => {
|
|
const tz = DateTime.zoneMakeNamedUnsafe("UTC")
|
|
const cron = Cron.parseUnsafe("0 0 1,16,31 * *", tz)
|
|
// From Feb 18 the next matching day in February would be the 31st, which does
|
|
// not exist. Rolling onto it must not overshoot past March 1 (also a match).
|
|
deepStrictEqual(next(cron, new Date("2020-02-18T00:00:00.000Z")), new Date("2020-03-01T00:00:00.000Z"))
|
|
// `*/15` expands to days [1, 16, 31] and exhibits the same wrap.
|
|
deepStrictEqual(
|
|
next(Cron.parseUnsafe("0 0 */15 * *", tz), new Date("2020-02-18T00:00:00.000Z")),
|
|
new Date("2020-03-01T00:00:00.000Z")
|
|
)
|
|
// 30-day month: from the 20th the next day is the 31st (missing) -> July 1.
|
|
deepStrictEqual(next(cron, new Date("2024-06-20T00:00:00.000Z")), new Date("2024-07-01T00:00:00.000Z"))
|
|
})
|
|
|
|
it("next respects day-of-month or weekday constraints when entering a restricted month", () => {
|
|
const tz = DateTime.zoneMakeNamedUnsafe("UTC")
|
|
const after = new Date("2024-01-01T00:00:00.000Z")
|
|
|
|
deepStrictEqual(next(Cron.parseUnsafe("0 0 29 2 MON", tz), after), new Date("2024-02-05T00:00:00.000Z"))
|
|
deepStrictEqual(next(Cron.parseUnsafe("0 0 30 2 MON", tz), after), new Date("2024-02-05T00:00:00.000Z"))
|
|
deepStrictEqual(next(Cron.parseUnsafe("0 0 31 2 MON", tz), after), new Date("2024-02-05T00:00:00.000Z"))
|
|
})
|
|
|
|
it("prev", () => {
|
|
const utc = DateTime.zoneMakeNamedUnsafe("UTC")
|
|
const before = new Date("2024-01-04T16:21:00Z")
|
|
deepStrictEqual(prev(Cron.parseUnsafe("5 0 8 2 *", utc), before), new Date("2023-02-08T00:05:00.000Z"))
|
|
deepStrictEqual(prev(Cron.parseUnsafe("15 14 1 * *", utc), before), new Date("2024-01-01T14:15:00.000Z"))
|
|
deepStrictEqual(prev(Cron.parseUnsafe("23 0-20/2 * * * 0", utc), before), new Date("2023-12-31T23:20:23.000Z"))
|
|
deepStrictEqual(prev(Cron.parseUnsafe("5 4 * * SUN", utc), before), new Date("2023-12-31T04:05:00.000Z"))
|
|
deepStrictEqual(prev(Cron.parseUnsafe("5 4 * DEC SUN", utc), before), new Date("2023-12-31T04:05:00.000Z"))
|
|
deepStrictEqual(prev(Cron.parseUnsafe("30 5 0 8 2 *", utc), before), new Date("2023-02-08T00:05:30.000Z"))
|
|
|
|
const wednesday = new Date("2025-10-22T01:00:00.000Z")
|
|
deepStrictEqual(prev(Cron.parseUnsafe("0 1 * * MON", utc), wednesday), new Date("2025-10-20T01:00:00.000Z"))
|
|
deepStrictEqual(next(Cron.parseUnsafe("0 1 * * MON", utc), wednesday), new Date("2025-10-27T01:00:00.000Z"))
|
|
deepStrictEqual(prev(Cron.parseUnsafe("0 1 * * TUE", utc), wednesday), new Date("2025-10-21T01:00:00.000Z"))
|
|
deepStrictEqual(next(Cron.parseUnsafe("0 1 * * TUE", utc), wednesday), new Date("2025-10-28T01:00:00.000Z"))
|
|
})
|
|
|
|
it("returns the latest second when rolling back a minute", () => {
|
|
const utc = DateTime.zoneMakeNamedUnsafe("UTC")
|
|
const expr = Cron.parseUnsafe("10,30 * * * * *", utc)
|
|
const before = new Date("2024-01-01T00:00:05.000Z")
|
|
deepStrictEqual(prev(expr, before), new Date("2023-12-31T23:59:30.000Z"))
|
|
})
|
|
|
|
it("forward and reverse sequences stay aligned", () => {
|
|
const cases = [
|
|
["5 2 * * 1", "2020-01-01T00:00:01Z", "2021-01-01T00:00:01Z"],
|
|
["0 12 1 * *", "2020-01-01T00:00:01Z", "2021-01-01T00:00:01Z"],
|
|
["10,30 * * * * *", "2024-01-01T00:00:00Z", "2024-01-02T00:00:00Z"]
|
|
] as const
|
|
|
|
const gatherForward = (cron: Cron.Cron, lower: Date, upper: Date) => {
|
|
const dates: Array<Date> = []
|
|
let current = lower
|
|
while (true) {
|
|
current = Cron.next(cron, current)
|
|
if (current >= upper) {
|
|
return dates
|
|
}
|
|
dates.push(current)
|
|
}
|
|
}
|
|
|
|
const gatherReverse = (cron: Cron.Cron, lower: Date, upper: Date) => {
|
|
const dates: Array<Date> = []
|
|
let current = upper
|
|
while (true) {
|
|
current = Cron.prev(cron, current)
|
|
if (current <= lower) {
|
|
return dates.reverse()
|
|
}
|
|
dates.push(current)
|
|
}
|
|
}
|
|
|
|
for (const [expr, lowerStr, upperStr] of cases) {
|
|
const lower = new Date(lowerStr)
|
|
const upper = new Date(upperStr)
|
|
const cron = Cron.parseUnsafe(expr, DateTime.zoneMakeNamedUnsafe("UTC"))
|
|
|
|
deepStrictEqual(gatherForward(cron, lower, upper), gatherReverse(cron, lower, upper))
|
|
}
|
|
})
|
|
|
|
it("prev prefers the latest matching day within the previous month", () => {
|
|
const cron = Cron.parseUnsafe("0 0 8 5,20 * *", DateTime.zoneMakeNamedUnsafe("UTC"))
|
|
const before = new Date("2024-06-03T00:00:00.000Z")
|
|
deepStrictEqual(prev(cron, before), new Date("2024-05-20T08:00:00.000Z"))
|
|
})
|
|
|
|
it("prev wraps weekday using the last allowed value", () => {
|
|
const cron = Cron.parseUnsafe("0 1 * * MON,FRI", DateTime.zoneMakeNamedUnsafe("UTC"))
|
|
const sunday = new Date("2025-10-19T12:00:00.000Z")
|
|
deepStrictEqual(prev(cron, sunday), new Date("2025-10-17T01:00:00.000Z"))
|
|
})
|
|
|
|
it("prev chooses the preferred occurrence in DST fall-back", () => {
|
|
const make = (s: string): DateTime.Zoned => Option.getOrThrow(DateTime.makeZonedFromString(s))
|
|
const cron = Cron.parseUnsafe("0 30 2 * * *", "Europe/Berlin")
|
|
const before = make("2024-10-27T03:30:00.000+01:00[Europe/Berlin]")
|
|
const result = DateTime.makeZonedUnsafe(prev(cron, before), { timeZone: "Europe/Berlin" })
|
|
deepStrictEqual(result.pipe(DateTime.formatIsoZoned), "2024-10-27T02:30:00.000+02:00[Europe/Berlin]")
|
|
})
|
|
|
|
it("prev respects combined day-of-month and weekday constraints", () => {
|
|
const tz = DateTime.zoneMakeNamedUnsafe("UTC")
|
|
const cron = Cron.parseUnsafe("0 0 9 1,15 * MON", tz)
|
|
const before = new Date("2024-04-02T12:00:00.000Z")
|
|
deepStrictEqual(prev(cron, before), new Date("2024-04-01T09:00:00.000Z"))
|
|
})
|
|
|
|
it("prev handles step expressions across day boundary", () => {
|
|
const tz = DateTime.zoneMakeNamedUnsafe("UTC")
|
|
const cron = Cron.parseUnsafe("0 */7 8-10 * * *", tz)
|
|
const before = new Date("2024-01-01T08:01:00.000Z")
|
|
deepStrictEqual(prev(cron, before), new Date("2024-01-01T08:00:00.000Z"))
|
|
})
|
|
|
|
it("prev works with fixed offset time zones", () => {
|
|
const offset = DateTime.zoneMakeOffset(2 * 60 * 60 * 1000)
|
|
const cron = Cron.parseUnsafe("0 0 10 * * *", offset)
|
|
const before = new Date("2024-05-01T07:00:00.000Z")
|
|
deepStrictEqual(prev(cron, before), new Date("2024-04-30T08:00:00.000Z"))
|
|
})
|
|
|
|
it("prev wraps across year boundary", () => {
|
|
const tz = DateTime.zoneMakeNamedUnsafe("UTC")
|
|
const cron = Cron.parseUnsafe("0 0 1 1 *", tz)
|
|
const from = new Date("2024-01-01T00:00:00.000Z")
|
|
deepStrictEqual(prev(cron, from), new Date("2023-01-01T00:00:00.000Z"))
|
|
})
|
|
|
|
it("prev handles day 31 skipping months without it", () => {
|
|
const tz = DateTime.zoneMakeNamedUnsafe("UTC")
|
|
const cron = Cron.parseUnsafe("0 0 31 * *", tz)
|
|
const from = new Date("2024-03-01T00:00:00.000Z")
|
|
deepStrictEqual(prev(cron, from), new Date("2024-01-31T00:00:00.000Z"))
|
|
})
|
|
|
|
it("prev clamps to the last valid day when rolling back a month with only month constraints", () => {
|
|
const tz = DateTime.zoneMakeNamedUnsafe("UTC")
|
|
const cron = Cron.parseUnsafe("0 0 0 * FEB *", tz)
|
|
const from = new Date("2024-03-31T12:00:00.000Z")
|
|
deepStrictEqual(prev(cron, from), new Date("2024-02-29T00:00:00.000Z"))
|
|
})
|
|
|
|
it("prev with multiple months specified", () => {
|
|
const tz = DateTime.zoneMakeNamedUnsafe("UTC")
|
|
const cron = Cron.parseUnsafe("0 0 15 1,4,7,10 *", tz)
|
|
const from = new Date("2024-05-01T00:00:00.000Z")
|
|
deepStrictEqual(prev(cron, from), new Date("2024-04-15T00:00:00.000Z"))
|
|
})
|
|
|
|
it("sequence", () => {
|
|
const start = new Date("2024-01-01 00:00:00")
|
|
const generator = Cron.sequence(Cron.parseUnsafe("23 0-20/2 * * 0"), start)
|
|
deepStrictEqual(generator.next().value, new Date("2024-01-07 00:23:00"))
|
|
deepStrictEqual(generator.next().value, new Date("2024-01-07 02:23:00"))
|
|
deepStrictEqual(generator.next().value, new Date("2024-01-07 04:23:00"))
|
|
deepStrictEqual(generator.next().value, new Date("2024-01-07 06:23:00"))
|
|
deepStrictEqual(generator.next().value, new Date("2024-01-07 08:23:00"))
|
|
})
|
|
|
|
it("equal", () => {
|
|
const cron = Cron.parseUnsafe("23 0-20/2 * * 0")
|
|
assertTrue(Equal.equals(cron, cron))
|
|
assertTrue(Equal.equals(cron, Cron.parseUnsafe("23 0-20/2 * * 0")))
|
|
assertFalse(Equal.equals(cron, Cron.parseUnsafe("23 0-20/2 * * 1")))
|
|
assertFalse(Equal.equals(cron, Cron.parseUnsafe("23 0-20/2 * * 0-6")))
|
|
assertFalse(Equal.equals(cron, Cron.parseUnsafe("23 0-20/2 1 * 0")))
|
|
})
|
|
|
|
it("handles leap years", () => {
|
|
assertTrue(match("0 0 29 2 *", new Date("2024-02-29 00:00:00")))
|
|
assertFalse(match("0 0 29 2 *", new Date("2025-02-29 00:00:00")))
|
|
assertFalse(match("0 0 29 2 *", new Date("2026-02-29 00:00:00")))
|
|
assertFalse(match("0 0 29 2 *", new Date("2027-02-29 00:00:00")))
|
|
assertTrue(match("0 0 29 2 *", new Date("2028-02-29 00:00:00")))
|
|
|
|
deepStrictEqual(next("0 0 29 2 *", new Date("2024-01-01 00:00:00")), new Date("2024-02-29 00:00:00"))
|
|
deepStrictEqual(next("0 0 29 2 *", new Date("2025-01-01 00:00:00")), new Date("2028-02-29 00:00:00"))
|
|
})
|
|
|
|
it("handles transition into daylight savings time", () => {
|
|
const make = (date: string): DateTime.Zoned => Option.getOrThrow(DateTime.makeZonedFromString(date))
|
|
const sequence = Cron.sequence(
|
|
Cron.parseUnsafe("30 * * * *", "Europe/Berlin"),
|
|
make("2024-03-31T00:00:00.000+01:00[Europe/Berlin]")
|
|
)
|
|
const next = (): DateTime.Zoned => DateTime.makeZonedUnsafe(sequence.next().value, { timeZone: "Europe/Berlin" })
|
|
|
|
const a = make("2024-03-31T00:30:00.000+01:00[Europe/Berlin]")
|
|
const b = make("2024-03-31T01:30:00.000+01:00[Europe/Berlin]")
|
|
const c = make("2024-03-31T03:30:00.000+02:00[Europe/Berlin]")
|
|
const d = make("2024-03-31T04:30:00.000+02:00[Europe/Berlin]")
|
|
|
|
deepStrictEqual(next().pipe(DateTime.formatIsoZoned), a.pipe(DateTime.formatIsoZoned))
|
|
deepStrictEqual(next().pipe(DateTime.formatIsoZoned), b.pipe(DateTime.formatIsoZoned))
|
|
deepStrictEqual(next().pipe(DateTime.formatIsoZoned), c.pipe(DateTime.formatIsoZoned))
|
|
deepStrictEqual(next().pipe(DateTime.formatIsoZoned), d.pipe(DateTime.formatIsoZoned))
|
|
})
|
|
|
|
it("handles transition out of daylight savings time", () => {
|
|
// DST fall-back transition in Europe/Berlin on 2024-10-27:
|
|
// At 3:00 AM +02:00, clocks "fall back" to 2:00 AM +01:00
|
|
// This means times from 2:00-2:59 AM occur twice (ambiguous period)
|
|
//
|
|
// Correct "once" mode behavior for cron:
|
|
// - Include all normal times (00:30, 01:30)
|
|
// - Return first occurrence only of ambiguous times (02:30 +02:00)
|
|
// - Skip second occurrence of ambiguous times (02:30 +01:00)
|
|
// - Continue normally after transition (03:30 +01:00)
|
|
|
|
const make = (date: string): DateTime.Zoned => Option.getOrThrow(DateTime.makeZonedFromString(date))
|
|
const sequence = Cron.sequence(
|
|
Cron.parseUnsafe("30 * * * *", "Europe/Berlin"),
|
|
make("2024-10-27T00:00:00.000+02:00[Europe/Berlin]")
|
|
)
|
|
const next = (): DateTime.Zoned => DateTime.makeZonedUnsafe(sequence.next().value, { timeZone: "Europe/Berlin" })
|
|
|
|
const a = make("2024-10-27T00:30:00.000+02:00[Europe/Berlin]") // Normal time
|
|
const b = make("2024-10-27T01:30:00.000+02:00[Europe/Berlin]") // Normal time (not ambiguous)
|
|
const c = make("2024-10-27T02:30:00.000+02:00[Europe/Berlin]") // First occurrence during DST
|
|
const d = make("2024-10-27T03:30:00.000+01:00[Europe/Berlin]") // Standard time (skips 2nd 02:30)
|
|
const e = make("2024-10-27T04:30:00.000+01:00[Europe/Berlin]") // Standard time
|
|
|
|
deepStrictEqual(next().pipe(DateTime.formatIsoZoned), a.pipe(DateTime.formatIsoZoned))
|
|
deepStrictEqual(next().pipe(DateTime.formatIsoZoned), b.pipe(DateTime.formatIsoZoned))
|
|
deepStrictEqual(next().pipe(DateTime.formatIsoZoned), c.pipe(DateTime.formatIsoZoned))
|
|
deepStrictEqual(next().pipe(DateTime.formatIsoZoned), d.pipe(DateTime.formatIsoZoned))
|
|
deepStrictEqual(next().pipe(DateTime.formatIsoZoned), e.pipe(DateTime.formatIsoZoned))
|
|
})
|
|
|
|
it("handles utc timezone", () => {
|
|
const utc = DateTime.zoneMakeNamedUnsafe("UTC")
|
|
const make = (date: string): DateTime.Zoned => Option.getOrThrow(DateTime.makeZonedFromString(date))
|
|
const sequence = Cron.sequence(Cron.parseUnsafe("30 * * * *", utc), make("2024-10-27T00:00:00.000+00:00[UTC]"))
|
|
const next = (): DateTime.Zoned => DateTime.makeZonedUnsafe(sequence.next().value, { timeZone: utc })
|
|
|
|
const a = make("2024-10-27T00:30:00.000+00:00[UTC]")
|
|
const b = make("2024-10-27T01:30:00.000+00:00[UTC]")
|
|
const c = make("2024-10-27T02:30:00.000+00:00[UTC]")
|
|
const d = make("2024-10-27T03:30:00.000+00:00[UTC]")
|
|
|
|
deepStrictEqual(next().pipe(DateTime.formatIsoZoned), a.pipe(DateTime.formatIsoZoned))
|
|
deepStrictEqual(next().pipe(DateTime.formatIsoZoned), b.pipe(DateTime.formatIsoZoned))
|
|
deepStrictEqual(next().pipe(DateTime.formatIsoZoned), c.pipe(DateTime.formatIsoZoned))
|
|
deepStrictEqual(next().pipe(DateTime.formatIsoZoned), d.pipe(DateTime.formatIsoZoned))
|
|
})
|
|
})
|