feat: new schedule triggering, new builder pattern in internal

This commit is contained in:
2025-08-02 00:45:31 -05:00
parent 79f810b1f8
commit bbba55574f
5 changed files with 881 additions and 0 deletions

View File

@@ -47,3 +47,7 @@ func GetEquivalentWebsocketScheme(scheme string) (string, error) {
return "", fmt.Errorf("unexpected scheme: %s", scheme) return "", fmt.Errorf("unexpected scheme: %s", scheme)
} }
} }
func Ptr[T any](v T) *T {
return &v
}

View 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
}

View 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)
})
}
}

View 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()
}

View 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{}
}