From 258bea962a4b7bbbd1ab6d3ba2a9dd6b32a49b8a Mon Sep 17 00:00:00 2001 From: Xevion Date: Sat, 2 Aug 2025 02:54:40 -0500 Subject: [PATCH] feat: new cron trigger --- go.mod | 1 + go.sum | 2 + internal/scheduling/cron.go | 43 ++++++++++ internal/scheduling/cron_test.go | 131 +++++++++++++++++++++++++++++++ 4 files changed, 177 insertions(+) create mode 100644 internal/scheduling/cron.go create mode 100644 internal/scheduling/cron_test.go diff --git a/go.mod b/go.mod index 786ad8b..eed2117 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/kr/pretty v0.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/robfig/cron/v3 v3.0.0 // indirect golang.org/x/net v0.42.0 // indirect gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect ) diff --git a/go.sum b/go.sum index 69b6507..ad672d9 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,8 @@ github.com/nathan-osman/go-sunrise v1.1.0/go.mod h1:RcWqhT+5ShCZDev79GuWLayetpJp github.com/philhofer/fwd v1.1.1/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/robfig/cron/v3 v3.0.0 h1:kQ6Cb7aHOHTSzNVNEhmp8EcWKLb4CbiMW9h9VyIhO4E= +github.com/robfig/cron/v3 v3.0.0/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= diff --git a/internal/scheduling/cron.go b/internal/scheduling/cron.go new file mode 100644 index 0000000..9246bf8 --- /dev/null +++ b/internal/scheduling/cron.go @@ -0,0 +1,43 @@ +package scheduling + +import ( + "fmt" + "hash/fnv" + "time" + + "github.com/robfig/cron/v3" +) + +// CronTrigger represents a trigger based on a cron expression. +type CronTrigger struct { + expression string // required for hash + schedule cron.Schedule +} + +// NewCronTrigger creates a new CronTrigger from a cron expression. +func NewCronTrigger(expression string) (*CronTrigger, error) { + // Use the standard cron parser + parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) + schedule, err := parser.Parse(expression) + if err != nil { + return nil, fmt.Errorf("invalid cron expression: %w", err) + } + + return &CronTrigger{ + expression: expression, + schedule: schedule, + }, nil +} + +// NextTime calculates the next occurrence of this cron trigger after the given time. +func (t *CronTrigger) NextTime(now time.Time) *time.Time { + next := t.schedule.Next(now) + return &next +} + +// Hash returns a stable hash value for the CronTrigger. +func (t *CronTrigger) Hash() uint64 { + h := fnv.New64() + fmt.Fprintf(h, "cron:%s", t.expression) + return h.Sum64() +} diff --git a/internal/scheduling/cron_test.go b/internal/scheduling/cron_test.go new file mode 100644 index 0000000..b4d601e --- /dev/null +++ b/internal/scheduling/cron_test.go @@ -0,0 +1,131 @@ +package scheduling_test + +import ( + "testing" + "time" + + "github.com/Xevion/go-ha/internal/scheduling" +) + +func TestCronTrigger(t *testing.T) { + // Use a fixed time for consistent testing + baseTime := time.Date(2025, 8, 2, 10, 30, 0, 0, time.UTC) + + tests := []struct { + name string + cron string + now time.Time + expected time.Time + }{ + { + name: "daily at 9am", + cron: "0 9 * * *", + now: baseTime, + expected: time.Date(2025, 8, 3, 9, 0, 0, 0, time.UTC), + }, + { + name: "every 15 minutes", + cron: "*/15 * * * *", + now: baseTime, + expected: time.Date(2025, 8, 2, 10, 45, 0, 0, time.UTC), + }, + { + name: "weekdays at 8am (Saturday)", + // Base time is a Saturday, so next run should be Monday + cron: "0 8 * * 1-5", + now: time.Date(2025, 8, 2, 10, 30, 0, 0, time.UTC), + expected: time.Date(2025, 8, 4, 8, 0, 0, 0, time.UTC), + }, + { + name: "weekdays at 8am (Sunday)", + // Base time is a Sunday, so next run should be Monday + cron: "0 8 * * 1-5", + now: time.Date(2025, 8, 3, 10, 30, 0, 0, time.UTC), + expected: time.Date(2025, 8, 4, 8, 0, 0, 0, time.UTC), + }, + + { + name: "monthly on 1st", + cron: "0 0 1 * *", + now: baseTime, + expected: time.Date(2025, 9, 1, 0, 0, 0, 0, time.UTC), + }, + { + name: "specific time today", + cron: "0 14 * * *", + now: baseTime, + expected: time.Date(2025, 8, 2, 14, 0, 0, 0, time.UTC), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + trigger, err := scheduling.NewCronTrigger(tt.cron) + if err != nil { + t.Fatalf("Failed to create cron trigger: %v", err) + } + + next := trigger.NextTime(tt.now) + if next == nil { + t.Fatal("Expected next time, got nil") + } + + if !next.Equal(tt.expected) { + t.Errorf("Expected %v, got %v", tt.expected, *next) + } + }) + } +} + +func TestCronTriggerInvalid(t *testing.T) { + tests := []struct { + name string + expression string + }{ + { + name: "bad pattern", + expression: "invalid", + }, + { + name: "requires 5 fields - too few", + expression: "4", + }, + { + name: "requires 5 fields - missing field", + expression: "0 9 * *", + }, + { + name: "too many fields", + expression: "0 9 * * * *", + }, + { + name: "invalid minute", + expression: "60 9 * * *", + }, + { + name: "invalid hour", + expression: "0 25 * * *", + }, + { + name: "invalid day of month", + expression: "0 9 32 * *", + }, + { + name: "invalid month", + expression: "0 9 * 13 *", + }, + { + name: "invalid day of week", + expression: "0 9 * * 7", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := scheduling.NewCronTrigger(tt.expression) + if err == nil { + t.Errorf("Expected error for invalid expression %q", tt.expression) + } + }) + } +}