package stats import ( "context" "math" "regexp" "strings" "testing" "time" "github.com/DATA-DOG/go-sqlmock" ) func hydrogenV35TestSample(at time.Time, mass, pressure, mileage, soc float64, runningMode int) HydrogenStreamSample { return HydrogenStreamSample{ VIN: "LA9GG64L0NBAF4175", Date: at.Format("2006-01-02"), SourceEndpoint: "source-a", EventID: at.Format(time.RFC3339Nano), EventTime: at, MassKG: mass, PressureMPa: pressure, TemperatureC: 30, NoiseKG: 0.05, RefuelThresholdKG: 1, MileageKM: mileage, MileageKnown: true, SOCPercent: soc, SOCKnown: true, VehicleState: 1, VehicleStateKnown: true, ChargeState: 3, ChargeStateKnown: true, RunningMode: runningMode, RunningModeKnown: true, FuelCellActive: runningMode == 2, FuelCellStateKnown: true, FuelCellPowerKW: 40, FuelCellPowerKnown: true, ConsumptionEligible: true, TankCapacityLiters: 520, } } func TestHydrogenV35RealtimeConfirmsMixedAndBalancesSOC(t *testing.T) { base := time.Date(2026, 9, 4, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)) charging := hydrogenV35TestSample(base, 10.1, 30, 100, 90, 1) charging.ChargeState = 1 state := newHydrogenV35RealtimeState(charging, HydrogenRealtimeParameters{BatteryCapacityKWh: 80, HydrogenEnergyKWhKG: 16}) values := []HydrogenStreamSample{ hydrogenV35TestSample(base.Add(time.Minute), 10.0, 29.5, 100, 90, 1), hydrogenV35TestSample(base.Add(2*time.Minute), 9.8, 29, 105, 88, 1), hydrogenV35TestSample(base.Add(3*time.Minute), 9.7, 28.5, 106, 87, 2), hydrogenV35TestSample(base.Add(3*time.Minute+10*time.Second), 9.2, 27, 112, 84, 2), hydrogenV35TestSample(base.Add(3*time.Minute+20*time.Second), 8.5, 25, 120, 80, 2), } for _, value := range values { if !state.add(value) { t.Fatalf("sample %s rejected", value.EventID) } } projection := state.projection() if projection.QualityStatus != "OK" { t.Fatalf("quality=%s reason=%s state=%#v", projection.QualityStatus, projection.QualityReason, state) } if math.Abs(projection.ConsumptionKG-1.2) > 0.001 || math.Abs(projection.PureMileageKM-6) > 0.001 || math.Abs(projection.MixedMileageKM-14) > 0.001 { t.Fatalf("projection=%#v", projection) } if projection.SOCDeltaPercent == nil || math.Abs(*projection.SOCDeltaPercent-(-7)) > 0.001 { t.Fatalf("SOC projection=%#v", projection) } if projection.CorrectedConsumptionKG == nil || math.Abs(*projection.CorrectedConsumptionKG-1.55) > 0.001 { t.Fatalf("balanced projection=%#v", projection) } } func TestHydrogenV35RealtimeWaitsForRefuelConfirmation(t *testing.T) { base := time.Date(2026, 9, 4, 9, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)) state := newHydrogenV35RealtimeState(hydrogenV35TestSample(base, 10, 20, 100, 80, 2), HydrogenRealtimeParameters{BatteryCapacityKWh: 80}) state.add(hydrogenV35TestSample(base.Add(time.Minute), 9, 19, 101, 79, 2)) state.add(hydrogenV35TestSample(base.Add(2*time.Minute), 12.5, 22.5, 101, 79, 2)) if state.RefuelCount != 0 || !state.RefuelCandidate.Set { t.Fatalf("refuel confirmed too early: %#v", state.RefuelCandidate) } state.add(hydrogenV35TestSample(base.Add(2*time.Minute+10*time.Second), 12.4, 22.4, 101, 79, 2)) state.add(hydrogenV35TestSample(base.Add(2*time.Minute+20*time.Second), 12.3, 22.3, 101, 79, 2)) if state.RefuelCount != 1 || state.RefuelCandidate.Set || state.HydrogenSegments != 1 { t.Fatalf("refuel not confirmed: count=%d segments=%d candidate=%#v", state.RefuelCount, state.HydrogenSegments, state.RefuelCandidate) } state.add(hydrogenV35TestSample(base.Add(3*time.Minute), 11.5, 21, 112, 75, 2)) if got := state.projection().ConsumptionKG; math.Abs(got-2.0) > 0.001 { t.Fatalf("consumption=%.3f, want 2.000; state=%#v", got, state) } } func TestHydrogenV35RealtimeDetectsThermalRefuelAcrossReportingGap(t *testing.T) { base := time.Date(2026, 9, 4, 9, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)) first := hydrogenV35TestSample(base, 5, 10, 100, 80, 2) first.TemperatureC = 20 state := newHydrogenV35RealtimeState(first, HydrogenRealtimeParameters{BatteryCapacityKWh: 80}) for index, offset := range []time.Duration{21*time.Minute + 50*time.Second, 22 * time.Minute, 22*time.Minute + 10*time.Second} { value := hydrogenV35TestSample(base.Add(offset), 9-float64(index)/10, 14-float64(index)/10, 100.3, 80, 2) value.TemperatureC = 24 state.add(value) } if state.RefuelCount != 1 { t.Fatalf("thermal refuel count=%d candidate=%#v", state.RefuelCount, state.RefuelCandidate) } } func TestHydrogenV35RealtimeRejectsLateFrameWithoutChangingState(t *testing.T) { base := time.Date(2026, 9, 4, 10, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)) state := newHydrogenV35RealtimeState(hydrogenV35TestSample(base, 10, 20, 100, 80, 2), HydrogenRealtimeParameters{}) before := state.SampleCount if state.add(hydrogenV35TestSample(base, 9, 19, 101, 79, 2)) { t.Fatal("same-time frame was accepted") } if state.SampleCount != before || state.LastMassKG != 10 { t.Fatalf("late frame changed state: %#v", state) } } func TestHydrogenV35RealtimeNoDataDoesNotExposePer100KMRates(t *testing.T) { base := time.Date(2026, 9, 4, 10, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)) state := newHydrogenV35RealtimeState(hydrogenV35TestSample(base, 10, 20, 100, 80, 2), HydrogenRealtimeParameters{BatteryCapacityKWh: 80}) state.add(hydrogenV35TestSample(base.Add(time.Minute), 9.5, 19, 105, 78, 2)) state.add(hydrogenV35TestSample(base.Add(2*time.Minute), 9, 18, 109, 76, 2)) projection := state.projection() if projection.QualityStatus != "NO_DATA" { t.Fatalf("quality=%s, want NO_DATA", projection.QualityStatus) } if projection.ConsumptionPer100KM != nil || projection.CorrectedPer100KM != nil { t.Fatalf("NO_DATA projection exposes per-100km rates: %#v", projection) } } func TestLoadHydrogenRealtimeParametersKeepsNewestEffectiveRow(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatal(err) } defer db.Close() mock.ExpectQuery(regexp.QuoteMeta("SELECT UPPER(TRIM(vin)),battery_capacity_kwh,hydrogen_energy_kwh_per_kg")). WithArgs("2026-09-04", "2026-09-04"). WillReturnRows(sqlmock.NewRows([]string{"vin", "battery_capacity_kwh", "hydrogen_energy_kwh_per_kg"}). AddRow("LA9GG64L0NBAF4175", 80.0, 15.8). AddRow("LA9GG64L0NBAF4175", 75.0, 16.0)) values, err := LoadHydrogenRealtimeParameters(context.Background(), db, "2026-09-04") if err != nil { t.Fatal(err) } if got := values["LA9GG64L0NBAF4175"]; got.BatteryCapacityKWh != 80 || got.HydrogenEnergyKWhKG != 15.8 { t.Fatalf("parameters=%#v", got) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatal(err) } } func TestHydrogenV35RealtimeSQLMarksPreviewWithoutOverwritingFinalSemantics(t *testing.T) { for _, want := range []string{ "calculation_phase", "'PRELIMINARY'", "algorithm_version=VALUES(algorithm_version)", "soc_balanced_consumption_kg", "consumption_kg_per_100km", } { if !regexp.MustCompile(regexp.QuoteMeta(want)).MatchString(projectHydrogenV35RealtimeSQL) { t.Fatalf("realtime projection SQL missing %q", want) } } if got := strings.Count(strings.Split(projectHydrogenV35RealtimeSQL, "ON DUPLICATE KEY UPDATE")[0], "?"); got != 25 { t.Fatalf("realtime projection placeholders=%d, want 25", got) } }