diff --git a/Project.toml b/Project.toml index 867fb91..0e9746d 100644 --- a/Project.toml +++ b/Project.toml @@ -1,7 +1,7 @@ name = "JobSchedulers" uuid = "eeff360b-c02d-44d3-ab26-4013c616a17e" authors = ["Jiacheng Chuan "] -version = "0.10.7" +version = "0.11.0" [deps] DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" diff --git a/docs/src/changelog.md b/docs/src/changelog.md index dd5c4e7..21ca1e5 100644 --- a/docs/src/changelog.md +++ b/docs/src/changelog.md @@ -1,5 +1,9 @@ # Changelog +v0.11.0 + +- Fix and compat: `Cron` has been rewritten based on the standard crontab, including its bug described [here](https://crontab.guru/cron-bug.html). + v0.10.7 - Fix: crash when showing progress meter after all jobs finished while stdout/sterr are redirected to files. Remove call to legacy `queue_summary`, which was replaced a while ago. diff --git a/src/JobSchedulers.jl b/src/JobSchedulers.jl index 4d12a7d..a76d9d2 100644 --- a/src/JobSchedulers.jl +++ b/src/JobSchedulers.jl @@ -1,6 +1,8 @@ module JobSchedulers +using Revise + using Reexport using Base.Threads @reexport using Dates diff --git a/src/job_recur.jl b/src/job_recur.jl index 2dcfac4..8ecf914 100644 --- a/src/job_recur.jl +++ b/src/job_recur.jl @@ -6,6 +6,7 @@ struct Cron day_of_month::UInt64 month::UInt64 day_of_week::UInt64 + union_of_days::Bool # union or intersect day of month and day of week. It is same as Crontab's behaviors: How a cron bug became the de-facto standard (https://crontab.guru/cron-bug.html) function Cron(second, minute, hour, day_of_month, month, day_of_week) new( cron_value_parse(second), @@ -13,7 +14,8 @@ struct Cron cron_value_parse(hour), cron_value_parse(day_of_month), cron_value_parse(month), - cron_value_parse(day_of_week) + cron_value_parse(day_of_week), + is_union_of_days(day_of_month, day_of_week) ) end end @@ -29,9 +31,9 @@ end day_of_week = '*', ) -`Cron` stores the schedule of a repeative `Job`, inspired by Linux-based `crontab`(5) table. +`Cron` stores the schedule of a repeative `Job`, implemented according to Linux-based `crontab`(5) table. -Jobs are executed by JobScheduler when the second, minute, and hour fields match the current time, and when at least one of the two day fields (day of month & month, or day of week) match the current time. +Jobs are executed when the **second, minute, hour and month** fields match the current time. If neither `day_of_month` nor `day_of_week` **starts with** `*`, cron takes the union (∪) of their values `day_of_month ∪ day_of_week`. Otherwise cron takes the intersection (∩) of their values `day_of_month ∩ day_of_week`. ## When an argument is an `Int64`: @@ -44,6 +46,11 @@ Jobs are executed by JobScheduler when the second, minute, and hour fields match | `month` | 1-12 | | `day_of_week` | 1-7 (1 is Monday) | +!!! compat "Diff between Linux crontab" + 1. Typical Linux distributions do not have `second` filed as JobSchedulers. + 2. Sunday is only coded `7` in JobSchedulers, while it is `0` or `7` in Linux, so the behaviors like `day_of_week = "*/2"` are different in two systems. + 3. From JobSchedulers v0.11, `Cron` has been rewritten based on the standard crontab, including its bug described [here](https://crontab.guru/cron-bug.html). + ## When an argument is a `String` or `Char`: An argument may be an asterisk (`*`), which always stands for ``first-last``. @@ -134,10 +141,32 @@ const stepmasks = map(1:64) do step final end +""" + is_union_of_days(day_of_month, day_of_week) :: Bool + +Wether we choose union or intersection of `day_of_month` and `day_of_week`. + +To adapt with Crontab's behavior: How a cron bug became the de-facto standard (https://crontab.guru/cron-bug.html) +""" +function is_union_of_days(day_of_month, day_of_week) + asterisk_dom = first_is_asterisk(day_of_month) + asterisk_dow = first_is_asterisk(day_of_week) + + do_intersect = asterisk_dom || asterisk_dow + return !do_intersect +end + +first_is_asterisk(value::Integer) = false +first_is_asterisk(value::Char) = value == '*' +first_is_asterisk(value::Function) = value == * +first_is_asterisk(value::String) = length(value) >= 1 && value[1] == '*' +first_is_asterisk(value::Vector) = length(value) >= 1 && first_is_asterisk(value[1]) + + """ cron_value_parse(value::UInt64) - cron_value_parse(value::Int64) - cron_value_parse(value::String) + cron_value_parse(value::Signed) + cron_value_parse(value::AbstractString) cron_value_parse(value::Char) cron_value_parse(value::Vector) cron_value_parse(*) = cron_value_parse('*') @@ -147,9 +176,9 @@ Parse crontab-like value to `UInt64`. See details: [`Cron`](@ref). @inline function cron_value_parse(value::UInt64) value end -@inline function cron_value_parse(value::Int64) +@inline function cron_value_parse(value::Signed) if value > 60 || value < 0 - error("Cron: cannot parse $value::Int64: out of range.") + error("Cron: cannot parse $value::Signed: out of range.") end 0x0000000000000001 << value end @@ -160,29 +189,33 @@ end error("Cron: cannot parse $value::Function: invalid. Only * is allowed.") end end -@inline function cron_value_parse(value::String) # "0-4,8-12,5/2" +@inline function cron_value_parse(value::AbstractString) # "0-4,8-12,5/2" + value = replace(value, r"[ \t]" => "") if value == "*" return 0xffffffffffffffff end - m = match(r"^([\d\-\,]+|\*)(/(\d+))?$", value) - if isnothing(m) - error("Cron: cannot parse $value: not a crontab value format. Example: * */2 1,5,7 1-4,8 1-6,8-9/3") - end - ranges = m.captures[1] # "0-4,8-12,5" - steps = m.captures[3] # nothing or "2" + values = split(value, ",") - if isnothing(steps) - return cron_value_ranges_parse(ranges) + uint = UInt64(0) + for v in values + uint |= _cron_value_parse(v) end - step = parse(Int, steps) - if step > 64 || step < 1 - error("Cron: cannot parse /$step in $value: valid step range is 1-64.") + uint +end + +function _cron_value_parse(value::AbstractString) # 0-4 5-8/2 5/2 */2 + m = match(r"^(\d+|\*)(-(\d+))?(/(\d+))?$", value) + + if isnothing(m) + error("Cron: cannot parse $value: not a crontab value format. Example: * 5 */2 1-12/4 5/2. They can be combined with comma (,) to form a union.") end - range_uint = cron_value_ranges_parse(ranges) - offset = trailing_zeros(range_uint) - step_mask = stepmasks[step] << offset - return range_uint & step_mask + start = m.captures[1] # int or * + stop = m.captures[3] # nothing or int + step = m.captures[5] # nothing or int + + uint = cron_range_and_step_parse(start, stop, step) + return uint end @inline function cron_value_parse(value::Char) # "0-4,8-12,5/2" @@ -192,31 +225,39 @@ end cron_value_parse(string(value)) end -function cron_value_ranges_parse(ranges::SubString{String}) - if ranges == "*" - return 0xffffffffffffffff - end - range_split = split(ranges, ",") - final = 0x0000000000000000 - for i in range_split - i_split = split(i, '-') - if length(i_split) == 1 - v = parse(Int, i_split[1]) - final = final | cron_value_parse(v) - elseif length(i_split) == 2 - start = parse(Int, i_split[1]) - stop = parse(Int, i_split[2]) - if start > stop - error("Cron: cannot parse $i in $ranges: only - is allowed. Example: 1-2 3-6") - end - bit_value = ~(0xffffffffffffffff << (stop + 1)) & (0xffffffffffffffff << start) - final = final | bit_value - else - error("Cron: cannot parse $i in $ranges: only - is allowed. Example: 1-2 3-6") - end +function cron_range_and_step_parse(start::AbstractString, stop::Nothing, step::Nothing) + start == "*" && (return 0xffffffffffffffff) + cron_value_parse(parse(Int, start)) +end +function cron_range_and_step_parse(start::AbstractString, stop::AbstractString, step::Nothing) + stop_int = parse(Int, stop) + + if start == "*" + start_uint = 0xffffffffffffffff + else + start_int = parse(Int, start) + @assert start_int <= stop_int "Cron: cannot parse range $start-$stop because start > stop." + + start_uint = 0xffffffffffffffff << start_int end - final + + ~(0xffffffffffffffff << (stop_int + 1)) & start_uint end +function cron_range_and_step_parse(start::AbstractString, stop::AbstractString, step::AbstractString) + range_uint = cron_range_and_step_parse(start, stop, nothing) + step_int = parse(Int, step) + + @assert 1 <= step_int <= 64 "Cron: cannot parse step /$step because step must from 1 to 64." + + offset = trailing_zeros(range_uint) + step_mask = stepmasks[step_int] << offset + range_uint & step_mask +end +function cron_range_and_step_parse(start::AbstractString, stop::Nothing, step::AbstractString) + # regard as start-all/step + cron_range_and_step_parse(start, "64", step) +end + function cron_value_parse(value::Vector) final = 0x0000000000000000 @@ -236,7 +277,6 @@ const cron_none = Cron(:none) Jobs are executed by JobScheduler when the second, minute, hour, and month of year fields match the current time, and when at least one of the two day fields (day of month, or day of week) match the current time. """ function Dates.tonext(dt::DateTime, c::Cron; same::Bool = false) - now_date = Date(dt) if is_valid_day(now_date, c) # today @@ -248,7 +288,7 @@ function Dates.tonext(dt::DateTime, c::Cron; same::Bool = false) end end - # next_available_day + # next available day next_date = tonext(now_date, c) if isnothing(next_date) return nothing @@ -274,41 +314,21 @@ function Dates.tonext(t::Time, c::Cron; same::Bool = false) Time(hr, min, sec) end -function tonext_dayofweek(d::Date, c::Cron; same::Bool = false) - dow = bitfindnext(c.day_of_week, dayofweek(d) + !same, 1:7) - if isnothing(dow) - return nothing # check month-day - else - num_day = dow - dayofweek(d) - if num_day < 0 - num_day += 7 - elseif num_day == 0 && !same - num_day += 7 - end - if same && num_day == 0 - return d - elseif !same && num_day == 1 - return d + Day(1) - end - return d + Day(num_day) - end -end +function Dates.tonext(d::Date, c::Cron; same::Bool = false, limit::Date = d + Day(1000)) + # same month? + mon = bitfindnext(c.month, 1, 1:12) # can be same month -function tonext_monthday(d::Date, c::Cron; same::Bool = false, limit::Date = d + Day(3000)) - # day of month invalid? - dom = bitfindnext(c.day_of_month, 1, 1:31) - mon = bitfindnext(c.month, 1, 1:12) - if isnothing(dom) || isnothing(mon) + if mon === nothing return nothing end # stepwise - if same && is_valid_month_day(d, c) + if same && is_valid_day(d, c) return d end d2 = d + Day(1) while d2 <= limit - if is_valid_month_day(d2, c) + if is_valid_day(d2, c) return d2 else d2 += Day(1) @@ -317,44 +337,6 @@ function tonext_monthday(d::Date, c::Cron; same::Bool = false, limit::Date = d + return nothing end -function Dates.tonext(d::Date, c::Cron; same::Bool = false) - based = date_based_on(c) - if based == :everyday - return (same ? d : d + Day(1)) - elseif based == :monthday - return tonext_monthday(d, c; same = same) - elseif based == :dayofweek - return tonext_dayofweek(d, c; same = same) - elseif based == :both - next_dow = tonext_dayofweek(d, c; same = same) - if (same && next_dow == d) || (!same && next_dow == d + Day(1)) - return next_dow - end - next_md = tonext_monthday(d, c; same = same, limit = next_dow) - return (next_md < next_dow ? next_md : next_dow) - else # :none - return nothing - end -end - - -@inline function is_same_time(dt::DateTime, sec::Int, min::Int, hr::Int) - sec == second(dt) && min == minute(dt) && hr == hour(dt) -end - -@inline function is_time_larger(dt::DateTime, sec::Int, min::Int, hr::Int) - if hr > hour(dt) - return true - elseif hr < hour(dt) - return false - end - if min > minute(dt) - return true - elseif min < minute(dt) - return false - end - return sec > second(dt) -end @inline function is_every_second(c::Cron) c.second & 0x0fffffffffffffff == 0x0fffffffffffffff @@ -368,10 +350,6 @@ end c.hour & 0x0000000000ffffff == 0x0000000000ffffff end -@inline function is_same_day(dt::DateTime, dom, mon, dow) - dow == dayofweek(dt) || (dom == day(dt) && mon == month(dt)) -end - @inline function is_every_day_of_week(c::Cron) c.day_of_week & 0x00000000000000fe == 0x00000000000000fe end @@ -379,24 +357,20 @@ end c.day_of_week & 0x00000000000000fe == 0 end -@inline function is_every_month(c::Cron) - (c.month & 0x0000000000001ffe == 0x0000000000001ffe) -end -@inline function is_every_day_of_month(c::Cron) - (c.day_of_month & 0x00000000fffffffe == 0x00000000fffffffe) -end -@inline function is_every_month_day(c::Cron) - is_every_month(c) && is_every_day_of_month(c) +@inline function is_every_day_of_month(c::Cron) + c.day_of_month & 0x00000000fffffffe == 0x00000000fffffffe end -@inline function is_none_month_day(c::Cron) - (c.month & 0x0000000000001ffe == 0) || (c.day_of_month & 0x00000000fffffffe == 0) +@inline function is_none_day_of_month(c::Cron) + c.day_of_month & 0x00000000fffffffe == 0 end +@inline function is_every_month(c::Cron) + (c.month & 0x0000000000001ffe == 0x0000000000001ffe) +end - -@inline function is_every_day(c::Cron) - is_every_day_of_week(c) || is_every_month_day(c) +@inline function is_none_month(c::Cron) + c.month & 0x0000000000001ffe == 0 end @inline function is_one_at(uint::UInt64, idx::Int64) @@ -404,55 +378,80 @@ end uint & x == x end +""" + is_valid_day(dt::Date, c::Cron) + +Is `dt` is a valid day matching `c`? +""" function is_valid_day(dt::Date, c::Cron) - if is_one_at(c.day_of_week, dayofweek(dt)) - return true + is_month_valid = is_one_at(c.month, month(dt)) + + if !is_month_valid + return false end - is_one_at(c.month, month(dt)) && is_one_at(c.day_of_month, day(dt)) -end -is_valid_day(dt::DateTime, c::Cron) = is_valid_day(Date(dt), c) -function is_valid_month_day(dt::Date, c::Cron) - is_one_at(c.month, month(dt)) && is_one_at(c.day_of_month, day(dt)) + is_dow_valid = is_one_at(c.day_of_week, dayofweek(dt)) + is_dom_valid = is_one_at(c.day_of_month, day(dt)) + + if c.union_of_days + is_dow_valid || is_dom_valid + else + is_dow_valid && is_dom_valid + end end +is_valid_day(dt::DateTime, c::Cron) = is_valid_day(Date(dt), c) """ date_based_on(c::Cron) -> Symbol -Whether date of `c` is based on `:dayofweek`, `:monthday`, `:everyday`, `:both`, or `:none`. +Whether date of `c` is based on `:day_of_week`, `:day_of_month`, `:union`, `:intersect`, `:everyday`, `:none`, or `:undefined`. """ function date_based_on(c::Cron) - if is_every_day_of_week(c) - if is_every_month_day(c) - :everyday + if is_none_month(c) + return :none + end + + none_dow = is_none_day_of_week(c) + none_dom = is_none_day_of_month(c) + if c.union_of_days + none_dow && none_dom && (return :none) + else # intersect + (none_dow || none_dom) && (return :none) + end + + all_dow = is_every_day_of_week(c) + all_dom = is_every_day_of_month(c) + + some_dow = !(all_dow || none_dow) + some_dom = !(all_dom || none_dom) + + if c.union_of_days + # any dow or dom + if all_dow || all_dom + return :everyday + elseif some_dow && some_dom + return :union + elseif some_dow + return :day_of_week + elseif some_dom + return :day_of_month else - if is_none_month_day(c) - :everyday - else - :monthday - end + @warn "Undefined: Please report a bug with the Cron info. Thank you." c + return :undefined end - else # dow - if is_every_month_day(c) - if is_none_day_of_week(c) - :everyday - else - :dayofweek - end + else + # both match dow and dom + if all_dow && all_dom + return :everyday + elseif all_dow && some_dom + return :day_of_month + elseif some_dow && all_dom + return :day_of_week + elseif some_dow && some_dom + return :intersect else - if is_none_day_of_week(c) - if is_none_month_day(c) - :none - else - :monthday - end - else - if is_none_month_day(c) - :dayofweek - else - :both - end - end + @warn "Undefined: Please report a bug with the Cron info. Thank you." c + return :undefined end end end @@ -472,7 +471,8 @@ function get_time_description(c::Cron) hours = bitsfind(c.hour, 0:23, empty_add_0 = true) if length(seconds) == length(minutes) == length(hours) == 1 - return "at $(hours[1]):$(minutes[1]):$(seconds[1])" + + return "at $(Time(hours[1], minutes[1], seconds[1]))" end str = if every_min @@ -492,7 +492,9 @@ function get_time_description(c::Cron) end end - if !every_hour + if every_hour + str *= " past every hour" + else hour_str = get_hour_description(hours) str *= " past $hour_str" end @@ -505,7 +507,7 @@ function get_second_description(seconds::Vector{Int64}) elseif length(seconds) == 1 "$(seconds[1]) second" else - str = join(seconds, ",") + str = join(seconds, ", ", " and ") "$(str) seconds" end end @@ -515,7 +517,7 @@ function get_minute_description(minutes::Vector{Int64}) elseif length(minutes) == 1 "$(minutes[1]) minute" else - str = join(minutes, ",") + str = join(minutes, ", ", " and ") "$(str) minutes" end end @@ -525,33 +527,56 @@ function get_hour_description(hours::Vector{Int64}) elseif length(hours) == 1 "$(hours[1]) hour" else - str = join(hours, ",") + str = join(hours, ", ", " and ") "$(str) hours" end end function get_date_description(c::Cron) based = date_based_on(c) + str = "" if based === :everyday - return "" - elseif based === :dayofweek - dow_str = get_dow_description(c) - return "on $dow_str" - elseif based === :monthday - monthday_str = get_monthday_description(c) - return monthday_str + str *= "everyday" + elseif based === :day_of_week + str *= get_dow_description(c) + elseif based === :day_of_month + str *= get_dom_description(c) + elseif based === :union + str *= get_dom_description(c) * " and " * get_dow_description(c) + elseif based === :intersect + str *= get_dom_description(c) * " if it's " * get_dow_description(c) + elseif based === :none + str *= "no repeated date" + end + + # month + if is_every_month(c) + nothing else - dow_str = get_dow_description(c) - monthday_str = get_monthday_description(c) - return "on $dow_str or $monthday_str" + str *= get_month_description(c) end + + str end function get_dow_description(c::Cron) dows = bitsfind(c.day_of_week, 1:7) human_readables = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] dows2 = human_readables[dows] - str = join(dows2, ",") + return "on " * join(dows2, ", ", " and ") +end + +function get_month_description(c::Cron) + months = bitsfind(c.month, 1:12) + human_readables = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + months2 = human_readables[months] + return " in " * join(months2, ", ", " and ") +end + +function get_dom_description(c::Cron) + days = bitsfind(c.day_of_month, 1:31) + day_str = join(days, ", ", " and ") + return "on day-of-month $day_str" end function get_monthday_description(c::Cron) @@ -560,19 +585,19 @@ function get_monthday_description(c::Cron) "" else days = bitsfind(c.day_of_month, 1:31) - day_str = join(days, ",") + day_str = join(days, ", ", " and ") "on day-of-month $day_str" end else months = bitsfind(c.month, 1:12) human_readables = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] months2 = human_readables[months] - mon_str = join(months2, ",") + mon_str = join(months2, ", ", " and ") if is_every_day_of_month(c) "everyday in $mon_str" else days = bitsfind(c.day_of_month, 1:31) - day_str = join(days, ",") + day_str = join(days, ", ", " and ") "on day-of-month $day_str in $mon_str" end end diff --git a/src/pretty_print.jl b/src/pretty_print.jl index 3ef5411..2f062fd 100644 --- a/src/pretty_print.jl +++ b/src/pretty_print.jl @@ -217,6 +217,7 @@ simplify(x::Symbol, detail::Bool = false) = ":$x" simplify(x::Int, detail::Bool = false) = string(x) simplify(x::AbstractString, detail::Bool = false) = "\"$x\"" simplify(x::Float64, detail::Bool = false) = string(round(x, digits=1)) + function simplify(x::DateTime, detail::Bool = false) if Date(x) == today() Dates.format(x, dateformat"HH:MM:SS") @@ -228,6 +229,7 @@ function simplify(x::DateTime, detail::Bool = false) Dates.format(x, dateformat"yyyy-mm-dd HH:MM:SS") end end + function simplify(deps::Vector{Pair{Symbol,Union{Int64, Job}}}, detail::Bool = false) n_dep = length(deps) if n_dep == 0 @@ -244,7 +246,9 @@ function simplify(deps::Vector{Pair{Symbol,Union{Int64, Job}}}, detail::Bool = f "$n_dep jobs" end end + simplify(x::Task, detail::Bool = false) = "Task" + function simplify(c::Cron, detail::Bool = false) date_based = date_based_on(c) if date_based === :none @@ -261,6 +265,7 @@ function simplify(c::Cron, detail::Bool = false) return "Cron($time_str $date_str)" end end + simplify(x, detail::Bool = false) = string(x) @eval function simplify_memory(mem::Int, detail::Bool = false) diff --git a/test/recur.jl b/test/recur.jl index addb97e..c5da9a2 100644 --- a/test/recur.jl +++ b/test/recur.jl @@ -13,8 +13,9 @@ @test JobSchedulers.cron_value_parse("*/1") == 0xffffffffffffffff @test JobSchedulers.cron_value_parse("*") == 0xffffffffffffffff @test JobSchedulers.cron_value_parse("1-5,7,7,4") == 0x00000000000000be - @test JobSchedulers.cron_value_parse("1-5,7,7,4/2") == 0x00000000000000aa + @test JobSchedulers.cron_value_parse("1-5,7,7,4/2") == 0x55555555555555fe @test JobSchedulers.cron_value_parse("0-5,7,7,4") == 0x00000000000000bf + @test JobSchedulers.cron_value_parse("5/2") == 0xaaaaaaaaaaaaaaa0 @test JobSchedulers.cron_value_parse('*') == 0xffffffffffffffff @test JobSchedulers.cron_value_parse([1,3,5, "7,9"]) == 0x00000000000002aa @test JobSchedulers.cron_value_parse([1,3,5, "*"]) == 0xffffffffffffffff @@ -38,14 +39,14 @@ for c2 in (Cron(:yearly), Cron(0,0,0,1,1,0)) - @test JobSchedulers.date_based_on(c2) == :monthday + @test JobSchedulers.date_based_on(c2) == :day_of_month @test JobSchedulers.tonext(Date(2023,1,2), c2) == Date(2024,1,1) @test JobSchedulers.tonext(Date(2023,1,1), c2) == Date(2024,1,1) @test JobSchedulers.tonext(Date(2023,1,1), c2, same=true) == Date(2023,1,1) end for c3 in (Cron(:monthly), Cron(0,0,0,1,*,0)) - @test JobSchedulers.date_based_on(c3) == :monthday + @test JobSchedulers.date_based_on(c3) == :day_of_month @test JobSchedulers.tonext(Date(2023,1,2), c3) == Date(2023,2,1) @test JobSchedulers.tonext(Date(2023,1,1), c3) == Date(2023,2,1) @test JobSchedulers.tonext(Date(2023,1,1), c3, same=true) == Date(2023,1,1) @@ -53,8 +54,8 @@ @test JobSchedulers.tonext(Date(2023,12,1), c3) == Date(2024,1,1) end - for c4 in (Cron(:weekly), Cron(0,0,0,0,0,1)) - @test JobSchedulers.date_based_on(c4) == :dayofweek + for c4 in (Cron(:weekly), Cron(0,0,0,0,*,1)) + @test JobSchedulers.date_based_on(c4) == :day_of_week @test JobSchedulers.tonext(Date(2023,1,2), c4) == Date(2023,1,9) @test JobSchedulers.tonext(Date(2023,1,2), c4, same=true) == Date(2023,1,2) @test JobSchedulers.tonext(Date(2023,1,1), c4, same=true) == Date(2023,1,2) @@ -86,6 +87,20 @@ @test JobSchedulers.tonext(DateTime(2023,1,2,12,30,00), c8, same=true) == DateTime(2023,1,2,12,30,00) @test JobSchedulers.tonext(DateTime(2023,1,2,12,30,50), c8, same=false) == DateTime(2023,1,2,12,31,00) @test JobSchedulers.tonext(DateTime(2023,1,2,12,30,55), c8, same=false) == DateTime(2023,1,2,12,31,00) + + + # 24 0 1-31/10 1 */2 + c9 = Cron(0,24, 0, "1-31/10", 1, "*/2") + dt = DateTime(2025,1,1,0,12,0) + dt = JobSchedulers.tonext(dt, c9) + @test dt == DateTime(2025,1,11,0,24,0) + dt = JobSchedulers.tonext(dt, c9) + @test dt == DateTime(2025,1,21,0,24,0) + dt = JobSchedulers.tonext(dt, c9) + @test dt == DateTime(2026,1,1,0,24,0) + dt = JobSchedulers.tonext(dt, c9) + @test dt == DateTime(2026,1,31,0,24,0) + end