Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions tariff/fixed.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ func NewFixedFromConfig(other map[string]any) (api.Tariff, error) {
func (t *Fixed) Rates() (api.Rates, error) {
var res api.Rates

start := now.With(t.clock.Now().Local()).BeginningOfDay()
start := now.With(t.clock.Now()).BeginningOfDay()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

required, fix clock dependencies

for i := range 7 {
dayStart := start.AddDate(0, 0, i)
dow := fixed.Day((int(start.Weekday()) + i) % 7)
Expand All @@ -110,8 +110,9 @@ func (t *Fixed) Rates() (api.Rates, error) {
var zone *fixed.Zone
for j := len(zones) - 1; j >= 0; j-- {
if zones[j].Hours.Contains(m) {
zone = &zones[j]
break
if zone == nil || fixed.MoreSpecific(zones[j], *zone) {
zone = &zones[j]
}
}
}

Expand Down
22 changes: 22 additions & 0 deletions tariff/fixed/zone.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,5 +78,27 @@ HOURS:
res = append(res, HourMin{Hour: hour, Min: 0})
}

// Sort markers by time to ensure correct ordering
slices.SortFunc(res, func(a, b HourMin) int {
return a.Minutes() - b.Minutes()
})

Comment on lines +81 to +85
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

major required fix - paired with correcting rates clock

return res
}

// MoreSpecific returns true if zone a is more specific than zone b.
// A zone is more specific if it has constraints on fewer days or months.
func MoreSpecific(a, b Zone) bool {
return specificity(a) > specificity(b)
}

func specificity(z Zone) int {
spec := 0
if len(z.Days) > 0 && len(z.Days) < 7 {
spec++
}
if len(z.Months) > 0 && len(z.Months) < 12 {
spec++
}
return spec
}
60 changes: 60 additions & 0 deletions tariff/fixed_specificity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package tariff

import (
"testing"
"time"

"github.com/benbjohnson/clock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestFixedSpecificity validates that MoreSpecific logic is necessary.
// Edge case: zones with IDENTICAL hours but different month/day constraints.
// Without MoreSpecific, the result would depend on undefined sort order.
func TestFixedSpecificity(t *testing.T) {
at, err := NewFixedFromConfig(map[string]any{
"price": 0.30,
"zones": []struct {
Price float64
Hours string
Months string
}{
// REVERSED: specific first, general second
// Without MoreSpecific, this will fail!
{0.10, "0-5", "Jan-Mar,Oct-Dec"}, // specific (winter only)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Am Ende müssen vmtl. auch Überlappungen gehandhabt werden? In jedem Fall wäre es schön diese- wenn nicht vergleichbar- als Fehler identifizieren zu können.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aber nur wenn wirklich "ambigious"? Das braucht ein paar Helper

{0.20, "0-5", ""}, // general (all year)
},
})
require.NoError(t, err)

testCases := []struct {
month time.Month
expected float64
}{
{time.January, 0.10}, // winter: specific zone wins
{time.June, 0.20}, // summer: general zone
{time.December, 0.10}, // winter: specific zone wins
}

// Test both UTC and Local timezones
timezones := []*time.Location{time.UTC, time.Local}
for _, tz := range timezones {
t.Run(tz.String(), func(t *testing.T) {
for _, tc := range testCases {
clock := clock.NewMock()
at.(*Fixed).clock = clock
clock.Set(time.Date(2025, tc.month, 15, 3, 0, 0, 0, tz))

rr, err := at.Rates()
require.NoError(t, err)

r, err := rr.At(clock.Now())
require.NoError(t, err)

assert.Equal(t, tc.expected, r.Value,
"TZ=%s, %s: expected %.2f", tz, tc.month, tc.expected)
}
})
}
}
57 changes: 57 additions & 0 deletions tariff/fixed_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,60 @@ func TestFixedSplitZones(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, expect, rates)
}

func TestFixedMonthsSorting(t *testing.T) {
at, err := NewFixedFromConfig(map[string]any{
"zones": []struct {
Price float64
Hours string
Months string
}{
{0.1, "0-5", ""}, // all year
{0.2, "5-0", ""}, // all year
{0.3, "2-4", "Jun"}, // Jun only
{0.4, "18-20", "Jun"}, // Jun only
// TODO: specific days
},
})
require.NoError(t, err)

tc := []struct {
m, d, h int
rate float64
}{
// all year
{1, 1, 0, 0.1},
{1, 1, 2, 0.1},
{1, 1, 5, 0.2},
{1, 1, 18, 0.2},

// Jun only
{6, 1, 0, 0.1},
{6, 1, 2, 0.3},
{6, 1, 5, 0.2},
{6, 1, 18, 0.4},
}

// Test both UTC and Local timezones to verify clock timezone is respected
timezones := []*time.Location{time.UTC, time.Local}
for _, tz := range timezones {
t.Run(tz.String(), func(t *testing.T) {
for _, tc := range tc {
clock := clock.NewMock()
at.(*Fixed).clock = clock

clock.Set(time.Date(2025, time.Month(tc.m), tc.d, 0, 0, 0, 0, tz))

rr, err := at.Rates()
require.NoError(t, err)

r, err := rr.At(clock.Now().Add(time.Duration(tc.h) * time.Hour))
require.NoError(t, err)

assert.Equal(t, tc.rate, r.Value,
"TZ=%s, %04d-%02d-%02d %02d:00",
tz, 2025, tc.m, tc.d, tc.h)
}
})
}
}
Loading