tariff/octopusde_test.go
Rama master · réplica de lectura del repositorio original.
package tariff
import (
"testing"
"time"
krakengql "github.com/evcc-io/evcc/tariff/octopuskraken/graphql"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestOctopusDeConfigParse(t *testing.T) {
validConfig := map[string]any{
"email": "test@example.com",
"password": "testpassword",
"accountNumber": "A-12345678",
}
tariff, err := buildOctopusDeFromConfig(validConfig)
require.NoError(t, err)
require.NotNil(t, tariff)
missingEmailConfig := map[string]any{
"password": "testpassword",
"accountNumber": "A-12345678",
}
_, err = buildOctopusDeFromConfig(missingEmailConfig)
require.Error(t, err)
require.Contains(t, err.Error(), "missing email")
missingPasswordConfig := map[string]any{
"email": "test@example.com",
"accountNumber": "A-12345678",
}
_, err = buildOctopusDeFromConfig(missingPasswordConfig)
require.Error(t, err)
require.Contains(t, err.Error(), "missing password")
missingAccountNumberConfig := map[string]any{
"email": "test@example.com",
"password": "testpassword",
}
_, err = buildOctopusDeFromConfig(missingAccountNumberConfig)
require.Error(t, err)
require.Contains(t, err.Error(), "missing account number")
}
// t0 is a fixed reference time (Monday midnight UTC) used across
// forecast tests so that time-of-use period generation is fully deterministic.
var t0 = time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC)
// dynamicAgreement builds an agreement that uses a dynamic tariff:
// the unitRateForecast field contains two half-hour forecast slots with
// per-slot prices stored in TimeOfUseProductUnitRateInformation.
func dynamicAgreement() krakengql.Agreement {
t1 := t0
t2 := t0.Add(15 * time.Minute)
t3 := t2.Add(15 * time.Minute)
return krakengql.Agreement{
IsActive: true,
UnitRateForecast: []krakengql.UnitRateForecast{
{
ValidFrom: t1,
ValidTo: t2,
UnitRateInformation: krakengql.ForecastUnitRateInformation{
TimeOfUseProductUnitRateInformation: krakengql.TimeOfUseProductUnitRateInformation{
Rates: []krakengql.Rate{
{NetUnitRateCentsPerKwh: "10.50", LatestGrossUnitRateCentsPerKwh: "12.495"},
},
},
},
},
{
ValidFrom: t2,
ValidTo: t3,
UnitRateInformation: krakengql.ForecastUnitRateInformation{
TimeOfUseProductUnitRateInformation: krakengql.TimeOfUseProductUnitRateInformation{
Rates: []krakengql.Rate{
{NetUnitRateCentsPerKwh: "8.00", LatestGrossUnitRateCentsPerKwh: "9.52"},
},
},
},
},
},
}
}
// simpleAgreement builds an agreement with a single fixed rate covering one year.
func simpleAgreement() krakengql.Agreement {
return krakengql.Agreement{
IsActive: true,
ValidFrom: t0,
ValidTo: t0.AddDate(1, 0, 0),
UnitRateInformation: krakengql.AgreementUnitRateInformation{
SimpleProductUnitRateInformation: krakengql.SimpleProductUnitRateInformation{
NetUnitRateCentsPerKwh: "25.00",
LatestGrossUnitRateCentsPerKwh: "29.75",
},
},
}
}
// touAgreement builds an agreement with a two-slot time-of-use tariff:
// - Day rate 06:00–22:00
// - Night rate 22:00–06:00 (wraps past midnight)
func touAgreement() krakengql.Agreement {
return krakengql.Agreement{
IsActive: true,
UnitRateInformation: krakengql.AgreementUnitRateInformation{
TimeOfUseProductUnitRateInformation: krakengql.TouAgreementUnitRateInformation{
Rates: []krakengql.TouRate{
{
TimeslotName: "Day",
NetUnitRateCentsPerKwh: "30.00",
LatestGrossUnitRateCentsPerKwh: "35.70",
TimeslotActivationRules: []krakengql.TimeslotActivationRule{
{ActiveFromTime: "06:00:00", ActiveToTime: "22:00:00"},
},
},
{
TimeslotName: "Night",
NetUnitRateCentsPerKwh: "15.00",
LatestGrossUnitRateCentsPerKwh: "17.85",
TimeslotActivationRules: []krakengql.TimeslotActivationRule{
// 22:00 → 06:00 wraps past midnight
{ActiveFromTime: "22:00:00", ActiveToTime: "06:00:00"},
},
},
},
},
},
}
}
// TestRatesForAgreement_Dynamic verifies that a dynamic tariff agreement returns
// one RatePeriod per forecast entry, preserving ValidFrom/ValidTo and rates.
func TestRatesForAgreement_Dynamic(t *testing.T) {
rates, err := ratesForAgreement(dynamicAgreement(), t0)
require.NoError(t, err)
require.Len(t, rates, 2)
assert.Equal(t, t0, rates[0].ValidFrom)
assert.Equal(t, t0.Add(15*time.Minute), rates[0].ValidTo)
assert.InDelta(t, 10.50, rates[0].NetUnitRateCentsPerKwh, 0.001)
assert.InDelta(t, 12.495, rates[0].GrossUnitRateCentsPerKwh, 0.001)
assert.Equal(t, t0.Add(15*time.Minute), rates[1].ValidFrom)
assert.Equal(t, t0.Add(30*time.Minute), rates[1].ValidTo)
assert.InDelta(t, 8.00, rates[1].NetUnitRateCentsPerKwh, 0.001)
assert.InDelta(t, 9.52, rates[1].GrossUnitRateCentsPerKwh, 0.001)
}
// TestRatesForAgreement_Simple verifies that a simple fixed-rate agreement returns
// a single RatePeriod capped to the planning horizon (7 days), not the full
// agreement validity window. This prevents the planner from expanding a multi-year
// agreement into thousands of 15-minute intervals.
func TestRatesForAgreement_Simple(t *testing.T) {
rates, err := ratesForAgreement(simpleAgreement(), t0)
require.NoError(t, err)
require.Len(t, rates, 1)
assert.Equal(t, t0, rates[0].ValidFrom)
assert.Equal(t, t0.AddDate(0, 0, planDays), rates[0].ValidTo)
assert.InDelta(t, 25.00, rates[0].NetUnitRateCentsPerKwh, 0.001)
assert.InDelta(t, 29.75, rates[0].GrossUnitRateCentsPerKwh, 0.001)
}
// TestSimpleRateIndefiniteEnd verifies that a simple tariff with no end date (ValidTo
// is zero) is also capped to the planning horizon rather than being handled as
// indefinite (which would have previously resulted in run() adding one year).
func TestSimpleRateIndefiniteEndCappedToPlanningHorizon(t *testing.T) {
agr := simpleAgreement()
agr.ValidTo = time.Time{}
rates, err := ratesForAgreement(agr, t0)
require.NoError(t, err)
require.Len(t, rates, 1)
// A zero ValidTo has no cap from the agreement; computeHorizon returns now+planDays.
assert.Equal(t, t0.AddDate(0, 0, planDays), rates[0].ValidTo)
}
// TestSimpleRateStartCappedToNow verifies that when now is after the agreement's
// ValidFrom, the rate period begins at now rather than the (past) agreement start.
func TestSimpleRateStartCappedToNow(t *testing.T) {
now := t0.Add(48 * time.Hour) // 2 days after agreement start
agr := simpleAgreement()
rates, err := ratesForAgreement(agr, now)
require.NoError(t, err)
require.Len(t, rates, 1)
// ValidFrom should be now, not the past agreement start t0.
assert.Equal(t, now, rates[0].ValidFrom)
assert.Equal(t, now.AddDate(0, 0, planDays), rates[0].ValidTo)
}
// TestRatesForAgreement_TimeOfUse verifies that a two-slot ToU tariff is expanded
// into 14 RatePeriods (7 days × 2 slots). With testNow at midnight the entire
// first day is in the future, so no period is filtered out.
//
// Expected layout per day (repeated 7 times):
//
// rates[2n+0]: Day [day+06:00, day+22:00] net=30 gross=35.70
// rates[2n+1]: Night [day+22:00, day+30:00] net=15 gross=17.85 (wraps to next-day 06:00)
func TestRatesForAgreement_TimeOfUse(t *testing.T) {
rates, err := ratesForAgreement(touAgreement(), t0)
require.NoError(t, err)
require.Len(t, rates, 14)
// --- Day-0 day slot ---
assert.Equal(t, t0.Add(6*time.Hour), rates[0].ValidFrom)
assert.Equal(t, t0.Add(22*time.Hour), rates[0].ValidTo)
assert.InDelta(t, 30.00, rates[0].NetUnitRateCentsPerKwh, 0.001)
assert.InDelta(t, 35.70, rates[0].GrossUnitRateCentsPerKwh, 0.001)
// --- Day-0 night slot (wraps: 22:00 → next-day 06:00 = +30h) ---
assert.Equal(t, t0.Add(22*time.Hour), rates[1].ValidFrom)
assert.Equal(t, t0.Add(30*time.Hour), rates[1].ValidTo)
assert.InDelta(t, 15.00, rates[1].NetUnitRateCentsPerKwh, 0.001)
assert.InDelta(t, 17.85, rates[1].GrossUnitRateCentsPerKwh, 0.001)
// --- Day-6 day slot (last day within 7-day horizon) ---
day6 := t0.Add(6 * 24 * time.Hour)
assert.Equal(t, day6.Add(6*time.Hour), rates[12].ValidFrom)
assert.Equal(t, day6.Add(22*time.Hour), rates[12].ValidTo)
// --- Day-6 night slot (starts before the 7-day horizon, so included) ---
assert.Equal(t, day6.Add(22*time.Hour), rates[13].ValidFrom)
assert.Equal(t, day6.Add(30*time.Hour), rates[13].ValidTo)
}
// TestRatesForAgreement_InvalidAgreement verifies that an agreement with ValidFrom
// after the planning horizon end returns an error.
func TestRatesForAgreement_InvalidAgreement_NotYetStarted(t *testing.T) {
// Create an agreement that starts well into the future, beyond the 7-day planning horizon
futureStart := t0.AddDate(0, 0, planDays+10)
agr := simpleAgreement()
agr.ValidFrom = futureStart
rates, err := ratesForAgreement(agr, t0)
assert.Error(t, err)
assert.Contains(t, err.Error(), "agreement is not valid for the planning horizon")
assert.Nil(t, rates)
}
func TestRatesForAgreement_InvalidAgreement_AlreadyCompleted(t *testing.T) {
// Create an agreement that ended before the current time, beyond the 7-day planning horizon
pastStart := t0.AddDate(0, 0, -10)
pastEnd := t0.AddDate(0, 0, -3)
agr := simpleAgreement()
agr.ValidFrom = pastStart
agr.ValidTo = pastEnd
rates, err := ratesForAgreement(agr, t0)
assert.Error(t, err)
assert.Contains(t, err.Error(), "agreement is not valid for the planning horizon")
assert.Nil(t, rates)
}
// TestRatesForAgreement_TimeOfUseDSTChange verifies that slots stay on wall clock time
// across the 25 hour day at the end of DST.
func TestRatesForAgreement_TimeOfUseDSTChange(t *testing.T) {
loc, err := time.LoadLocation("Europe/Berlin")
require.NoError(t, err)
rates, err := ratesForAgreement(touAgreement(), time.Date(2026, 10, 25, 0, 0, 0, 0, loc))
require.NoError(t, err)
require.Len(t, rates, 14)
at := func(day, hour int) time.Time {
return time.Date(2026, 10, 25+day, hour, 0, 0, 0, loc)
}
for i, r := range rates {
day := i / 2
if i%2 == 0 {
assert.Equal(t, at(day, 6), r.ValidFrom, "day slot %d", i)
assert.Equal(t, at(day, 22), r.ValidTo, "day slot %d", i)
} else {
assert.Equal(t, at(day, 22), r.ValidFrom, "night slot %d", i)
assert.Equal(t, at(day+1, 6), r.ValidTo, "night slot %d", i)
}
}
}