package tariff import ( "testing" "time" "github.com/benbjohnson/clock" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/tariff/fixed" "github.com/jinzhu/now" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestFixed(t *testing.T) { tf := &Fixed{ embed: &embed{}, clock: clock.NewMock(), zones: []fixed.Zone{ {Price: 0.3}, }, } var expect api.Rates for dow := range 7 { dayStart := now.With(tf.clock.Now()).BeginningOfDay().AddDate(0, 0, dow) for hour := range 24 { expect = append(expect, api.Rate{ Value: 0.3, Start: dayStart.Add(time.Hour * time.Duration(hour)), End: dayStart.Add(time.Hour * time.Duration(hour+1)), }) } } rates, err := tf.Rates() require.NoError(t, err) assert.Equal(t, expect, rates) } func TestFixedSplitZones(t *testing.T) { at, err := NewFixedFromConfig(map[string]any{ "price": 0.5, "zones": []struct { Price float64 Hours string }{ {0.1, "0-5:30,20-21,21-0"}, }, }) require.NoError(t, err) tf := at.(*Fixed) tf.clock = clock.NewMock() var expect api.Rates for i := range 7 { dayStart := now.With(tf.clock.Now()).BeginningOfDay().AddDate(0, 0, i) // 00:00-05:00 0.1 for hour := range 5 { expect = append(expect, api.Rate{ Value: 0.1, Start: dayStart.Add(time.Hour * time.Duration(hour)), End: dayStart.Add(time.Hour * time.Duration(hour+1)), }) } // 05:00-05:30 0.1 expect = append(expect, api.Rate{ Value: 0.1, Start: dayStart.Add(5 * time.Hour), End: dayStart.Add(5*time.Hour + 30*time.Minute), }) // 05:30-06:00 0.5 expect = append(expect, api.Rate{ Value: 0.5, Start: dayStart.Add(5*time.Hour + 30*time.Minute), End: dayStart.Add(6 * time.Hour), }) // 06:00-20:00 0.5 for hour := 6; hour < 20; hour++ { expect = append(expect, api.Rate{ Value: 0.5, Start: dayStart.Add(time.Hour * time.Duration(hour)), End: dayStart.Add(time.Hour * time.Duration(hour+1)), }) } // 20:00-21:00,21:00-00:00 0.1 for hour := 20; hour < 24; hour++ { expect = append(expect, api.Rate{ Value: 0.1, Start: dayStart.Add(time.Hour * time.Duration(hour)), End: dayStart.Add(time.Hour * time.Duration(hour+1)), }) } } rates, err := tf.Rates() require.NoError(t, err) assert.Equal(t, expect, rates) } func TestFixedChargesZonesMarkers(t *testing.T) { at, err := NewFixedFromConfig(map[string]any{ "price": 0.5, "chargesZones": []struct { Charges float64 Hours string }{ {0.1, "0-5:30"}, }, }) require.NoError(t, err) tf := at.(*Fixed) tf.clock = clock.NewMock() rates, err := tf.Rates() require.NoError(t, err) dayStart := now.With(tf.clock.Now()).BeginningOfDay() // charges zone ends mid-hour at 05:30 - the price change must take effect there, not at 06:00 require.GreaterOrEqual(t, len(rates), 2) assert.Equal(t, dayStart.Add(5*time.Hour), rates[5].Start) assert.Equal(t, dayStart.Add(5*time.Hour+30*time.Minute), rates[5].End) assert.Equal(t, 0.6, rates[5].Value) assert.Equal(t, dayStart.Add(5*time.Hour+30*time.Minute), rates[6].Start) assert.Equal(t, 0.5, rates[6].Value) } func TestFixedChargesZonesOrder(t *testing.T) { at, err := NewFixedFromConfig(map[string]any{ "price": 0.5, "zones": []struct { Price float64 Hours string }{ {0.2, "12-14"}, }, "chargesZones": []struct { Charges float64 Hours string }{ {0.1, "0-5:30"}, }, }) require.NoError(t, err) tf := at.(*Fixed) tf.clock = clock.NewMock() rates, err := tf.Rates() require.NoError(t, err) // rates must stay ordered and contiguous even if charges zone markers don't align with price zones for i, r := range rates { require.True(t, r.End.After(r.Start), "invalid rate %d: %v", i, r) if i > 0 { require.Equal(t, rates[i-1].End, r.Start, "non-contiguous rate %d: %v", i, r) } } } func TestFixedDstSpringForward(t *testing.T) { loc, err := time.LoadLocation("Europe/Berlin") require.NoError(t, err) tf := &Fixed{ embed: &embed{}, clock: clock.NewMock(), zones: []fixed.Zone{{Price: 0.3}}, } // 2026-03-29 02:00 -> 03:00 tf.clock.(*clock.Mock).Set(time.Date(2026, 3, 27, 12, 0, 0, 0, loc)) rates, err := tf.Rates() require.NoError(t, err) // one hourly slot dropped on the 23h day start := now.With(tf.clock.Now()).BeginningOfDay() require.Len(t, rates, 7*24-1) assert.Equal(t, start, rates[0].Start) assert.Equal(t, start.AddDate(0, 0, 7), rates[len(rates)-1].End) for i, r := range rates { require.True(t, r.End.After(r.Start), "invalid rate %d: %v", i, r) if i > 0 { require.Equal(t, rates[i-1].End, r.Start, "non-contiguous rate %d: %v", i, r) } } _, short := shortSlot(rates) assert.False(t, short) } func TestFixedDstSpringForwardSkippedMarker(t *testing.T) { loc, err := time.LoadLocation("Europe/Berlin") require.NoError(t, err) zones, err := fixed.ParseZones([]fixed.ZoneSpec{{Price: 0.1, Hours: "00:00-02:30"}}) require.NoError(t, err) tf := &Fixed{ embed: &embed{}, clock: clock.NewMock(), zones: append([]fixed.Zone{{Price: 0.3}}, zones...), } // 2026-03-29 02:00 -> 03:00, 02:30 does not exist tf.clock.(*clock.Mock).Set(time.Date(2026, 3, 29, 0, 30, 0, 0, loc)) rates, err := tf.Rates() require.NoError(t, err) for i, r := range rates { require.True(t, r.End.After(r.Start), "invalid rate %d: %v", i, r) if i > 0 { require.Equal(t, rates[i-1].End, r.Start, "non-contiguous rate %d: %v", i, r) } } r, err := rates.At(time.Date(2026, 3, 29, 1, 30, 0, 0, loc)) require.NoError(t, err) assert.Equal(t, 0.1, r.Value) r, err = rates.At(time.Date(2026, 3, 29, 3, 0, 0, 0, loc)) require.NoError(t, err) assert.Equal(t, 0.3, r.Value) } func TestFixedDstFallBack(t *testing.T) { loc, err := time.LoadLocation("Europe/Berlin") require.NoError(t, err) zones, err := fixed.ParseZones([]fixed.ZoneSpec{{Price: 0.1, Hours: "19:00-20:00"}}) require.NoError(t, err) tf := &Fixed{ embed: &embed{}, clock: clock.NewMock(), zones: append([]fixed.Zone{{Price: 0.3}}, zones...), } // 2026-10-25 03:00 -> 02:00 tf.clock.(*clock.Mock).Set(time.Date(2026, 10, 25, 12, 0, 0, 0, loc)) rates, err := tf.Rates() require.NoError(t, err) var cheap int for _, r := range rates { if r.Value != 0.1 { continue } cheap++ assert.Equal(t, 19, r.Start.In(loc).Hour(), "start %v", r.Start) assert.Equal(t, time.Hour, r.End.Sub(r.Start), "duration %v", r) } assert.Equal(t, 7, cheap) }