199 lines
6.9 KiB
Go
199 lines
6.9 KiB
Go
package tariff
|
||
|
||
import (
|
||
"testing"
|
||
"time"
|
||
|
||
octoDeGql "github.com/evcc-io/evcc/tariff/octopusde/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() octoDeGql.Agreement {
|
||
t1 := t0
|
||
t2 := t0.Add(15 * time.Minute)
|
||
t3 := t2.Add(15 * time.Minute)
|
||
return octoDeGql.Agreement{
|
||
IsActive: true,
|
||
UnitRateForecast: []octoDeGql.UnitRateForecast{
|
||
{
|
||
ValidFrom: t1,
|
||
ValidTo: t2,
|
||
UnitRateInformation: octoDeGql.ForecastUnitRateInformation{
|
||
TimeOfUseProductUnitRateInformation: octoDeGql.TimeOfUseProductUnitRateInformation{
|
||
Rates: []octoDeGql.Rate{
|
||
{NetUnitRateCentsPerKwh: "10.50", LatestGrossUnitRateCentsPerKwh: "12.495"},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
ValidFrom: t2,
|
||
ValidTo: t3,
|
||
UnitRateInformation: octoDeGql.ForecastUnitRateInformation{
|
||
TimeOfUseProductUnitRateInformation: octoDeGql.TimeOfUseProductUnitRateInformation{
|
||
Rates: []octoDeGql.Rate{
|
||
{NetUnitRateCentsPerKwh: "8.00", LatestGrossUnitRateCentsPerKwh: "9.52"},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
// simpleAgreement builds an agreement with a single fixed rate covering one year.
|
||
func simpleAgreement() octoDeGql.Agreement {
|
||
return octoDeGql.Agreement{
|
||
IsActive: true,
|
||
ValidFrom: t0,
|
||
ValidTo: t0.AddDate(1, 0, 0),
|
||
UnitRateInformation: octoDeGql.AgreementUnitRateInformation{
|
||
SimpleProductUnitRateInformation: octoDeGql.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() octoDeGql.Agreement {
|
||
return octoDeGql.Agreement{
|
||
IsActive: true,
|
||
UnitRateInformation: octoDeGql.AgreementUnitRateInformation{
|
||
TimeOfUseProductUnitRateInformation: octoDeGql.TouAgreementUnitRateInformation{
|
||
Rates: []octoDeGql.TouRate{
|
||
{
|
||
TimeslotName: "Day",
|
||
NetUnitRateCentsPerKwh: "30.00",
|
||
LatestGrossUnitRateCentsPerKwh: "35.70",
|
||
TimeslotActivationRules: []octoDeGql.TimeslotActivationRule{
|
||
{ActiveFromTime: "06:00:00", ActiveToTime: "22:00:00"},
|
||
},
|
||
},
|
||
{
|
||
TimeslotName: "Night",
|
||
NetUnitRateCentsPerKwh: "15.00",
|
||
LatestGrossUnitRateCentsPerKwh: "17.85",
|
||
TimeslotActivationRules: []octoDeGql.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 spanning the full agreement validity window.
|
||
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(1, 0, 0), rates[0].ValidTo)
|
||
assert.InDelta(t, 25.00, rates[0].NetUnitRateCentsPerKwh, 0.001)
|
||
assert.InDelta(t, 29.75, rates[0].GrossUnitRateCentsPerKwh, 0.001)
|
||
}
|
||
|
||
// 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)
|
||
}
|