daemon/internal/jobs: Add a cron expression parser
Add the five-field cron grammar of schedule triggers: numbers, lists, ranges and steps. Names, shortcuts and Quartz extensions are left to clients. Day-of-month and day-of-week combine following Vixie's rule. Occurrences follow the wall clock of the job's timezone: a time skipped by a DST transition does not fire that day, and a repeated one fires once. The search is bounded by the 400-year Gregorian cycle, so impossible dates never fire while sparse ones such as February 29 on a Sunday still do. Zone data is embedded, so timezones resolve on hosts without a zoneinfo database, such as Windows. Co-authored-by: Paweł Gronowski <pawel.gronowski@docker.com> Signed-off-by: Guillaume Lours <glours@users.noreply.github.com> Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
This commit is contained in:
1 parent
59d61f0d0a
commit
093882dc71
2 files changed
+469
No files matched your search
@@ -0,0 +1,244 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
// Schedules name IANA timezones and must evaluate the same on every
|
||||
// host, including Windows and minimal images without zoneinfo. The
|
||||
// system database still takes precedence where it exists.
|
||||
_ "time/tzdata"
|
||||
)
|
||||
|
||||
// cronSchedule is a parsed five-field crontab expression. Each set is a
|
||||
// bitmask of the accepted values for its field.
|
||||
type cronSchedule struct {
|
||||
minutes uint64 // 0-59
|
||||
hours uint64 // 0-23
|
||||
dom uint64 // 1-31, day of month
|
||||
months uint64 // 1-12
|
||||
dow uint64 // 0-6, day of week, Sunday is 0
|
||||
|
||||
// The day-of-month and day-of-week fields follow the standard crontab
|
||||
// quirk: when both are restricted (neither is *), a day matching either
|
||||
// fires; otherwise the restricted one must match.
|
||||
domRestricted, dowRestricted bool
|
||||
}
|
||||
|
||||
// nextCronBoundYears caps the search for the next occurrence. The Gregorian
|
||||
// calendar, weekdays included, repeats every 400 years, so an expression
|
||||
// with no occurrence in that span never fires, as with impossible dates
|
||||
// such as "0 0 30 2 *"; sparse ones such as "0 0 29 2 */7" (February 29 on
|
||||
// a Sunday) still find occurrences decades apart. The scan jumps over
|
||||
// unmatched months and days, so even a full-span search stays in the
|
||||
// milliseconds.
|
||||
const nextCronBoundYears = 400
|
||||
|
||||
// parseCron parses a five-field crontab expression: minute, hour,
|
||||
// day-of-month, month, day-of-week. Each field accepts numbers, lists
|
||||
// (a,b,c), ranges (a-b) and steps (*/n or a-b/n).
|
||||
//
|
||||
// The wire format is deliberately strict: month and weekday names, @daily
|
||||
// style shortcuts, and Quartz extensions (L, #, W) are rejected. Clients
|
||||
// expand shortcuts before submitting, so the daemon-side grammar can stay
|
||||
// small and unambiguous.
|
||||
func parseCron(expr string) (*cronSchedule, error) {
|
||||
fields := strings.Fields(expr)
|
||||
if len(fields) != 5 {
|
||||
return nil, fmt.Errorf("cron expression must have 5 fields (minute hour day-of-month month day-of-week), got %d", len(fields))
|
||||
}
|
||||
var (
|
||||
schedule cronSchedule
|
||||
err error
|
||||
)
|
||||
if schedule.minutes, err = parseCronField(fields[0], 0, 59); err != nil {
|
||||
return nil, fmt.Errorf("minute field: %w", err)
|
||||
}
|
||||
if schedule.hours, err = parseCronField(fields[1], 0, 23); err != nil {
|
||||
return nil, fmt.Errorf("hour field: %w", err)
|
||||
}
|
||||
if schedule.dom, err = parseCronField(fields[2], 1, 31); err != nil {
|
||||
return nil, fmt.Errorf("day-of-month field: %w", err)
|
||||
}
|
||||
if schedule.months, err = parseCronField(fields[3], 1, 12); err != nil {
|
||||
return nil, fmt.Errorf("month field: %w", err)
|
||||
}
|
||||
if schedule.dow, err = parseCronField(fields[4], 0, 7); err != nil {
|
||||
return nil, fmt.Errorf("day-of-week field: %w", err)
|
||||
}
|
||||
// Both 0 and 7 mean Sunday; fold 7 onto 0 so matching only checks 0-6.
|
||||
if schedule.dow&(1<<7) != 0 {
|
||||
schedule.dow = (schedule.dow &^ (1 << 7)) | 1
|
||||
}
|
||||
// Vixie's first-character rule: a field is "restricted" only when it
|
||||
// does not start with *, so */2 counts as unrestricted for the AND/OR
|
||||
// day-combination choice while its mask still applies.
|
||||
schedule.domRestricted = !strings.HasPrefix(fields[2], "*")
|
||||
schedule.dowRestricted = !strings.HasPrefix(fields[4], "*")
|
||||
return &schedule, nil
|
||||
}
|
||||
|
||||
// parseCronField parses one field into a bitmask of accepted values.
|
||||
func parseCronField(field string, minValue, maxValue int) (uint64, error) {
|
||||
var mask uint64
|
||||
for part := range strings.SplitSeq(field, ",") {
|
||||
rangeExpr, stepExpr, hasStep := strings.Cut(part, "/")
|
||||
step := 1
|
||||
if hasStep {
|
||||
// Steps only make sense over a span; a step on a single value
|
||||
// ("3/5") is a Vixie laxity this grammar deliberately rejects.
|
||||
if rangeExpr != "*" && !strings.Contains(rangeExpr, "-") {
|
||||
return 0, fmt.Errorf("invalid term %q: a step requires * or a range", part)
|
||||
}
|
||||
// Atoi tolerates a leading sign; the grammar is digits only.
|
||||
if stepExpr == "" || stepExpr[0] < '0' || stepExpr[0] > '9' {
|
||||
return 0, fmt.Errorf("invalid step in term %q", part)
|
||||
}
|
||||
parsed, err := strconv.Atoi(stepExpr)
|
||||
if err != nil || parsed <= 0 {
|
||||
return 0, fmt.Errorf("invalid step in term %q", part)
|
||||
}
|
||||
step = parsed
|
||||
}
|
||||
|
||||
lo, hi := minValue, maxValue
|
||||
if rangeExpr != "*" {
|
||||
loExpr, hiExpr, isRange := strings.Cut(rangeExpr, "-")
|
||||
var err error
|
||||
if lo, err = parseCronValue(loExpr, minValue, maxValue); err != nil {
|
||||
return 0, fmt.Errorf("invalid term %q: %w", part, err)
|
||||
}
|
||||
hi = lo
|
||||
if isRange {
|
||||
if hi, err = parseCronValue(hiExpr, minValue, maxValue); err != nil {
|
||||
return 0, fmt.Errorf("invalid term %q: %w", part, err)
|
||||
}
|
||||
if hi < lo {
|
||||
return 0, fmt.Errorf("invalid term %q: range is reversed", part)
|
||||
}
|
||||
}
|
||||
}
|
||||
for v := lo; v <= hi; v += step {
|
||||
mask |= 1 << v
|
||||
// Check the remaining span before adding an untrusted step,
|
||||
// which may be large enough to overflow an int.
|
||||
if step > hi-v {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if mask == 0 {
|
||||
return 0, errors.New("field is empty")
|
||||
}
|
||||
return mask, nil
|
||||
}
|
||||
|
||||
// parseCronValue parses one numeric field value and checks its bounds.
|
||||
func parseCronValue(expr string, minValue, maxValue int) (int, error) {
|
||||
// Atoi tolerates a leading sign; the grammar is digits only.
|
||||
if expr == "" || expr[0] < '0' || expr[0] > '9' {
|
||||
return 0, fmt.Errorf("%q is not a number (names and shortcuts are not supported)", expr)
|
||||
}
|
||||
value, err := strconv.Atoi(expr)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%q is not a number (names and shortcuts are not supported)", expr)
|
||||
}
|
||||
if value < minValue || value > maxValue {
|
||||
return 0, fmt.Errorf("value %d is outside %d-%d", value, minValue, maxValue)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// next returns the first occurrence strictly after the given time, evaluated
|
||||
// on the wall clock of loc, and false when no occurrence exists within the
|
||||
// search bound.
|
||||
//
|
||||
// Daylight-saving transitions follow wall-clock semantics: an occurrence
|
||||
// falling in the skipped hour of a spring-forward day does not fire that day
|
||||
// (the wall-clock time never happens), and an occurrence in the repeated
|
||||
// hour of a fall-back day fires once, at the first occurrence.
|
||||
func (s *cronSchedule) next(after time.Time, loc *time.Location) (time.Time, bool) {
|
||||
t := after.In(loc).Truncate(time.Minute).Add(time.Minute)
|
||||
|
||||
bound := after.AddDate(nextCronBoundYears, 0, 0)
|
||||
for t.Before(bound) {
|
||||
if s.months&(1<<uint(t.Month())) == 0 {
|
||||
// Jump to the first minute of the next month.
|
||||
t = jumpForward(t, time.Date(t.Year(), t.Month()+1, 1, 0, 0, 0, 0, loc))
|
||||
continue
|
||||
}
|
||||
if !s.dayMatches(t) {
|
||||
t = jumpForward(t, time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, loc))
|
||||
continue
|
||||
}
|
||||
if s.hours&(1<<uint(t.Hour())) == 0 {
|
||||
// Advance in absolute time rather than by rebuilding wall-clock
|
||||
// components: across a fall-back transition the rebuilt time is
|
||||
// ambiguous and Go may resolve it to the second occurrence,
|
||||
// skipping the first one; the absolute walk visits both in real
|
||||
// order and the fields are re-checked on every loop anyway.
|
||||
t = t.Add(time.Duration(60-t.Minute()) * time.Minute)
|
||||
continue
|
||||
}
|
||||
if s.minutes&(1<<uint(t.Minute())) == 0 {
|
||||
t = t.Add(time.Minute)
|
||||
continue
|
||||
}
|
||||
// The first pass through a fall-back day's repeated hour already
|
||||
// matched this wall-clock time, so it fires only once.
|
||||
if end, repeated := repeatedWallClock(t); repeated {
|
||||
t = end
|
||||
continue
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// jumpForward returns target, or the minute after t when target is not after
|
||||
// it. Where a transition skips midnight, as in America/Havana, time.Date
|
||||
// normalizes a day's midnight back into the previous day, so jumping to it
|
||||
// would repeat forever; walking minutes reaches the transition instead.
|
||||
func jumpForward(t, target time.Time) time.Time {
|
||||
if target.After(t) {
|
||||
return target
|
||||
}
|
||||
|
||||
return t.Add(time.Minute)
|
||||
}
|
||||
|
||||
// repeatedWallClock reports whether t's wall-clock time already happened
|
||||
// earlier, because a transition just set the clock back, and returns when
|
||||
// the repeated span ends.
|
||||
func repeatedWallClock(t time.Time) (end time.Time, repeated bool) {
|
||||
start, _ := t.ZoneBounds()
|
||||
if start.IsZero() {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
_, offset := t.Zone()
|
||||
_, before := start.Add(-time.Second).Zone()
|
||||
|
||||
setBack := time.Duration(before-offset) * time.Second
|
||||
if setBack <= 0 || t.Sub(start) >= setBack {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
return start.Add(setBack), true
|
||||
}
|
||||
|
||||
// dayMatches applies Vixie cron's day-combination rule: both masks always
|
||||
// apply, combined with OR only when both fields are restricted ("1,15 * Sun"
|
||||
// means the 1st, the 15th AND Sundays) and with AND otherwise (an
|
||||
// unrestricted field's mask is full, so it never excludes anything).
|
||||
func (s *cronSchedule) dayMatches(t time.Time) bool {
|
||||
domOK := s.dom&(1<<uint(t.Day())) != 0
|
||||
dowOK := s.dow&(1<<uint(t.Weekday())) != 0
|
||||
if s.domRestricted && s.dowRestricted {
|
||||
return domOK || dowOK
|
||||
}
|
||||
return domOK && dowOK
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gotest.tools/v3/assert"
|
||||
is "gotest.tools/v3/assert/cmp"
|
||||
)
|
||||
|
||||
func TestParseCron(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
doc string
|
||||
expr string
|
||||
expectErr string
|
||||
}{
|
||||
{doc: "every minute", expr: "* * * * *"},
|
||||
{doc: "daily at 3am", expr: "0 3 * * *"},
|
||||
{doc: "lists ranges steps", expr: "*/15 0-6 1,15 */2 1-5"},
|
||||
{doc: "sunday as 7", expr: "0 12 * * 7"},
|
||||
{doc: "range with step", expr: "10-50/10 * * * *"},
|
||||
|
||||
{doc: "too few fields", expr: "* * * *", expectErr: "must have 5 fields"},
|
||||
{doc: "too many fields", expr: "* * * * * *", expectErr: "must have 5 fields"},
|
||||
{doc: "not a number", expr: "a * * * *", expectErr: "not a number"},
|
||||
{doc: "minute out of range", expr: "60 * * * *", expectErr: "outside 0-59"},
|
||||
{doc: "hour out of range", expr: "* 24 * * *", expectErr: "outside 0-23"},
|
||||
{doc: "day-of-month zero", expr: "* * 0 * *", expectErr: "outside 1-31"},
|
||||
{doc: "month out of range", expr: "* * * 13 *", expectErr: "outside 1-12"},
|
||||
{doc: "weekday out of range", expr: "* * * * 8", expectErr: "outside 0-7"},
|
||||
{doc: "reversed range", expr: "30-10 * * * *", expectErr: "range is reversed"},
|
||||
{doc: "zero step", expr: "*/0 * * * *", expectErr: "invalid step"},
|
||||
{doc: "step on single value", expr: "3/5 * * * *", expectErr: "a step requires * or a range"},
|
||||
{doc: "month name", expr: "* * * jan *", expectErr: "not a number"},
|
||||
{doc: "weekday name", expr: "* * * * mon", expectErr: "not a number"},
|
||||
{doc: "shortcut", expr: "@daily", expectErr: "must have 5 fields"},
|
||||
{doc: "double range", expr: "1-2-3 * * * *", expectErr: "not a number"},
|
||||
{doc: "empty step", expr: "*/ * * * *", expectErr: "invalid step"},
|
||||
{doc: "step without range", expr: "/5 * * * *", expectErr: "a step requires * or a range"},
|
||||
{doc: "open-ended range", expr: "5- * * * *", expectErr: "not a number"},
|
||||
{doc: "open-started range", expr: "-5 * * * *", expectErr: "not a number"},
|
||||
{doc: "empty list entry", expr: "1,,2 * * * *", expectErr: "not a number"},
|
||||
{doc: "leading sign", expr: "+5 * * * *", expectErr: "not a number"},
|
||||
{doc: "leading sign in step", expr: "*/+5 * * * *", expectErr: "invalid step"},
|
||||
} {
|
||||
t.Run(tc.doc, func(t *testing.T) {
|
||||
_, err := parseCron(tc.expr)
|
||||
if tc.expectErr == "" {
|
||||
assert.NilError(t, err)
|
||||
} else {
|
||||
assert.Check(t, is.ErrorContains(err, tc.expectErr))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCronLargeStep(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
schedule, err := parseCron(fmt.Sprintf("0 0 */%d * *", math.MaxInt))
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, schedule.dom, uint64(1<<1))
|
||||
}
|
||||
|
||||
func TestCronNext(t *testing.T) {
|
||||
utc := func(y int, mo time.Month, d, h, mi int) time.Time {
|
||||
return time.Date(y, mo, d, h, mi, 0, 0, time.UTC)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
doc string
|
||||
expr string
|
||||
after time.Time
|
||||
want time.Time
|
||||
never bool
|
||||
}{
|
||||
{
|
||||
doc: "daily before the hour",
|
||||
expr: "0 3 * * *", after: utc(2026, time.January, 15, 2, 0),
|
||||
want: utc(2026, time.January, 15, 3, 0),
|
||||
},
|
||||
{
|
||||
doc: "strictly after an exact hit",
|
||||
expr: "0 3 * * *", after: utc(2026, time.January, 15, 3, 0),
|
||||
want: utc(2026, time.January, 16, 3, 0),
|
||||
},
|
||||
{
|
||||
doc: "seconds are truncated away",
|
||||
expr: "0 3 * * *", after: utc(2026, time.January, 15, 2, 59).Add(30 * time.Second),
|
||||
want: utc(2026, time.January, 15, 3, 0),
|
||||
},
|
||||
{
|
||||
doc: "quarter-hour step",
|
||||
expr: "*/15 * * * *", after: utc(2026, time.January, 15, 10, 7),
|
||||
want: utc(2026, time.January, 15, 10, 15),
|
||||
},
|
||||
{
|
||||
doc: "step wraps to next hour",
|
||||
expr: "*/15 * * * *", after: utc(2026, time.January, 15, 10, 45),
|
||||
want: utc(2026, time.January, 15, 11, 0),
|
||||
},
|
||||
{
|
||||
doc: "yearly",
|
||||
expr: "0 0 1 1 *", after: utc(2026, time.June, 1, 0, 0),
|
||||
want: utc(2027, time.January, 1, 0, 0),
|
||||
},
|
||||
{
|
||||
doc: "weekday match",
|
||||
expr: "0 12 * * 0", after: utc(2026, time.January, 15, 0, 0), // a Thursday
|
||||
want: utc(2026, time.January, 18, 12, 0), // the next Sunday
|
||||
},
|
||||
{
|
||||
doc: "sunday spelled as 7",
|
||||
expr: "0 12 * * 7", after: utc(2026, time.January, 15, 0, 0),
|
||||
want: utc(2026, time.January, 18, 12, 0),
|
||||
},
|
||||
{
|
||||
doc: "restricted day-of-month and day-of-week combine as OR",
|
||||
// The next Friday (March 6th) comes before the next 13th.
|
||||
expr: "0 0 13 * 5", after: utc(2026, time.March, 1, 0, 0),
|
||||
want: utc(2026, time.March, 6, 0, 0),
|
||||
},
|
||||
{
|
||||
doc: "star-step day-of-month does not defeat the weekday",
|
||||
// Vixie's first-character rule: */1 counts as unrestricted, so
|
||||
// the weekday alone constrains the day.
|
||||
expr: "0 3 */1 * 1", after: utc(2026, time.January, 15, 0, 0), // a Thursday
|
||||
want: utc(2026, time.January, 19, 3, 0), // the next Monday
|
||||
},
|
||||
{
|
||||
doc: "star-step day-of-month mask still applies",
|
||||
// January 16th is even: the next odd day is the 17th.
|
||||
expr: "0 3 */2 * *", after: utc(2026, time.January, 15, 4, 0),
|
||||
want: utc(2026, time.January, 17, 3, 0),
|
||||
},
|
||||
{
|
||||
doc: "february 29th waits for a leap year",
|
||||
expr: "0 0 29 2 *", after: utc(2026, time.January, 1, 0, 0),
|
||||
want: utc(2028, time.February, 29, 0, 0),
|
||||
},
|
||||
{
|
||||
doc: "sparse weekday-bound february 29th",
|
||||
// February 29th on a Sunday: after 2032 the next is 2060.
|
||||
expr: "0 0 29 2 */7", after: utc(2032, time.March, 1, 0, 0),
|
||||
want: utc(2060, time.February, 29, 0, 0),
|
||||
},
|
||||
{
|
||||
doc: "impossible date never fires",
|
||||
expr: "0 0 30 2 *", after: utc(2026, time.January, 1, 0, 0),
|
||||
never: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.doc, func(t *testing.T) {
|
||||
schedule, err := parseCron(tc.expr)
|
||||
assert.NilError(t, err)
|
||||
got, ok := schedule.next(tc.after, time.UTC)
|
||||
if tc.never {
|
||||
assert.Check(t, !ok)
|
||||
return
|
||||
}
|
||||
assert.Assert(t, ok)
|
||||
assert.Check(t, is.Equal(got.String(), tc.want.String()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronNextDST(t *testing.T) {
|
||||
paris, err := time.LoadLocation("Europe/Paris")
|
||||
assert.NilError(t, err)
|
||||
schedule, err := parseCron("30 2 * * *")
|
||||
assert.NilError(t, err)
|
||||
|
||||
t.Run("spring forward skips the nonexistent hour", func(t *testing.T) {
|
||||
// On 2026-03-29 the Paris wall clock jumps from 02:00 to 03:00;
|
||||
// 02:30 never happens that day.
|
||||
after := time.Date(2026, time.March, 29, 0, 0, 0, 0, paris)
|
||||
got, ok := schedule.next(after, paris)
|
||||
assert.Assert(t, ok)
|
||||
assert.Check(t, is.Equal(got.String(), time.Date(2026, time.March, 30, 2, 30, 0, 0, paris).String()))
|
||||
})
|
||||
|
||||
t.Run("fall back fires once in the repeated hour", func(t *testing.T) {
|
||||
// On 2026-10-25 the Paris wall clock falls from 03:00 back to
|
||||
// 02:00: 02:30 happens twice (CEST then CET). The occurrence fires
|
||||
// on the first one only.
|
||||
after := time.Date(2026, time.October, 25, 0, 0, 0, 0, paris)
|
||||
first, ok := schedule.next(after, paris)
|
||||
assert.Assert(t, ok)
|
||||
// The first 02:30 is the CEST one, i.e. 00:30 UTC.
|
||||
assert.Check(t, is.Equal(first.UTC().String(), time.Date(2026, time.October, 25, 0, 30, 0, 0, time.UTC).String()))
|
||||
|
||||
// The occurrence after it is the next day's, not the repeated
|
||||
// (CET, 01:30 UTC) 02:30 of the same day.
|
||||
second, ok := schedule.next(first, paris)
|
||||
assert.Assert(t, ok)
|
||||
assert.Check(t, is.Equal(second.String(), time.Date(2026, time.October, 26, 2, 30, 0, 0, paris).String()))
|
||||
})
|
||||
|
||||
t.Run("fall back does not repeat the hour after a fire on its last minute", func(t *testing.T) {
|
||||
// The occurrence after 02:59 CEST is a minute later in real time,
|
||||
// at 02:00 CET, but that wall-clock time already fired an hour ago.
|
||||
schedule, err := parseCron("0,59 2 * * *")
|
||||
assert.NilError(t, err)
|
||||
|
||||
lastMinute := time.Date(2026, time.October, 25, 0, 59, 0, 0, time.UTC)
|
||||
got, ok := schedule.next(lastMinute, paris)
|
||||
assert.Assert(t, ok)
|
||||
assert.Check(t, is.Equal(got.String(), time.Date(2026, time.October, 26, 2, 0, 0, 0, paris).String()))
|
||||
})
|
||||
|
||||
t.Run("transition skipping midnight does not stall the day search", func(t *testing.T) {
|
||||
// On 2026-03-08 the Havana wall clock jumps from 00:00 to 01:00, so
|
||||
// that day's midnight normalizes back into March 7.
|
||||
havana, err := time.LoadLocation("America/Havana")
|
||||
assert.NilError(t, err)
|
||||
schedule, err := parseCron("0 0 * * 1")
|
||||
assert.NilError(t, err)
|
||||
|
||||
after := time.Date(2026, time.March, 7, 12, 0, 0, 0, havana) // a Saturday
|
||||
got, ok := schedule.next(after, havana)
|
||||
assert.Assert(t, ok)
|
||||
assert.Check(t, is.Equal(got.String(), time.Date(2026, time.March, 9, 0, 0, 0, 0, havana).String()))
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user