Files
lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats/gps_mileage_test.go

253 lines
8.7 KiB
Go

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 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)
}
}