mirror of
https://github.com/Xevion/go-ha.git
synced 2025-12-05 23:15:07 -06:00
feat: new schedule triggering, new builder pattern in internal
This commit is contained in:
@@ -47,3 +47,7 @@ func GetEquivalentWebsocketScheme(scheme string) (string, error) {
|
||||
return "", fmt.Errorf("unexpected scheme: %s", scheme)
|
||||
}
|
||||
}
|
||||
|
||||
func Ptr[T any](v T) *T {
|
||||
return &v
|
||||
}
|
||||
|
||||
114
internal/scheduling/builder.go
Normal file
114
internal/scheduling/builder.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package scheduling
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/Xevion/go-ha/types"
|
||||
)
|
||||
|
||||
type DailyScheduleBuilder struct {
|
||||
errors []error
|
||||
hashes map[uint64]bool
|
||||
triggers []Trigger
|
||||
}
|
||||
|
||||
func NewSchedule() *DailyScheduleBuilder {
|
||||
return &DailyScheduleBuilder{
|
||||
hashes: make(map[uint64]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// tryAddTrigger adds a trigger to the builder if it is not already present.
|
||||
// If the trigger is already present, an error will be added to the builder's errors.
|
||||
// It will return the builder for chaining.
|
||||
func (b *DailyScheduleBuilder) tryAddTrigger(trigger Trigger) *DailyScheduleBuilder {
|
||||
hash := trigger.Hash()
|
||||
if _, ok := b.hashes[hash]; ok {
|
||||
b.errors = append(b.errors, fmt.Errorf("duplicate trigger: %v", trigger))
|
||||
return b
|
||||
}
|
||||
|
||||
b.triggers = append(b.triggers, trigger)
|
||||
b.hashes[hash] = true
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *DailyScheduleBuilder) onSun(sunset bool, offset ...types.DurationString) *DailyScheduleBuilder {
|
||||
if len(offset) == 0 {
|
||||
b.errors = append(b.errors, fmt.Errorf("no offset provided for sun"))
|
||||
return b
|
||||
}
|
||||
|
||||
offsetDuration, err := time.ParseDuration(string(offset[0]))
|
||||
if err != nil {
|
||||
b.errors = append(b.errors, err)
|
||||
return b
|
||||
}
|
||||
|
||||
return b.tryAddTrigger(&SunTrigger{
|
||||
sunset: sunset,
|
||||
offset: &offsetDuration,
|
||||
})
|
||||
}
|
||||
|
||||
// OnSunrise adds a trigger for sunrise with an optional offset.
|
||||
// Only the first offset is considered.
|
||||
// You can call this multiple times to add multiple triggers for sunrise with different offsets.
|
||||
func (b *DailyScheduleBuilder) OnSunrise(offset ...types.DurationString) *DailyScheduleBuilder {
|
||||
return b.onSun(false, offset...)
|
||||
}
|
||||
|
||||
// OnSunset adds a trigger for sunset with an optional offset.
|
||||
// Only the first offset is considered.
|
||||
func (b *DailyScheduleBuilder) OnSunset(offset ...types.DurationString) *DailyScheduleBuilder {
|
||||
return b.onSun(true, offset...)
|
||||
}
|
||||
|
||||
// OnFixedTime adds a trigger for a fixed time each day.
|
||||
// The time is in the local timezone.
|
||||
// This will error if the integer values are not in the range 0-23 for the hour and 0-59 for the minute.
|
||||
func (b *DailyScheduleBuilder) OnFixedTime(hour, minute int) *DailyScheduleBuilder {
|
||||
errored := false
|
||||
if hour < 0 || hour > 23 {
|
||||
b.errors = append(b.errors, fmt.Errorf("hour must be between 0 and 23"))
|
||||
errored = true
|
||||
}
|
||||
|
||||
if minute < 0 || minute > 59 {
|
||||
b.errors = append(b.errors, fmt.Errorf("minute must be between 0 and 59"))
|
||||
errored = true
|
||||
}
|
||||
|
||||
if errored {
|
||||
return b
|
||||
}
|
||||
|
||||
return b.tryAddTrigger(&FixedTimeTrigger{
|
||||
Hour: hour,
|
||||
Minute: minute,
|
||||
})
|
||||
}
|
||||
|
||||
// Build returns a Trigger that will trigger at the configured times.
|
||||
// It will return an error if any errors occurred during configuration.
|
||||
func (b *DailyScheduleBuilder) Build() (Trigger, error) {
|
||||
// If there are no triggers, add an error.
|
||||
if len(b.triggers) == 0 {
|
||||
b.errors = append(b.errors, fmt.Errorf("no triggers provided"))
|
||||
}
|
||||
|
||||
// If there are errors, return an error.
|
||||
if len(b.errors) > 0 {
|
||||
return nil, fmt.Errorf("errors occurred: %v", b.errors)
|
||||
}
|
||||
|
||||
// If there is only one trigger, return it.
|
||||
if len(b.triggers) == 1 {
|
||||
return b.triggers[0], nil
|
||||
}
|
||||
|
||||
// Otherwise, return a composite schedule that combines all the triggers.
|
||||
return &CompositeDailySchedule{triggers: b.triggers}, nil
|
||||
}
|
||||
353
internal/scheduling/builder_test.go
Normal file
353
internal/scheduling/builder_test.go
Normal file
@@ -0,0 +1,353 @@
|
||||
package scheduling
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Xevion/go-ha/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewSchedule(t *testing.T) {
|
||||
builder := NewSchedule()
|
||||
assert.NotNil(t, builder)
|
||||
assert.Empty(t, builder.errors)
|
||||
assert.Empty(t, builder.triggers)
|
||||
assert.NotNil(t, builder.hashes)
|
||||
}
|
||||
|
||||
func TestDailyScheduleBuilder_OnFixedTime(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hour int
|
||||
minute int
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid time",
|
||||
hour: 12,
|
||||
minute: 30,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "midnight",
|
||||
hour: 0,
|
||||
minute: 0,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid hour negative",
|
||||
hour: -1,
|
||||
minute: 30,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid hour too high",
|
||||
hour: 24,
|
||||
minute: 30,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid minute negative",
|
||||
hour: 12,
|
||||
minute: -1,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid minute too high",
|
||||
hour: 12,
|
||||
minute: 60,
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
builder := NewSchedule()
|
||||
result := builder.OnFixedTime(tt.hour, tt.minute)
|
||||
|
||||
assert.Equal(t, builder, result) // Should return self for chaining
|
||||
|
||||
if tt.expectError {
|
||||
assert.Len(t, builder.errors, 1)
|
||||
} else {
|
||||
assert.Empty(t, builder.errors)
|
||||
assert.Len(t, builder.triggers, 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyScheduleBuilder_OnSunrise(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
offset []types.DurationString
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "with offset",
|
||||
offset: []types.DurationString{"30m"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "with negative offset",
|
||||
offset: []types.DurationString{"-1h"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "no offset",
|
||||
offset: []types.DurationString{},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid duration",
|
||||
offset: []types.DurationString{"invalid"},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
builder := NewSchedule()
|
||||
result := builder.OnSunrise(tt.offset...)
|
||||
|
||||
assert.Equal(t, builder, result) // Should return self for chaining
|
||||
|
||||
if tt.expectError {
|
||||
assert.Len(t, builder.errors, 1)
|
||||
} else {
|
||||
assert.Empty(t, builder.errors)
|
||||
assert.Len(t, builder.triggers, 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyScheduleBuilder_OnSunset(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
offset []types.DurationString
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "with offset",
|
||||
offset: []types.DurationString{"1h"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "with negative offset",
|
||||
offset: []types.DurationString{"-30m"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "no offset",
|
||||
offset: []types.DurationString{},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid duration",
|
||||
offset: []types.DurationString{"invalid"},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
builder := NewSchedule()
|
||||
result := builder.OnSunset(tt.offset...)
|
||||
|
||||
assert.Equal(t, builder, result) // Should return self for chaining
|
||||
|
||||
if tt.expectError {
|
||||
assert.Len(t, builder.errors, 1)
|
||||
} else {
|
||||
assert.Empty(t, builder.errors)
|
||||
assert.Len(t, builder.triggers, 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyScheduleBuilder_DuplicateTriggers(t *testing.T) {
|
||||
builder := NewSchedule()
|
||||
|
||||
// Add the same fixed time trigger twice
|
||||
builder.OnFixedTime(12, 30)
|
||||
builder.OnFixedTime(12, 30)
|
||||
|
||||
assert.Len(t, builder.errors, 1)
|
||||
assert.Len(t, builder.triggers, 1) // Only one should be added
|
||||
assert.Contains(t, builder.errors[0].Error(), "duplicate trigger")
|
||||
}
|
||||
|
||||
func TestDailyScheduleBuilder_Build_Success(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupBuilder func(*DailyScheduleBuilder)
|
||||
expectedType string
|
||||
expectedCount int
|
||||
}{
|
||||
{
|
||||
name: "single fixed time trigger",
|
||||
setupBuilder: func(b *DailyScheduleBuilder) {
|
||||
b.OnFixedTime(12, 30)
|
||||
},
|
||||
expectedType: "*scheduling.FixedTimeTrigger",
|
||||
expectedCount: 1,
|
||||
},
|
||||
{
|
||||
name: "single sunrise trigger",
|
||||
setupBuilder: func(b *DailyScheduleBuilder) {
|
||||
b.OnSunrise("30m")
|
||||
},
|
||||
expectedType: "*scheduling.SunTrigger",
|
||||
expectedCount: 1,
|
||||
},
|
||||
{
|
||||
name: "multiple triggers",
|
||||
setupBuilder: func(b *DailyScheduleBuilder) {
|
||||
b.OnFixedTime(8, 0)
|
||||
b.OnFixedTime(12, 0)
|
||||
b.OnSunrise("1h")
|
||||
},
|
||||
expectedType: "*scheduling.CompositeDailySchedule",
|
||||
expectedCount: 3,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
builder := NewSchedule()
|
||||
tt.setupBuilder(builder)
|
||||
|
||||
trigger, err := builder.Build()
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, trigger)
|
||||
assert.Equal(t, tt.expectedType, fmt.Sprintf("%T", trigger))
|
||||
|
||||
// Test that the trigger works
|
||||
now := time.Date(2025, 8, 2, 10, 0, 0, 0, time.Local)
|
||||
result := trigger.NextTime(now)
|
||||
assert.NotNil(t, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyScheduleBuilder_Build_Errors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupBuilder func(*DailyScheduleBuilder)
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "no triggers",
|
||||
setupBuilder: func(b *DailyScheduleBuilder) {
|
||||
// Don't add any triggers
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid hour",
|
||||
setupBuilder: func(b *DailyScheduleBuilder) {
|
||||
b.OnFixedTime(25, 0) // Invalid hour
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid minute",
|
||||
setupBuilder: func(b *DailyScheduleBuilder) {
|
||||
b.OnFixedTime(12, 60) // Invalid minute
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "no offset for sun trigger",
|
||||
setupBuilder: func(b *DailyScheduleBuilder) {
|
||||
b.OnSunrise() // No offset
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid duration",
|
||||
setupBuilder: func(b *DailyScheduleBuilder) {
|
||||
b.OnSunset("invalid") // Invalid duration
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
builder := NewSchedule()
|
||||
tt.setupBuilder(builder)
|
||||
|
||||
trigger, err := builder.Build()
|
||||
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, trigger)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, trigger)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyScheduleBuilder_Chaining(t *testing.T) {
|
||||
builder := NewSchedule()
|
||||
|
||||
// Test method chaining
|
||||
result := builder.
|
||||
OnFixedTime(8, 0).
|
||||
OnFixedTime(12, 0).
|
||||
OnSunrise("30m")
|
||||
|
||||
assert.Equal(t, builder, result)
|
||||
assert.Len(t, builder.triggers, 3)
|
||||
assert.Empty(t, builder.errors)
|
||||
}
|
||||
|
||||
func TestDailyScheduleBuilder_NextTime_Integration(t *testing.T) {
|
||||
builder := NewSchedule()
|
||||
builder.OnFixedTime(8, 0).
|
||||
OnFixedTime(12, 0).
|
||||
OnFixedTime(18, 0)
|
||||
|
||||
trigger, err := builder.Build()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test at different times
|
||||
tests := []struct {
|
||||
name string
|
||||
now time.Time
|
||||
expected time.Time
|
||||
}{
|
||||
{
|
||||
name: "before all triggers",
|
||||
now: time.Date(2025, 8, 2, 6, 0, 0, 0, time.Local),
|
||||
expected: time.Date(2025, 8, 2, 8, 0, 0, 0, time.Local),
|
||||
},
|
||||
{
|
||||
name: "between triggers",
|
||||
now: time.Date(2025, 8, 2, 10, 0, 0, 0, time.Local),
|
||||
expected: time.Date(2025, 8, 2, 12, 0, 0, 0, time.Local),
|
||||
},
|
||||
{
|
||||
name: "after all triggers",
|
||||
now: time.Date(2025, 8, 2, 20, 0, 0, 0, time.Local),
|
||||
expected: time.Date(2025, 8, 3, 8, 0, 0, 0, time.Local),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := trigger.NextTime(tt.now)
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, tt.expected, *result)
|
||||
})
|
||||
}
|
||||
}
|
||||
110
internal/scheduling/daily.go
Normal file
110
internal/scheduling/daily.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package scheduling
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"time"
|
||||
|
||||
"github.com/Xevion/go-ha/internal"
|
||||
"github.com/dromara/carbon/v2"
|
||||
"github.com/nathan-osman/go-sunrise"
|
||||
)
|
||||
|
||||
type Trigger interface {
|
||||
// NextTime calculates the next occurrence of this trigger after the given time
|
||||
NextTime(now time.Time) *time.Time
|
||||
Hash() uint64
|
||||
}
|
||||
|
||||
// FixedTimeTrigger represents a trigger at a specific hour and minute each day
|
||||
type FixedTimeTrigger struct {
|
||||
Hour int // 0-23
|
||||
Minute int // 0-59
|
||||
}
|
||||
|
||||
// SunTrigger represents a trigger based on sunrise or sunset with optional offset
|
||||
type SunTrigger struct {
|
||||
latitude float64 // latitude of the location
|
||||
longitude float64 // longitude of the location
|
||||
sunset bool // true for sunset, false for sunrise
|
||||
offset *time.Duration // offset from sun event (can be negative)
|
||||
}
|
||||
|
||||
func (t *FixedTimeTrigger) NextTime(now time.Time) *time.Time {
|
||||
next := carbon.NewCarbon(now).SetHour(t.Hour).SetMinute(t.Minute)
|
||||
|
||||
// If the calculated time is before or equal to now, advance to the next day
|
||||
if !next.StdTime().After(now) {
|
||||
next = next.AddDay()
|
||||
}
|
||||
|
||||
return internal.Ptr(next.StdTime().Local())
|
||||
}
|
||||
|
||||
// Hash returns a stable hash value for the FixedTimeTrigger
|
||||
func (t *FixedTimeTrigger) Hash() uint64 {
|
||||
h := fnv.New64()
|
||||
fmt.Fprintf(h, "%d:%d", t.Hour, t.Minute)
|
||||
return h.Sum64()
|
||||
}
|
||||
|
||||
// NextTime returns the next time the sun will rise or set. If an offset is provided, it will be added to the calculated time.
|
||||
func (t *SunTrigger) NextTime(now time.Time) *time.Time {
|
||||
var sun time.Time
|
||||
|
||||
if t.sunset {
|
||||
_, sun = sunrise.SunriseSunset(t.latitude, t.longitude, now.Year(), now.Month(), now.Day())
|
||||
} else {
|
||||
sun, _ = sunrise.SunriseSunset(t.latitude, t.longitude, now.Year(), now.Month(), now.Day())
|
||||
}
|
||||
|
||||
// In the case that the sun does not rise or set on the given day, return nil
|
||||
if sun.IsZero() {
|
||||
return nil
|
||||
}
|
||||
|
||||
sun = sun.Local() // Convert to local time
|
||||
if t.offset != nil && *t.offset != 0 {
|
||||
sun = sun.Add(*t.offset) // Add the offset if provided and not zero
|
||||
}
|
||||
|
||||
return &sun
|
||||
}
|
||||
|
||||
// Hash returns a stable hash value for the SunTrigger
|
||||
func (t *SunTrigger) Hash() uint64 {
|
||||
h := fnv.New64()
|
||||
fmt.Fprintf(h, "%f:%f:%t", t.latitude, t.longitude, t.sunset)
|
||||
if t.offset != nil {
|
||||
fmt.Fprintf(h, ":%d", t.offset.Nanoseconds())
|
||||
}
|
||||
return h.Sum64()
|
||||
}
|
||||
|
||||
// CompositeDailySchedule combines multiple triggers into a single daily schedule.
|
||||
type CompositeDailySchedule struct {
|
||||
triggers []Trigger
|
||||
}
|
||||
|
||||
// NextTime returns the next time the first viable trigger will run.
|
||||
func (c *CompositeDailySchedule) NextTime(now time.Time) *time.Time {
|
||||
best := c.triggers[0].NextTime(now)
|
||||
|
||||
for _, trigger := range c.triggers[1:] {
|
||||
potential := trigger.NextTime(now)
|
||||
if potential != nil && (best == nil || potential.Before(*best)) {
|
||||
best = potential
|
||||
}
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
// Hash returns a stable hash value for the CompositeDailySchedule
|
||||
func (c *CompositeDailySchedule) Hash() uint64 {
|
||||
h := fnv.New64()
|
||||
for _, trigger := range c.triggers {
|
||||
fmt.Fprintf(h, "%d", trigger.Hash())
|
||||
}
|
||||
return h.Sum64()
|
||||
}
|
||||
300
internal/scheduling/daily_test.go
Normal file
300
internal/scheduling/daily_test.go
Normal file
@@ -0,0 +1,300 @@
|
||||
package scheduling
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFixedTimeTrigger_NextTime(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hour int
|
||||
minute int
|
||||
now time.Time
|
||||
expected time.Time
|
||||
}{
|
||||
{
|
||||
name: "same day trigger",
|
||||
hour: 14,
|
||||
minute: 30,
|
||||
now: time.Date(2025, 8, 2, 10, 0, 0, 0, time.Local),
|
||||
expected: time.Date(2025, 8, 2, 14, 30, 0, 0, time.Local),
|
||||
},
|
||||
{
|
||||
name: "next day trigger",
|
||||
hour: 8,
|
||||
minute: 0,
|
||||
now: time.Date(2025, 8, 2, 10, 0, 0, 0, time.Local),
|
||||
expected: time.Date(2025, 8, 3, 8, 0, 0, 0, time.Local),
|
||||
},
|
||||
{
|
||||
name: "exact time",
|
||||
hour: 10,
|
||||
minute: 0,
|
||||
now: time.Date(2025, 8, 2, 10, 0, 0, 0, time.Local),
|
||||
expected: time.Date(2025, 8, 3, 10, 0, 0, 0, time.Local),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
trigger := &FixedTimeTrigger{
|
||||
Hour: tt.hour,
|
||||
Minute: tt.minute,
|
||||
}
|
||||
|
||||
result := trigger.NextTime(tt.now)
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, tt.expected, *result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixedTimeTrigger_Hash(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hour int
|
||||
minute int
|
||||
expected uint64
|
||||
}{
|
||||
{
|
||||
name: "basic time",
|
||||
hour: 12,
|
||||
minute: 30,
|
||||
expected: 0, // We'll check it's not zero
|
||||
},
|
||||
{
|
||||
name: "midnight",
|
||||
hour: 0,
|
||||
minute: 0,
|
||||
expected: 0, // We'll check it's not zero
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
trigger := &FixedTimeTrigger{
|
||||
Hour: tt.hour,
|
||||
Minute: tt.minute,
|
||||
}
|
||||
|
||||
hash := trigger.Hash()
|
||||
assert.NotZero(t, hash)
|
||||
assert.IsType(t, uint64(0), hash)
|
||||
})
|
||||
}
|
||||
|
||||
// Test that different times produce different hashes
|
||||
trigger1 := &FixedTimeTrigger{Hour: 12, Minute: 30}
|
||||
trigger2 := &FixedTimeTrigger{Hour: 12, Minute: 31}
|
||||
trigger3 := &FixedTimeTrigger{Hour: 13, Minute: 30}
|
||||
|
||||
hash1 := trigger1.Hash()
|
||||
hash2 := trigger2.Hash()
|
||||
hash3 := trigger3.Hash()
|
||||
|
||||
assert.NotEqual(t, hash1, hash2)
|
||||
assert.NotEqual(t, hash1, hash3)
|
||||
assert.NotEqual(t, hash2, hash3)
|
||||
|
||||
// Test that same times produce same hashes
|
||||
trigger4 := &FixedTimeTrigger{Hour: 12, Minute: 30}
|
||||
hash4 := trigger4.Hash()
|
||||
assert.Equal(t, hash1, hash4)
|
||||
}
|
||||
|
||||
func TestSunTrigger_NextTime(t *testing.T) {
|
||||
// Test with a known location (New York City)
|
||||
lat, lon := 40.7128, -74.0060
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sunset bool
|
||||
offset *time.Duration
|
||||
now time.Time
|
||||
expected bool // whether we expect a result
|
||||
}{
|
||||
{
|
||||
name: "sunrise without offset",
|
||||
sunset: false,
|
||||
offset: nil,
|
||||
now: time.Date(2025, 8, 2, 10, 0, 0, 0, time.Local),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "sunset without offset",
|
||||
sunset: true,
|
||||
offset: nil,
|
||||
now: time.Date(2025, 8, 2, 10, 0, 0, 0, time.Local),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "sunrise with positive offset",
|
||||
sunset: false,
|
||||
offset: func() *time.Duration { d := 30 * time.Minute; return &d }(),
|
||||
now: time.Date(2025, 8, 2, 10, 0, 0, 0, time.Local),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "sunset with negative offset",
|
||||
sunset: true,
|
||||
offset: func() *time.Duration { d := -1 * time.Hour; return &d }(),
|
||||
now: time.Date(2025, 8, 2, 10, 0, 0, 0, time.Local),
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
trigger := &SunTrigger{
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
sunset: tt.sunset,
|
||||
offset: tt.offset,
|
||||
}
|
||||
|
||||
result := trigger.NextTime(tt.now)
|
||||
if tt.expected {
|
||||
require.NotNil(t, result)
|
||||
assert.False(t, result.IsZero())
|
||||
} else {
|
||||
// For polar regions or extreme dates, sun might not rise/set
|
||||
// This is acceptable behavior
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSunTrigger_Hash(t *testing.T) {
|
||||
lat1, lon1 := 40.7128, -74.0060
|
||||
lat2, lon2 := 51.5074, -0.1278
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
lat float64
|
||||
lon float64
|
||||
sunset bool
|
||||
offset *time.Duration
|
||||
}{
|
||||
{
|
||||
name: "sunrise without offset",
|
||||
lat: lat1,
|
||||
lon: lon1,
|
||||
sunset: false,
|
||||
offset: nil,
|
||||
},
|
||||
{
|
||||
name: "sunset with offset",
|
||||
lat: lat1,
|
||||
lon: lon1,
|
||||
sunset: true,
|
||||
offset: func() *time.Duration { d := 30 * time.Minute; return &d }(),
|
||||
},
|
||||
{
|
||||
name: "different location",
|
||||
lat: lat2,
|
||||
lon: lon2,
|
||||
sunset: false,
|
||||
offset: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
trigger := &SunTrigger{
|
||||
latitude: tt.lat,
|
||||
longitude: tt.lon,
|
||||
sunset: tt.sunset,
|
||||
offset: tt.offset,
|
||||
}
|
||||
|
||||
hash := trigger.Hash()
|
||||
assert.NotZero(t, hash)
|
||||
assert.IsType(t, uint64(0), hash)
|
||||
})
|
||||
}
|
||||
|
||||
// Test that different configurations produce different hashes
|
||||
trigger1 := &SunTrigger{latitude: lat1, longitude: lon1, sunset: false, offset: nil}
|
||||
trigger2 := &SunTrigger{latitude: lat1, longitude: lon1, sunset: true, offset: nil}
|
||||
trigger3 := &SunTrigger{latitude: lat2, longitude: lon2, sunset: false, offset: nil}
|
||||
|
||||
hash1 := trigger1.Hash()
|
||||
hash2 := trigger2.Hash()
|
||||
hash3 := trigger3.Hash()
|
||||
|
||||
assert.NotEqual(t, hash1, hash2)
|
||||
assert.NotEqual(t, hash1, hash3)
|
||||
assert.NotEqual(t, hash2, hash3)
|
||||
|
||||
// Test that same configurations produce same hashes
|
||||
trigger4 := &SunTrigger{latitude: lat1, longitude: lon1, sunset: false, offset: nil}
|
||||
hash4 := trigger4.Hash()
|
||||
assert.Equal(t, hash1, hash4)
|
||||
}
|
||||
|
||||
func TestCompositeDailySchedule_NextTime(t *testing.T) {
|
||||
trigger1 := &FixedTimeTrigger{Hour: 8, Minute: 0}
|
||||
trigger2 := &FixedTimeTrigger{Hour: 12, Minute: 0}
|
||||
trigger3 := &FixedTimeTrigger{Hour: 18, Minute: 0}
|
||||
|
||||
composite := &CompositeDailySchedule{
|
||||
triggers: []Trigger{trigger1, trigger2, trigger3},
|
||||
}
|
||||
|
||||
now := time.Date(2025, 8, 2, 10, 0, 0, 0, time.Local)
|
||||
result := composite.NextTime(now)
|
||||
|
||||
require.NotNil(t, result)
|
||||
// Should return the earliest trigger after now (12:00)
|
||||
expected := time.Date(2025, 8, 2, 12, 0, 0, 0, time.Local)
|
||||
assert.Equal(t, expected, *result)
|
||||
}
|
||||
|
||||
func TestCompositeDailySchedule_Hash(t *testing.T) {
|
||||
trigger1 := &FixedTimeTrigger{Hour: 8, Minute: 0}
|
||||
trigger2 := &FixedTimeTrigger{Hour: 12, Minute: 0}
|
||||
|
||||
composite1 := &CompositeDailySchedule{
|
||||
triggers: []Trigger{trigger1, trigger2},
|
||||
}
|
||||
|
||||
composite2 := &CompositeDailySchedule{
|
||||
triggers: []Trigger{trigger2, trigger1}, // Different order
|
||||
}
|
||||
|
||||
composite3 := &CompositeDailySchedule{
|
||||
triggers: []Trigger{trigger1}, // Different number of triggers
|
||||
}
|
||||
|
||||
hash1 := composite1.Hash()
|
||||
hash2 := composite2.Hash()
|
||||
hash3 := composite3.Hash()
|
||||
|
||||
assert.NotZero(t, hash1)
|
||||
assert.NotZero(t, hash2)
|
||||
assert.NotZero(t, hash3)
|
||||
assert.IsType(t, uint64(0), hash1)
|
||||
|
||||
// Different orders should produce different hashes
|
||||
assert.NotEqual(t, hash1, hash2)
|
||||
assert.NotEqual(t, hash1, hash3)
|
||||
assert.NotEqual(t, hash2, hash3)
|
||||
|
||||
// Same configuration should produce same hash
|
||||
composite4 := &CompositeDailySchedule{
|
||||
triggers: []Trigger{trigger1, trigger2},
|
||||
}
|
||||
hash4 := composite4.Hash()
|
||||
assert.Equal(t, hash1, hash4)
|
||||
}
|
||||
|
||||
func TestTriggerInterface(t *testing.T) {
|
||||
// Test that all trigger types implement the Trigger interface
|
||||
var _ Trigger = &FixedTimeTrigger{}
|
||||
var _ Trigger = &SunTrigger{}
|
||||
var _ Trigger = &CompositeDailySchedule{}
|
||||
}
|
||||
Reference in New Issue
Block a user