309 lines
11 KiB
Go
309 lines
11 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 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)
|
|
}
|
|
}
|