package models import ( "math" "testing" "time" ) // The charging figures are derived from the whole history on every read, so the // window rules are the thing worth pinning down: what closes a window, what // folds into it, and what makes one uncomputable. func day(n int) time.Time { return time.Date(2026, 3, n, 8, 0, 0, 0, time.UTC) } func TestComputeChargingDerivedWindows(t *testing.T) { // A first full charge (no window before it), then 300 km on 45 kWh split // across a partial top-up and the full charge that closes the window. entries := []ChargingSession{ {Date: day(1), Km: 10000, Kwh: 50, Cost: 100, FullCharge: true}, {Date: day(3), Km: 10120, Kwh: 15, Cost: 45}, {Date: day(6), Km: 10300, Kwh: 30, Cost: 75, FullCharge: true}, } ComputeChargingDerived(entries) // The first full charge has nothing before it to measure against. if entries[0].ConsumptionKwh100 != nil { t.Errorf("first full charge got a consumption figure: %v", *entries[0].ConsumptionKwh100) } // A partial charge never closes a window — it folds into the next full one. if entries[1].ConsumptionKwh100 != nil { t.Errorf("partial charge got a consumption figure: %v", *entries[1].ConsumptionKwh100) } closing := entries[2] if closing.DistanceKm == nil || *closing.DistanceKm != 300 { t.Fatalf("distance = %v, want 300", closing.DistanceKm) } // 15 + 30 kWh over 300 km: the partial counts, the opening charge does not. if closing.KwhUsed == nil || *closing.KwhUsed != 45 { t.Fatalf("kwhUsed = %v, want 45", closing.KwhUsed) } assertFloat(t, "consumption", closing.ConsumptionKwh100, 15) // 45/300*100 assertFloat(t, "kmPerKwh", closing.KmPerKwh, 300.0/45.0) // 6.67 assertFloat(t, "costPerKm", closing.CostPerKm, (45+75)/300.0) // 0.40 assertFloat(t, "pricePerKwh", closing.PricePerKwh, 75.0/30.0) // 2.50 } func TestComputeChargingDerivedMissedSession(t *testing.T) { // The car was charged somewhere without being logged, so the kWh on record // do not account for the distance: reporting a figure would be fiction. entries := []ChargingSession{ {Date: day(1), Km: 10000, Kwh: 50, Cost: 100, FullCharge: true}, {Date: day(5), Km: 10400, Kwh: 40, Cost: 80, FullCharge: true, MissedSession: true}, } ComputeChargingDerived(entries) if entries[1].ConsumptionKwh100 != nil { t.Errorf("window with a missed session got a figure: %v", *entries[1].ConsumptionKwh100) } // The price of that charge is still known — it is on the receipt. assertFloat(t, "pricePerKwh", entries[1].PricePerKwh, 2) } func TestComputeChargingStats(t *testing.T) { // Two computable windows of different lengths: 100 km at 20 kWh/100km and // 300 km at 10 kWh/100km. The average has to be distance-weighted (40 kWh // over 400 km = 10 kWh/100km), not the mean of the two figures (15). entries := []ChargingSession{ {Date: day(1), Km: 10000, Kwh: 10, Cost: 20, FullCharge: true}, {Date: day(2), Km: 10100, Kwh: 20, Cost: 40, FullCharge: true}, {Date: day(4), Km: 10400, Kwh: 30, Cost: 60, FullCharge: true}, } ComputeChargingDerived(entries) st := ComputeChargingStats(entries) if st.Entries != 3 || st.TotalKwh != 60 || st.TotalCost != 120 { t.Fatalf("totals = %d entries, %v kWh, %v cost", st.Entries, st.TotalKwh, st.TotalCost) } if st.TrackedDistanceKm != 400 { t.Fatalf("trackedDistance = %d, want 400", st.TrackedDistanceKm) } assertFloat(t, "avg", st.AvgConsumptionKwh100, 12.5) // 50 kWh over 400 km assertFloat(t, "best", st.BestConsumptionKwh100, 10) // the 300 km window assertFloat(t, "worst", st.WorstConsumptionKwh100, 20) assertFloat(t, "avgPricePerKwh", st.AvgPricePerKwh, 2) assertFloat(t, "costPerKm", st.CostPerKm, 0.25) // 100 spent over 400 km } func TestComputeChargingStatsEmpty(t *testing.T) { st := ComputeChargingStats(nil) if st.Entries != 0 || st.AvgConsumptionKwh100 != nil || st.FirstDate != nil { t.Errorf("empty history summarised as %+v", st) } } func assertFloat(t *testing.T, name string, got *float64, want float64) { t.Helper() if got == nil { t.Fatalf("%s = nil, want %v", name, want) } if math.Abs(*got-want) > 1e-9 { t.Fatalf("%s = %v, want %v", name, *got, want) } }