package stats import ( "context" "math" "strings" "testing" "time" "github.com/DATA-DOG/go-sqlmock" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" ) func TestCalculateGPSMileageSegmentMatchesTrackSafetyWindow(t *testing.T) { start := time.Date(2026, 7, 19, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)) previous := GPSMileagePoint{EventTime: start, Longitude: 121.4737, Latitude: 31.2304} valid := CalculateGPSMileageSegment(previous, GPSMileagePoint{ EventTime: start.Add(5 * time.Minute), Longitude: 121.4837, Latitude: 31.2304, }) if !valid.Usable || valid.DistanceKM <= 0.9 || valid.DistanceKM >= 1.1 { t.Fatalf("valid segment = %+v, want about 0.95 km", valid) } longGap := CalculateGPSMileageSegment(previous, GPSMileagePoint{ EventTime: start.Add(11 * time.Minute), Longitude: 121.4837, Latitude: 31.2304, }) if !longGap.LongGap || longGap.Usable { t.Fatalf("long gap segment = %+v", longGap) } jump := CalculateGPSMileageSegment(previous, GPSMileagePoint{ EventTime: start.Add(time.Minute), Longitude: 121.5737, Latitude: 31.2304, }) if !jump.BadJump || jump.Usable { t.Fatalf("jump segment = %+v", jump) } outOfOrder := CalculateGPSMileageSegment(previous, GPSMileagePoint{ EventTime: start, Longitude: 121.4747, Latitude: 31.2304, }) if !outOfOrder.OutOfOrder || outOfOrder.Usable { t.Fatalf("out-of-order segment = %+v", outOfOrder) } } func TestGPSMileagePointFromEnvelopeUsesNaturalDayAndStableSource(t *testing.T) { loc := time.FixedZone("Asia/Shanghai", 8*3600) eventTime := time.Date(2026, 7, 19, 23, 59, 58, 0, loc) env := envelope.FrameEnvelope{ EventID: "evt-gps-1", Protocol: envelope.ProtocolJT808, VIN: "LMRKH9AC7R1004120", Phone: "64341233712", SourceEndpoint: "122.152.221.156:33805", EventTimeMS: eventTime.UnixMilli(), Fields: map[string]any{ "jt808.location.longitude": 121.4737, "jt808.location.latitude": 31.2304, }, } identity := SourceIdentity{ Protocol: envelope.ProtocolJT808, SourceIP: "122.152.221.156", SourceCode: "dongfang_beidou", SourceKind: "PLATFORM", PlatformName: "东方北斗", } point, ok := GPSMileagePointFromEnvelope(env, identity, loc) if !ok { t.Fatal("expected valid GPS point") } if point.StatDate != "2026-07-19" || point.SourceKey != "JT808:64341233712@PLATFORM:dongfang_beidou" { t.Fatalf("point = %+v", point) } candidateKey := gpsMileageCandidateSourceKey(point) if candidateKey != "JT808:64341233712#GPS_COORDINATE:122.152.221.156" || strings.Contains(candidateKey, platformSourceKeyPrefix) { t.Fatalf("coordinate candidate key must remain lower-trust than an odometer platform source: %q", candidateKey) } } func TestGPSMileagePointFromYutongEnvelopeRequiresMovement(t *testing.T) { loc := time.FixedZone("Asia/Shanghai", 8*3600) eventTime := time.Date(2026, 7, 19, 8, 0, 0, 0, loc) env := envelope.FrameEnvelope{ EventID: "evt-yutong-gps", Protocol: envelope.ProtocolYutongMQTT, VIN: "LMRKH9AC9R1004099", DeviceID: "LMRKH9AC9R1004099", SourceEndpoint: "mqtt://yutong/ytforward/shln/2", EventTimeMS: eventTime.UnixMilli(), Fields: map[string]any{ "yutong_mqtt.data.longitude": 121.4737, "yutong_mqtt.data.latitude": 31.2304, "yutong_mqtt.data.meter_speed": 18, }, } identity := SourceIdentity{ Protocol: envelope.ProtocolYutongMQTT, SourceIP: "mqtt", SourceCode: "yutong", SourceKind: "PLATFORM", PlatformName: "宇通", } point, ok := GPSMileagePointFromEnvelope(env, identity, loc) if !ok { t.Fatal("moving Yutong location must be eligible for GPS mileage fallback") } if point.Protocol != envelope.ProtocolYutongMQTT || point.SourceKey != "YUTONG_MQTT:LMRKH9AC9R1004099@PLATFORM:yutong" { t.Fatalf("point = %+v", point) } env.Fields["yutong_mqtt.data.meter_speed"] = 0 if _, ok := GPSMileagePointFromEnvelope(env, identity, loc); ok { t.Fatal("stationary Yutong jitter must not enter GPS mileage fallback") } } func TestWriterThrottlesHighFrequencyGPSMileageStateWrites(t *testing.T) { writer := NewWriter(&recordingExec{}, time.FixedZone("Asia/Shanghai", 8*3600)) writer.SetGPSAccumulationInterval(10 * time.Second) start := time.Date(2026, 7, 19, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)) point := GPSMileagePoint{ VIN: "LMRKH9AC9R1004099", StatDate: "2026-07-19", Protocol: envelope.ProtocolYutongMQTT, SourceKey: "YUTONG_MQTT:LMRKH9AC9R1004099@PLATFORM:yutong", EventTime: start, } if !writer.shouldAccumulateGPS(point) { t.Fatal("first point must be accepted") } writer.markGPSAccumulated(point) point.EventTime = start.Add(5 * time.Second) if writer.shouldAccumulateGPS(point) { t.Fatal("high-frequency point inside the 10s interval must be throttled") } point.EventTime = start.Add(11 * time.Second) if !writer.shouldAccumulateGPS(point) { t.Fatal("point after the 10s interval must be accepted") } if got := writer.CacheStats().GPSAccumulationEntries; got != 1 { t.Fatalf("GPS cache entries = %d, want 1", got) } } func TestGPSMileageCandidateEligibleRejectsStationaryDrift(t *testing.T) { if GPSMileageCandidateEligible(0.099999, 10) { t.Fatal("sub-100-metre GPS drift must not become a daily-mileage candidate") } if GPSMileageCandidateEligible(0.2, 0) { t.Fatal("distance without a usable segment must not become a candidate") } if !GPSMileageCandidateEligible(0.1, 1) { t.Fatal("100 metres with a usable segment should become eligible") } } func TestAccumulateJT808GPSMileagePersistsEstimateAndProjects(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() loc := time.FixedZone("Asia/Shanghai", 8*3600) firstTime := time.Date(2026, 7, 19, 8, 0, 0, 0, loc) point := GPSMileagePoint{ VIN: "LMRKH9AC7R1004120", StatDate: "2026-07-19", Protocol: envelope.ProtocolJT808, SourceKey: "JT808:64341233712@PLATFORM:dongfang_beidou", SourceIP: "122.152.221.156", SourceEndpoint: "122.152.221.156:33805", Phone: "64341233712", EventID: "evt-2", EventTime: firstTime.Add(5 * time.Minute), Longitude: 121.4837, Latitude: 31.2304, } mock.ExpectBegin() mock.ExpectQuery("SELECT first_event_time, latest_event_time"). WithArgs(point.VIN, point.StatDate, string(point.Protocol), point.SourceKey). WillReturnRows(sqlmock.NewRows([]string{ "first_event_time", "latest_event_time", "latest_event_id", "latest_longitude", "latest_latitude", "daily_mileage_km", "point_count", "usable_segment_count", "bad_jump_count", "long_gap_count", "out_of_order_count", }).AddRow(firstTime, firstTime, "evt-1", 121.4737, 31.2304, 0, 1, 0, 0, 0, 0)) mock.ExpectExec("UPDATE vehicle_daily_gps_mileage_state"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectExec("INSERT INTO vehicle_daily_mileage_source"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectExec("INSERT INTO vehicle_daily_mileage"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectExec("UPDATE vehicle_daily_mileage_source"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectExec("DELETE FROM vehicle_daily_mileage"). WillReturnResult(sqlmock.NewResult(0, 0)) mock.ExpectCommit() state, recovered, err := AccumulateJT808GPSMileage(context.Background(), db, point) if err != nil { t.Fatalf("AccumulateJT808GPSMileage() error = %v", err) } if !recovered || state.UsableSegmentCount != 1 || math.Abs(state.DailyMileageKM-0.9529) > 0.02 { t.Fatalf("state = %+v recovered=%v", state, recovered) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("unmet SQL expectations: %v", err) } } func TestAccumulateJT808GPSMileageNeedsUsableSegmentBeforeProjection(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() point := GPSMileagePoint{ VIN: "LMRKH9AC7R1004120", StatDate: "2026-07-19", Protocol: envelope.ProtocolJT808, SourceKey: "JT808:64341233712@PLATFORM:dongfang_beidou", SourceIP: "122.152.221.156", EventID: "evt-1", EventTime: time.Date(2026, 7, 19, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)), Longitude: 121.4737, Latitude: 31.2304, } mock.ExpectBegin() mock.ExpectQuery("SELECT first_event_time, latest_event_time"). WillReturnRows(sqlmock.NewRows([]string{ "first_event_time", "latest_event_time", "latest_event_id", "latest_longitude", "latest_latitude", "daily_mileage_km", "point_count", "usable_segment_count", "bad_jump_count", "long_gap_count", "out_of_order_count", })) mock.ExpectQuery("SELECT daily_mileage_km, first_event_time, sample_count"). WillReturnRows(sqlmock.NewRows([]string{"daily_mileage_km", "first_event_time", "sample_count"})) mock.ExpectExec("INSERT INTO vehicle_daily_gps_mileage_state"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() _, recovered, err := AccumulateJT808GPSMileage(context.Background(), db, point) if err != nil { t.Fatalf("AccumulateJT808GPSMileage() error = %v", err) } if recovered { t.Fatal("first point must not project an estimated mileage") } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("unmet SQL expectations: %v", err) } } func TestAccumulateGPSMileageKeepsSubThresholdDistanceAsStateOnly(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() start := time.Date(2026, 7, 20, 0, 14, 56, 0, time.FixedZone("Asia/Shanghai", 8*3600)) point := GPSMileagePoint{ VIN: "LMRKH9ACXR1004130", StatDate: "2026-07-20", Protocol: envelope.ProtocolYutongMQTT, SourceKey: "YUTONG_MQTT:LMRKH9ACXR1004130@PLATFORM:yutong", SourceIP: "mqtt", EventID: "evt-small-movement", EventTime: start.Add(time.Second), Longitude: 114.435573, Latitude: 30.645503, } mock.ExpectBegin() mock.ExpectQuery("SELECT first_event_time, latest_event_time"). WillReturnRows(sqlmock.NewRows([]string{ "first_event_time", "latest_event_time", "latest_event_id", "latest_longitude", "latest_latitude", "daily_mileage_km", "point_count", "usable_segment_count", "bad_jump_count", "long_gap_count", "out_of_order_count", }).AddRow(start, start, "evt-before", 114.435572, 30.645503, 0, 1, 0, 0, 0, 0)) mock.ExpectExec("UPDATE vehicle_daily_gps_mileage_state"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() state, recovered, err := AccumulateGPSMileage(context.Background(), db, point) if err != nil { t.Fatalf("AccumulateGPSMileage() error = %v", err) } if recovered || state.DailyMileageKM <= 0 || state.DailyMileageKM >= gpsMinimumDailyDistanceKM { t.Fatalf("sub-threshold state = %+v recovered=%v", state, recovered) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("unmet SQL expectations: %v", err) } }