From 4634e9530bf4125c67b27778291890f05c7984a0 Mon Sep 17 00:00:00 2001 From: Eray Ates Date: Sat, 5 Aug 2023 05:10:33 +0200 Subject: [PATCH] fix: next, prev updated Signed-off-by: Eray Ates --- Makefile | 22 ++++ README.md | 7 +- cron.go | 192 ++++++++++++++++++++++++++++++++--- cron_test.go | 276 ++++++++++++++++++++++++++++++++++++++++----------- 4 files changed, 419 insertions(+), 78 deletions(-) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..73baf8b --- /dev/null +++ b/Makefile @@ -0,0 +1,22 @@ +.DEFAULT_GOAL := help + +.PHONY: test coverage html html-gen html-wsl help + +test: ## Run unit tests + @go test -timeout 30s -v -race ./... + +coverage: ## Run unit tests with coverage + @go test -timeout 30s -v -race -cover -coverpkg=./... -coverprofile=coverage.out -covermode=atomic ./... + @go tool cover -func=coverage.out + +html: ## Show html coverage result + @go tool cover -html=./coverage.out + +html-gen: ## Export html coverage result + @go tool cover -html=./coverage.out -o ./coverage.html + +html-wsl: html-gen ## Open html coverage result in wsl + @explorer.exe `wslpath -w ./coverage.html` || true + +help: ## Display this help screen + @grep -h -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' diff --git a/README.md b/README.md index d6a161f..8e85eb0 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ go get github.com/worldline-go/hardloop Check the https://crontab.guru/ to explain about cron specs. +> Hardloop different works than _crontab.guru_ in weekdays and day of month selection. We use __and__ operation but that site use __or__ operation when used both of them. + You can give as much as you want start, stop times. If stop time is not given, it will run forever. @@ -64,8 +66,5 @@ myFunctionLoop.SetLogger(myLog{}) Test code ```sh -go test -cover -coverprofile=coverage.out ./... -go tool cover -func=coverage.out -go tool cover -html=coverage.out -# go tool cover -html coverage.out -o coverage.html +make coverage html-wsl ``` diff --git a/cron.go b/cron.go index 9e3a82b..969704b 100644 --- a/cron.go +++ b/cron.go @@ -24,6 +24,127 @@ const ( starBit = 1 << 63 ) +// Next returns the next time this schedule is activated, greater than the given +// time. If no time can be found to satisfy the schedule, return the zero time. +func (s *SpecSchedule) Next(t time.Time) time.Time { + // General approach + // + // For Month, Day, Hour, Minute, Second: + // Check if the time value matches. If yes, continue to the next field. + // If the field doesn't match the schedule, then increment the field until it matches. + // While incrementing the field, a wrap-around brings it back to the beginning + // of the field list (since it is necessary to re-verify previous field + // values) + + // Convert the given time into the schedule's timezone, if one is specified. + // Save the original timezone so we can convert back after we find a time. + // Note that schedules without a time zone specified (time.Local) are treated + // as local to the time provided. + origLocation := t.Location() + loc := s.Location + if loc == time.Local { + loc = t.Location() + } + if s.Location != time.Local { + t = t.In(s.Location) + } + + // Start at the earliest possible time (the upcoming second). + t = t.Add(1*time.Second - time.Duration(t.Nanosecond())*time.Nanosecond) + + // This flag indicates whether a field has been incremented. + added := false + + // If no time is found within five years, return zero. + yearLimit := t.Year() + 5 + +WRAP: + if t.Year() > yearLimit { + return time.Time{} + } + + // Find the first applicable month. + // If it's this month, then do nothing. + for 1< 12 { + t = t.Add(time.Duration(24-t.Hour()) * time.Hour) + } else { + t = t.Add(time.Duration(-t.Hour()) * time.Hour) + } + } + + if t.Day() == 1 { + goto WRAP + } + } + + for 1< 0 dowMatch bool = 1< 0 ) - if s.Dom&starBit > 0 || s.Dow&starBit > 0 { + + if s.Dom&starBit > 0 && s.Dow&starBit > 0 { + return domMatch || dowMatch + } + + if !(s.Dom&starBit > 0) && !(s.Dow&starBit > 0) { return domMatch && dowMatch } - return domMatch || dowMatch + + if !(s.Dow&starBit > 0) && dowMatch { + return dowMatch + } + + if !(s.Dom&starBit > 0) && domMatch { + return domMatch + } + + return domMatch && dowMatch } // Parse returns a new cron schedule for the given spec. // // It accepts -// - Standard crontab specs, e.g. "* * * * ?" -// - Descriptors, e.g. "@midnight", "@every 1h30m" +// - Standard crontab specs, e.g. "* * * * ?" +// - Descriptors, e.g. "@midnight", "@every 1h30m" func ParseStandard(spec string) (*SpecSchedule, error) { specSchedule, err := cron.ParseStandard(spec) if err != nil { @@ -194,3 +323,34 @@ func ParseStandard(spec string) (*SpecSchedule, error) { return &SpecSchedule{SpecSchedule: specSchedule.(*cron.SpecSchedule)}, nil } + +// Parser is default parser for cron to replacing the functions. +type Parser struct { + ParseFn func(standardSpec string) (cron.Schedule, error) +} + +func (p Parser) Parse(spec string) (cron.Schedule, error) { + parseFn := p.ParseFn + if parseFn == nil { + parseFn = cron.ParseStandard + } + + specSchedule, err := parseFn(spec) + if err != nil { + return nil, err + } + + return &SpecSchedule{SpecSchedule: specSchedule.(*cron.SpecSchedule)}, nil +} + +// Parse2 is a helper function for parsing the spec and returning the SpecSchedule. +func (p Parser) Parse2(spec string) (Schedule, error) { + v, err := p.Parse(spec) + if err != nil { + return nil, err + } + + return v.(*SpecSchedule), nil +} + +var _ cron.ScheduleParser = &Parser{} diff --git a/cron_test.go b/cron_test.go index 19cdfa9..6d4decc 100644 --- a/cron_test.go +++ b/cron_test.go @@ -1,95 +1,255 @@ package hardloop import ( - "reflect" + "fmt" "testing" "time" ) -func TestSpecSchedule_Prev(t *testing.T) { - type fields struct { - CronSpec string - } - type args struct { - t time.Time +func Test_ParseSchedule(t *testing.T) { + type testTime struct { + timeNow time.Time + next []time.Time + prev []time.Time } tests := []struct { - name string - fields fields - args args - want time.Time - wantErr bool + message string + schedule string + tests []testTime }{ { - name: "test [0 7 * * *]", - fields: fields{ - CronSpec: "0 7 * * *", + message: "every day at 7:00", + schedule: "0 7 * * *", + tests: []testTime{ + { + timeNow: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), + next: []time.Time{ + time.Date(2023, 1, 1, 7, 0, 0, 0, time.UTC), + time.Date(2023, 1, 2, 7, 0, 0, 0, time.UTC), + time.Date(2023, 1, 3, 7, 0, 0, 0, time.UTC), + }, + prev: []time.Time{ + time.Date(2022, 12, 31, 7, 0, 0, 0, time.UTC), + time.Date(2022, 12, 30, 7, 0, 0, 0, time.UTC), + time.Date(2022, 12, 29, 7, 0, 0, 0, time.UTC), + }, + }, }, - args: args{ - // 10:00:00 - t: time.Date(2020, time.January, 1, 10, 0, 0, 0, time.UTC), + }, + { + message: "every 5 minutes", + schedule: "*/5 * * * *", + tests: []testTime{ + { + timeNow: time.Date(2023, 1, 1, 1, 0, 0, 0, time.UTC), + next: []time.Time{ + time.Date(2023, 1, 1, 1, 5, 0, 0, time.UTC), + time.Date(2023, 1, 1, 1, 10, 0, 0, time.UTC), + time.Date(2023, 1, 1, 1, 15, 0, 0, time.UTC), + time.Date(2023, 1, 1, 1, 20, 0, 0, time.UTC), + }, + prev: []time.Time{ + time.Date(2023, 1, 1, 0, 55, 0, 0, time.UTC), + time.Date(2023, 1, 1, 0, 50, 0, 0, time.UTC), + time.Date(2023, 1, 1, 0, 45, 0, 0, time.UTC), + }, + }, }, - // 07:00:00 - want: time.Date(2020, time.January, 1, 7, 0, 0, 0, time.UTC), - wantErr: false, }, { - name: "test [0 17 * * 1,2,3,4,5]", - fields: fields{ - CronSpec: "0 17 * * 1,2,3,4,5", + message: "every minutes", + schedule: "* * * * *", + tests: []testTime{ + { + timeNow: time.Date(2023, 1, 1, 1, 0, 0, 0, time.UTC), + next: []time.Time{ + time.Date(2023, 1, 1, 1, 1, 0, 0, time.UTC), + time.Date(2023, 1, 1, 1, 2, 0, 0, time.UTC), + time.Date(2023, 1, 1, 1, 3, 0, 0, time.UTC), + }, + prev: []time.Time{ + time.Date(2023, 1, 1, 0, 59, 0, 0, time.UTC), + time.Date(2023, 1, 1, 0, 58, 0, 0, time.UTC), + time.Date(2023, 1, 1, 0, 57, 0, 0, time.UTC), + }, + }, }, - args: args{ - t: time.Date(2020, time.January, 5, 10, 0, 0, 0, time.UTC), + }, + { + message: "weekdays at 17:00", + schedule: "0 17 * * 1,2,3,4,5", + tests: []testTime{ + { + timeNow: time.Date(2023, 1, 1, 5, 0, 0, 0, time.UTC), + next: []time.Time{ + time.Date(2023, 1, 2, 17, 0, 0, 0, time.UTC), + time.Date(2023, 1, 3, 17, 0, 0, 0, time.UTC), + time.Date(2023, 1, 4, 17, 0, 0, 0, time.UTC), + time.Date(2023, 1, 5, 17, 0, 0, 0, time.UTC), + time.Date(2023, 1, 6, 17, 0, 0, 0, time.UTC), + time.Date(2023, 1, 9, 17, 0, 0, 0, time.UTC), + time.Date(2023, 1, 10, 17, 0, 0, 0, time.UTC), + }, + prev: []time.Time{ + time.Date(2022, 12, 30, 17, 0, 0, 0, time.UTC), + time.Date(2022, 12, 29, 17, 0, 0, 0, time.UTC), + time.Date(2022, 12, 28, 17, 0, 0, 0, time.UTC), + time.Date(2022, 12, 27, 17, 0, 0, 0, time.UTC), + time.Date(2022, 12, 26, 17, 0, 0, 0, time.UTC), + time.Date(2022, 12, 23, 17, 0, 0, 0, time.UTC), + }, + }, }, - want: time.Date(2020, time.January, 3, 17, 0, 0, 0, time.UTC), - wantErr: false, }, { - name: "test [35 17 * * 1,2,3,4,5]", - fields: fields{ - CronSpec: "35 17 * * 1,2,3,4,5", + message: "every 5 minutes in specific days", + schedule: "*/5 9-16 1-17,19-31 * 1-5", + tests: []testTime{ + { + timeNow: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), + next: []time.Time{ + time.Date(2023, 1, 2, 9, 0, 0, 0, time.UTC), + time.Date(2023, 1, 2, 9, 5, 0, 0, time.UTC), + }, + }, + { + // weekend will be monday + timeNow: time.Date(2023, time.August, 5, 0, 0, 0, 0, time.UTC), + next: []time.Time{ + time.Date(2023, time.August, 7, 9, 0, 0, 0, time.UTC), + }, + }, + { + // 18th day of month is not in schedule + timeNow: time.Date(2023, time.August, 18, 0, 0, 0, 0, time.UTC), + next: []time.Time{ + time.Date(2023, time.August, 21, 9, 0, 0, 0, time.UTC), + time.Date(2023, time.August, 21, 9, 5, 0, 0, time.UTC), + time.Date(2023, time.August, 21, 9, 10, 0, 0, time.UTC), + }, + prev: []time.Time{ + time.Date(2023, time.August, 17, 16, 55, 0, 0, time.UTC), + }, + }, + { + // 18th day of month and it is weekend is not in schedule + timeNow: time.Date(2023, time.November, 18, 0, 0, 0, 0, time.UTC), + next: []time.Time{ + time.Date(2023, time.November, 20, 9, 0, 0, 0, time.UTC), + time.Date(2023, time.November, 20, 9, 5, 0, 0, time.UTC), + time.Date(2023, time.November, 20, 9, 10, 0, 0, time.UTC), + }, + prev: []time.Time{ + time.Date(2023, time.November, 17, 16, 55, 0, 0, time.UTC), + }, + }, }, - args: args{ - t: time.Date(2020, time.January, 5, 10, 20, 0, 0, time.UTC), + }, + { + message: "to may", + schedule: "0 17 23 5 *", + tests: []testTime{ + { + timeNow: time.Date(2020, time.January, 5, 10, 50, 0, 0, time.UTC), + next: []time.Time{ + time.Date(2020, time.May, 23, 17, 0, 0, 0, time.UTC), + }, + prev: []time.Time{ + time.Date(2019, time.May, 23, 17, 0, 0, 0, time.UTC), + }, + }, }, - want: time.Date(2020, time.January, 3, 17, 35, 0, 0, time.UTC), - wantErr: false, }, { - name: "test [35 17 * * 1,2,3,4,5]", - fields: fields{ - CronSpec: "35 17 * * 1,2,3,4,5", + message: "year pass time", + schedule: "35 17 * * 1,2,3,4,5", + tests: []testTime{ + { + timeNow: time.Date(2020, time.January, 5, 10, 20, 0, 0, time.UTC), + prev: []time.Time{ + time.Date(2020, time.January, 3, 17, 35, 0, 0, time.UTC), + }, + }, + { + timeNow: time.Date(2020, time.January, 5, 10, 50, 0, 0, time.UTC), + prev: []time.Time{ + time.Date(2020, time.January, 3, 17, 35, 0, 0, time.UTC), + }, + }, }, - args: args{ - t: time.Date(2020, time.January, 5, 10, 50, 0, 0, time.UTC), + }, + { + message: "random 1", + schedule: "23 0-20/2 * * *", + tests: []testTime{ + { + timeNow: time.Date(2023, time.August, 5, 2, 26, 0, 0, time.UTC), + next: []time.Time{ + time.Date(2023, time.August, 5, 4, 23, 0, 0, time.UTC), + }, + prev: []time.Time{ + time.Date(2023, time.August, 5, 2, 23, 0, 0, time.UTC), + }, + }, }, - want: time.Date(2020, time.January, 3, 17, 35, 0, 0, time.UTC), - wantErr: false, }, { - name: "test [0 17 23 5 *]", - fields: fields{ - CronSpec: "0 17 23 5 *", + message: "random 2", + schedule: "0 0,12 1 */2 *", + tests: []testTime{ + { + timeNow: time.Date(2023, time.August, 5, 2, 26, 0, 0, time.UTC), + next: []time.Time{ + time.Date(2023, time.September, 1, 0, 0, 0, 0, time.UTC), + }, + prev: []time.Time{ + time.Date(2023, time.July, 1, 12, 0, 0, 0, time.UTC), + time.Date(2023, time.July, 1, 0, 0, 0, 0, time.UTC), + }, + }, }, - args: args{ - t: time.Date(2020, time.January, 5, 10, 50, 0, 0, time.UTC), + }, + { + message: "random 3", + schedule: "0 0 1,15 * 3", + tests: []testTime{ + { + timeNow: time.Date(2023, time.August, 5, 2, 26, 0, 0, time.UTC), + next: []time.Time{ + time.Date(2023, time.November, 1, 0, 0, 0, 0, time.UTC), + }, + prev: []time.Time{ + time.Date(2023, time.March, 15, 0, 0, 0, 0, time.UTC), + }, + }, }, - want: time.Date(2019, time.May, 23, 17, 0, 0, 0, time.UTC), - wantErr: false, }, } + for i, tt := range tests { + t.Run(fmt.Sprintf("parse_%d", i), func(t *testing.T) { + schedule, err := ParseStandard(tt.schedule) + if err != nil { + t.Fatalf("failed to parse schedule: %v", err) + } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - s, err := ParseStandard(tt.fields.CronSpec) - if err != nil != tt.wantErr { - t.Errorf("ParseStandard() error = %v, wantErr %v", err, tt.wantErr) + for _, testN := range tt.tests { + now := testN.timeNow - return - } + for _, next := range testN.next { + now = schedule.Next(now) + if !now.Equal(next) { + t.Fatalf("[next] %s expected %v, got %v, now %v", tt.schedule, next, now, testN.timeNow) + } + } + + now = testN.timeNow - if got := s.Prev(tt.args.t); !reflect.DeepEqual(got, tt.want) { - t.Errorf("SpecSchedule.Prev() = %v, want %v", got, tt.want) + for _, prev := range testN.prev { + now = schedule.Prev(now) + if !now.Equal(prev) { + t.Fatalf("[prev] %s expected %v, got %v, now %v", tt.schedule, prev, now, testN.timeNow) + } + } } }) }