mirror of
https://github.com/Xevion/go-ha.git
synced 2025-12-06 01:15:10 -06:00
feat: new interval trigger
This commit is contained in:
91
internal/scheduling/interval.go
Normal file
91
internal/scheduling/interval.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package scheduling
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// IntervalTrigger represents a trigger that fires at a sequence of intervals.
|
||||
type IntervalTrigger struct {
|
||||
intervals []time.Duration // required for hash
|
||||
epoch time.Time // required for hash
|
||||
totalDuration time.Duration
|
||||
}
|
||||
|
||||
// NewIntervalTrigger creates a new IntervalTrigger from one or more durations.
|
||||
// An error is returned if no intervals are provided or if any interval is not positive.
|
||||
// The epoch is the reference point for all interval calculations.
|
||||
// The duration between each time alternates between each interval (or, if there is only one interval, it is the interval).
|
||||
// For example, if the intervals are [1h, 2h, 3h], the first time will be at epoch + 1h, the second time will be at
|
||||
// epoch + 1h + 2h, the third time will be at epoch + 1h + 2h + 3h, and so on.
|
||||
func NewIntervalTrigger(interval time.Duration, additional ...time.Duration) (*IntervalTrigger, error) {
|
||||
if interval <= 0 {
|
||||
return nil, fmt.Errorf("intervals must be positive")
|
||||
}
|
||||
totalDuration := interval
|
||||
for _, d := range additional {
|
||||
if d <= 0 {
|
||||
return nil, fmt.Errorf("intervals must be positive")
|
||||
}
|
||||
totalDuration += d
|
||||
}
|
||||
|
||||
return &IntervalTrigger{
|
||||
intervals: append([]time.Duration{interval}, additional...),
|
||||
epoch: time.Time{}, // default epoch is zero time
|
||||
totalDuration: totalDuration,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// WithEpoch sets the epoch time for the IntervalTrigger. The epoch is the reference point for all interval calculations.
|
||||
func (t *IntervalTrigger) WithEpoch(epoch time.Time) *IntervalTrigger {
|
||||
t.epoch = epoch
|
||||
return t
|
||||
}
|
||||
|
||||
// NextTime calculates the next occurrence of this interval trigger after the given time.
|
||||
func (t *IntervalTrigger) NextTime(now time.Time) *time.Time {
|
||||
if t.totalDuration == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
epoch := t.epoch
|
||||
if epoch.IsZero() {
|
||||
epoch = time.Unix(0, 0).UTC()
|
||||
}
|
||||
|
||||
// If the current time is before the epoch, the next time is the first one in the cycle.
|
||||
if now.Before(epoch) {
|
||||
next := epoch.Add(t.intervals[0])
|
||||
return &next
|
||||
}
|
||||
|
||||
cyclesSinceEpoch := now.Sub(epoch) / t.totalDuration
|
||||
currentCycleStart := epoch.Add(time.Duration(cyclesSinceEpoch) * t.totalDuration)
|
||||
|
||||
// Cycle through the offsets until the next time is found
|
||||
cycle := currentCycleStart
|
||||
for i := 0; i < len(t.intervals); i++ {
|
||||
cycle = cycle.Add(t.intervals[i])
|
||||
if cycle.After(now) {
|
||||
return &cycle
|
||||
}
|
||||
}
|
||||
|
||||
// If we've reached here, it means we're at the end of a cycle.
|
||||
// The next time will be the first interval of the next cycle.
|
||||
nextCycleStart := currentCycleStart.Add(t.totalDuration)
|
||||
next := nextCycleStart.Add(t.intervals[0])
|
||||
return &next
|
||||
}
|
||||
|
||||
// Hash returns a stable hash value for the IntervalTrigger.
|
||||
func (t *IntervalTrigger) Hash() uint64 {
|
||||
h := fnv.New64a()
|
||||
fmt.Fprintf(h, "interval:%d", t.epoch.UnixNano())
|
||||
for _, d := range t.intervals {
|
||||
fmt.Fprintf(h, ":%d", d)
|
||||
}
|
||||
return h.Sum64()
|
||||
}
|
||||
134
internal/scheduling/interval_test.go
Normal file
134
internal/scheduling/interval_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package scheduling
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewIntervalTrigger(t *testing.T) {
|
||||
t.Run("valid single interval", func(t *testing.T) {
|
||||
trigger, err := NewIntervalTrigger(time.Hour)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, trigger)
|
||||
assert.Equal(t, []time.Duration{time.Hour}, trigger.intervals)
|
||||
assert.Equal(t, time.Hour, trigger.totalDuration)
|
||||
assert.True(t, trigger.epoch.IsZero())
|
||||
})
|
||||
|
||||
t.Run("valid multiple intervals", func(t *testing.T) {
|
||||
trigger, err := NewIntervalTrigger(time.Hour, 30*time.Minute)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, trigger)
|
||||
assert.Equal(t, []time.Duration{time.Hour, 30 * time.Minute}, trigger.intervals)
|
||||
assert.Equal(t, time.Hour+30*time.Minute, trigger.totalDuration)
|
||||
assert.True(t, trigger.epoch.IsZero())
|
||||
})
|
||||
|
||||
t.Run("invalid zero interval", func(t *testing.T) {
|
||||
_, err := NewIntervalTrigger(time.Hour, 0)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("invalid negative interval", func(t *testing.T) {
|
||||
_, err := NewIntervalTrigger(time.Hour, -time.Minute)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("first interval is invalid if zero", func(t *testing.T) {
|
||||
_, err := NewIntervalTrigger(0)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntervalTrigger_NextTime(t *testing.T) {
|
||||
// A known time for predictable tests
|
||||
now := time.Date(2024, 7, 25, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
t.Run("single interval no epoch", func(t *testing.T) {
|
||||
trigger, _ := NewIntervalTrigger(time.Hour)
|
||||
// With a zero epoch, NextTime should calculate from the last hour boundary.
|
||||
next := trigger.NextTime(now)
|
||||
expected := time.Date(2024, 7, 25, 13, 0, 0, 0, time.UTC)
|
||||
assert.Equal(t, expected, *next)
|
||||
})
|
||||
|
||||
t.Run("single interval with aligned epoch", func(t *testing.T) {
|
||||
trigger, _ := NewIntervalTrigger(time.Hour)
|
||||
// Epoch is on an hour boundary relative to the Unix epoch, so it's not modified by WithEpoch.
|
||||
epoch := time.Date(2024, 7, 25, 0, 0, 0, 0, time.UTC)
|
||||
trigger.WithEpoch(epoch)
|
||||
next := trigger.NextTime(now)
|
||||
expected := time.Date(2024, 7, 25, 13, 0, 0, 0, time.UTC)
|
||||
assert.Equal(t, expected, *next)
|
||||
})
|
||||
|
||||
t.Run("multiple intervals", func(t *testing.T) {
|
||||
trigger, _ := NewIntervalTrigger(time.Hour, 30*time.Minute) // total 1.5h
|
||||
epoch := time.Date(2024, 7, 25, 0, 0, 0, 0, time.UTC)
|
||||
trigger.WithEpoch(epoch)
|
||||
// now = 12:00. epoch = 00:00. duration = 12h.
|
||||
// cycles = 12h / 1.5h = 8.
|
||||
// currentCycleStart = 00:00 + 8 * 1.5h = 12:00.
|
||||
// 1. 12:00 + 1h = 13:00. This is after now, so it's the next time.
|
||||
next := trigger.NextTime(now)
|
||||
expected := time.Date(2024, 7, 25, 13, 0, 0, 0, time.UTC)
|
||||
assert.Equal(t, expected, *next)
|
||||
|
||||
// Test the time after that
|
||||
now2 := time.Date(2024, 7, 25, 13, 0, 0, 0, time.UTC)
|
||||
// currentCycleStart is still 12:00.
|
||||
// 1. 12:00 + 1h = 13:00. Not after now2.
|
||||
// 2. 13:00 + 30m = 13:30. This is after now2.
|
||||
next2 := trigger.NextTime(now2)
|
||||
expected2 := time.Date(2024, 7, 25, 13, 30, 0, 0, time.UTC)
|
||||
assert.Equal(t, expected2, *next2)
|
||||
})
|
||||
|
||||
t.Run("now before epoch", func(t *testing.T) {
|
||||
trigger, _ := NewIntervalTrigger(time.Hour)
|
||||
epoch := time.Date(2024, 7, 26, 0, 0, 0, 0, time.UTC)
|
||||
trigger.WithEpoch(epoch)
|
||||
next := trigger.NextTime(now)
|
||||
expected := time.Date(2024, 7, 26, 1, 0, 0, 0, time.UTC)
|
||||
assert.Equal(t, expected, *next)
|
||||
})
|
||||
|
||||
t.Run("now is exactly on a trigger time", func(t *testing.T) {
|
||||
trigger, _ := NewIntervalTrigger(time.Hour)
|
||||
epoch := time.Date(2024, 7, 25, 0, 0, 0, 0, time.UTC)
|
||||
trigger.WithEpoch(epoch)
|
||||
nowOnTrigger := time.Date(2024, 7, 25, 12, 0, 0, 0, time.UTC)
|
||||
// The next trigger should be the following one.
|
||||
next := trigger.NextTime(nowOnTrigger)
|
||||
expected := time.Date(2024, 7, 25, 13, 0, 0, 0, time.UTC)
|
||||
assert.Equal(t, expected, *next)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func TestIntervalTrigger_Hash(t *testing.T) {
|
||||
t.Run("stable hash for same configuration", func(t *testing.T) {
|
||||
trigger1, _ := NewIntervalTrigger(time.Hour, 30*time.Minute)
|
||||
trigger2, _ := NewIntervalTrigger(time.Hour, 30*time.Minute)
|
||||
assert.Equal(t, trigger1.Hash(), trigger2.Hash())
|
||||
})
|
||||
|
||||
t.Run("hash changes with interval", func(t *testing.T) {
|
||||
trigger1, _ := NewIntervalTrigger(time.Hour, 30*time.Minute)
|
||||
trigger2, _ := NewIntervalTrigger(time.Hour, 31*time.Minute)
|
||||
assert.NotEqual(t, trigger1.Hash(), trigger2.Hash())
|
||||
})
|
||||
|
||||
t.Run("hash changes with epoch", func(t *testing.T) {
|
||||
trigger1, _ := NewIntervalTrigger(time.Hour)
|
||||
trigger1.WithEpoch(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
|
||||
trigger2, _ := NewIntervalTrigger(time.Hour)
|
||||
trigger2.WithEpoch(time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC))
|
||||
|
||||
assert.NotEqual(t, trigger1.Hash(), trigger2.Hash())
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user