package tariff import ( "runtime" "testing" "time" "github.com/evcc-io/evcc/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestTotalPriceFallback(t *testing.T) { e := embed{Charges: 0.10, Tax: 0.19} require.NoError(t, e.init()) got := e.totalPrice(0.20, time.Now()) assert.InDelta(t, (0.20+0.10)*1.19, got, 1e-9) } func TestTotalPriceMatchingZone(t *testing.T) { e := embed{ Charges: 0.50, ChargesZones_: []chargesZoneConfig{ {Charges: 0.10, Hours: "00:00-06:00"}, }, } require.NoError(t, e.init()) ts := time.Date(2026, 1, 15, 2, 0, 0, 0, time.Local) got := e.totalPrice(0.20, ts) assert.InDelta(t, 0.20+0.10, got, 1e-9) } func TestTotalPriceNonMatchingZone(t *testing.T) { e := embed{ Charges: 0.50, ChargesZones_: []chargesZoneConfig{ {Charges: 0.10, Hours: "00:00-06:00"}, }, } require.NoError(t, e.init()) ts := time.Date(2026, 1, 15, 12, 0, 0, 0, time.Local) got := e.totalPrice(0.20, ts) assert.InDelta(t, 0.20+0.50, got, 1e-9) } func TestTotalPriceNegativeChargesZone(t *testing.T) { e := embed{ Charges: 0.10, ChargesZones_: []chargesZoneConfig{ {Charges: -0.05, Hours: "10:00-12:00"}, }, } require.NoError(t, e.init()) ts := time.Date(2026, 1, 15, 11, 0, 0, 0, time.Local) got := e.totalPrice(0.20, ts) assert.InDelta(t, 0.20-0.05, got, 1e-9) } func TestTotalPriceLastZoneWins(t *testing.T) { e := embed{ ChargesZones_: []chargesZoneConfig{ {Charges: 0.10, Hours: "00:00-06:00"}, {Charges: 0.05, Hours: "02:00-04:00"}, }, } require.NoError(t, e.init()) // 03:00 is covered by both zones; later entry wins ts := time.Date(2026, 1, 15, 3, 0, 0, 0, time.Local) assert.InDelta(t, 0.20+0.05, e.totalPrice(0.20, ts), 1e-9) // 05:00 is only covered by the broader zone ts = time.Date(2026, 1, 15, 5, 0, 0, 0, time.Local) assert.InDelta(t, 0.20+0.10, e.totalPrice(0.20, ts), 1e-9) } func TestEffectiveChargesMonthFilter(t *testing.T) { e := embed{ Charges: 0.20, ChargesZones_: []chargesZoneConfig{ {Charges: 0.05, Months: "Jan-Mar,Oct-Dec", Hours: "00:00-05:00"}, }, } require.NoError(t, e.init()) // February, in window: zone applies ts := time.Date(2026, 2, 15, 3, 0, 0, 0, time.Local) assert.InDelta(t, 0.05, e.effectiveCharges(ts), 1e-9) // June, in hour window but month does not match: fallback ts = time.Date(2026, 6, 15, 3, 0, 0, 0, time.Local) assert.InDelta(t, 0.20, e.effectiveCharges(ts), 1e-9) } func TestTotalPriceFormulaSeesResolvedCharges(t *testing.T) { e := embed{ Charges: 0.10, ChargesZones_: []chargesZoneConfig{ {Charges: 0.30, Hours: "10:00-12:00"}, }, Formula: "(price + charges) * 2", } require.NoError(t, e.init()) // In zone: charges resolves to 0.30 ts := time.Date(2026, 1, 15, 11, 0, 0, 0, time.Local) assert.InDelta(t, (0.20+0.30)*2, e.totalPrice(0.20, ts), 1e-9) // Out of zone: falls back to base 0.10 ts = time.Date(2026, 1, 15, 14, 0, 0, 0, time.Local) assert.InDelta(t, (0.20+0.10)*2, e.totalPrice(0.20, ts), 1e-9) } func TestEmbedDecodeChargesZones(t *testing.T) { other := map[string]any{ "charges": 0.20, "chargesZones": []map[string]any{ {"charges": 0.05, "months": "Jan-Mar", "hours": "00:00-05:00"}, {"charges": 0.30, "hours": "18:00-21:00"}, }, } var cc struct { embed `mapstructure:",squash"` } require.NoError(t, util.DecodeOther(other, &cc)) require.NoError(t, cc.embed.init()) assert.Len(t, cc.ChargesZones_, 2) assert.InDelta(t, 0.05, cc.ChargesZones_[0].Charges, 1e-9) assert.Equal(t, "Jan-Mar", cc.ChargesZones_[0].Months) assert.Len(t, cc.chargesZones, 2) } func TestTotalPriceFormulaMemoryStable(t *testing.T) { ts := time.Date(2026, 1, 15, 11, 0, 0, 0, time.Local) e := embed{ Charges: 0.10, Tax: 0.19, Formula: "(price + charges) * (1 + tax)", } require.NoError(t, e.init()) sample := func() uint64 { runtime.GC() var ms runtime.MemStats runtime.ReadMemStats(&ms) return ms.HeapAlloc } before := int64(sample()) for i := 0; i < 10_000; i++ { e.totalPrice(0.20, ts) } after := int64(sample()) runtime.KeepAlive(&e) // Interpreter grows ~4.5KB per parsed statement; 10k calls would add ~45MB assert.Less(t, after-before, int64(8<<20)) // 8MB } func TestTotalPriceFormulaPanic(t *testing.T) { e := embed{Formula: "float64([]int{1}[int(price)])"} require.NoError(t, e.init()) _, err := e.calc(5, 0, time.Now()) require.ErrorContains(t, err, "panic") assert.Zero(t, e.totalPrice(5, time.Now())) } func TestTotalPriceFormulaFloatKind(t *testing.T) { e := embed{Formula: "float32(price)"} require.NoError(t, e.init()) // the formula yields float32, so the result only carries float32 precision assert.InDelta(t, 0.20, e.totalPrice(0.20, time.Now()), 1e-6) } func TestTotalPriceFormulaScript(t *testing.T) { e := embed{ Tax: 0.1, Formula: "verkko := 0.025; if ts.Hour() < 7 || ts.Hour() >= 22 { verkko = 0.0112 }; math.Max((price + charges + tax)*1.255 + verkko, 0.0)", } require.NoError(t, e.init()) ts := time.Date(2026, 1, 1, 12, 0, 0, 0, time.Local) assert.InDelta(t, (1+0.1)*1.255+0.025, e.totalPrice(1, ts), 1e-9) ts = time.Date(2026, 1, 1, 23, 0, 0, 0, time.Local) assert.InDelta(t, (1+0.1)*1.255+0.0112, e.totalPrice(1, ts), 1e-9) } func TestTotalPriceFormulaMultiline(t *testing.T) { e := embed{Formula: "x := 2.0 // factor\nprice * x // result"} require.NoError(t, e.init()) assert.InDelta(t, 0.4, e.totalPrice(0.2, time.Now()), 1e-9) } func TestTotalPriceFormulaNoTrailingExpression(t *testing.T) { e := embed{Formula: "x := price"} require.ErrorContains(t, e.init(), "must end with an expression") }