1219 lines
45 KiB
Go
1219 lines
45 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/DATA-DOG/go-sqlmock"
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats"
|
|
)
|
|
|
|
func TestChooseTrustedSourceKeepsContinuingSourceAndRejectsNewJump(t *testing.T) {
|
|
previous := []dailySourceLast{{
|
|
VIN: "LA9GG64L7PBAF4001",
|
|
SourceKey: normalizedSourceKey("JT808", "13307765812", "", "115.231.168.135:42630"),
|
|
Phone: "13307765812",
|
|
SourceEndpoint: "115.231.168.135:42630",
|
|
TotalKM: 4100.8,
|
|
}}
|
|
current := []dailySourceLast{
|
|
{
|
|
VIN: "LA9GG64L7PBAF4001",
|
|
SourceKey: normalizedSourceKey("JT808", "13307765812", "", "115.159.85.149:28316"),
|
|
Phone: "13307765812",
|
|
SourceEndpoint: "115.159.85.149:28316",
|
|
TotalKM: 42447.2,
|
|
},
|
|
{
|
|
VIN: "LA9GG64L7PBAF4001",
|
|
SourceKey: normalizedSourceKey("JT808", "13307765812", "", "115.231.168.135:20215"),
|
|
Phone: "13307765812",
|
|
SourceEndpoint: "115.231.168.135:20215",
|
|
TotalKM: 4123.9,
|
|
},
|
|
}
|
|
|
|
chosen, ok := chooseTrustedSource(current, previous)
|
|
if !ok {
|
|
t.Fatal("chooseTrustedSource() did not choose a source")
|
|
}
|
|
if chosen.current.SourceKey != normalizedSourceKey("JT808", "13307765812", "", "115.231.168.135:20215") {
|
|
t.Fatalf("chosen source = %q", chosen.current.SourceKey)
|
|
}
|
|
if delta := chosen.current.TotalKM - chosen.previous.TotalKM; delta < 23 || delta > 24 {
|
|
t.Fatalf("delta = %v, want about 23.1", delta)
|
|
}
|
|
}
|
|
|
|
func TestCleanupSubthresholdGPSMileageCandidatesDryRunReportsWithoutDeleting(t *testing.T) {
|
|
db, mock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatalf("sqlmock.New() error = %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
mock.ExpectQuery("SELECT vin, DATE_FORMAT").
|
|
WithArgs("2026-07-20", "2026-07-20", stats.QualityReasonGPSCoordinate, stats.GPSMinimumDailyDistanceKM, "JT808").
|
|
WillReturnRows(sqlmock.NewRows([]string{"vin", "stat_date", "protocol", "row_count"}).
|
|
AddRow("LKLG7C4E9NA774739", "2026-07-20", "JT808", 1))
|
|
|
|
found, deleted, err := cleanupSubthresholdGPSMileageCandidates(context.Background(), db, config{
|
|
DateFrom: "2026-07-20",
|
|
DateTo: "2026-07-20",
|
|
Protocols: []envelope.Protocol{envelope.ProtocolJT808},
|
|
}, false)
|
|
if err != nil {
|
|
t.Fatalf("cleanupSubthresholdGPSMileageCandidates() error = %v", err)
|
|
}
|
|
if found != 1 || deleted != 0 {
|
|
t.Fatalf("cleanup result found=%d deleted=%d", found, deleted)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("unmet SQL expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCleanupSubthresholdGPSMileageCandidatesDeletesAndReprojectsAtomically(t *testing.T) {
|
|
db, mock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatalf("sqlmock.New() error = %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
mock.ExpectQuery("SELECT vin, DATE_FORMAT").
|
|
WithArgs("2026-07-20", "2026-07-20", stats.QualityReasonGPSCoordinate, stats.GPSMinimumDailyDistanceKM, "JT808").
|
|
WillReturnRows(sqlmock.NewRows([]string{"vin", "stat_date", "protocol", "row_count"}).
|
|
AddRow("LKLG7C4E9NA774739", "2026-07-20", "JT808", 1))
|
|
mock.ExpectBegin()
|
|
mock.ExpectExec("DELETE FROM vehicle_daily_mileage_source").
|
|
WithArgs("LKLG7C4E9NA774739", "2026-07-20", "JT808", stats.QualityReasonGPSCoordinate, stats.GPSMinimumDailyDistanceKM).
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
mock.ExpectExec("INSERT INTO vehicle_daily_mileage").
|
|
WillReturnResult(sqlmock.NewResult(0, 0))
|
|
mock.ExpectExec("UPDATE vehicle_daily_mileage_source").
|
|
WillReturnResult(sqlmock.NewResult(0, 0))
|
|
mock.ExpectExec("DELETE FROM vehicle_daily_mileage").
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
mock.ExpectCommit()
|
|
|
|
found, deleted, err := cleanupSubthresholdGPSMileageCandidates(context.Background(), db, config{
|
|
DateFrom: "2026-07-20",
|
|
DateTo: "2026-07-20",
|
|
Protocols: []envelope.Protocol{envelope.ProtocolJT808},
|
|
}, true)
|
|
if err != nil {
|
|
t.Fatalf("cleanupSubthresholdGPSMileageCandidates() error = %v", err)
|
|
}
|
|
if found != 1 || deleted != 1 {
|
|
t.Fatalf("cleanup result found=%d deleted=%d", found, deleted)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("unmet SQL expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestChooseTrustedSourceAcceptsSmallNegativeMileageJitter(t *testing.T) {
|
|
previous := []dailySourceLast{{
|
|
VIN: "LB9A32A28R0LS1574",
|
|
SourceKey: normalizedSourceKey("JT808", "64115156034", "", "115.159.85.149:53330"),
|
|
Phone: "64115156034",
|
|
SourceEndpoint: "115.159.85.149:53330",
|
|
TotalKM: 15355.4,
|
|
}}
|
|
current := []dailySourceLast{{
|
|
VIN: "LB9A32A28R0LS1574",
|
|
SourceKey: normalizedSourceKey("JT808", "64115156034", "", "115.159.85.149:53338"),
|
|
Phone: "64115156034",
|
|
SourceEndpoint: "115.159.85.149:53338",
|
|
TotalKM: 15355.3,
|
|
}}
|
|
|
|
chosen, ok := chooseTrustedSource(current, previous)
|
|
if !ok {
|
|
t.Fatal("chooseTrustedSource() should accept tiny negative mileage jitter")
|
|
}
|
|
if chosen.current.SourceKey != current[0].SourceKey {
|
|
t.Fatalf("chosen source = %q", chosen.current.SourceKey)
|
|
}
|
|
}
|
|
|
|
func TestChooseTrustedSourceAcceptsPlausibleMultiDayFallbackDelta(t *testing.T) {
|
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
|
sourceKey := normalizedSourceKey("GB32960", "", "", "8.134.95.166:37720")
|
|
previous := []dailySourceLast{{
|
|
VIN: "LNXNEGRR1SR319498",
|
|
SourceKey: sourceKey,
|
|
SourceEndpoint: "8.134.95.166:37720",
|
|
TS: time.Date(2026, 7, 4, 11, 7, 58, 0, loc),
|
|
TotalKM: 8832.1,
|
|
}}
|
|
current := []dailySourceLast{{
|
|
VIN: "LNXNEGRR1SR319498",
|
|
SourceKey: sourceKey,
|
|
SourceEndpoint: "8.134.95.166:37720",
|
|
TS: time.Date(2026, 7, 12, 2, 52, 47, 0, loc),
|
|
TotalKM: 16665.6,
|
|
}}
|
|
|
|
chosen, ok := chooseTrustedSource(current, previous)
|
|
if !ok {
|
|
t.Fatal("chooseTrustedSource() should accept delta within the historical baseline window")
|
|
}
|
|
if delta := chosen.current.TotalKM - chosen.previous.TotalKM; delta < 7833.4 || delta > 7833.6 {
|
|
t.Fatalf("delta = %v, want historical gap delta", delta)
|
|
}
|
|
}
|
|
|
|
func TestDailySourceLastBuildsCandidateKeysBySourceIP(t *testing.T) {
|
|
sourceA := dailySourceLast{
|
|
VIN: "LA9GG64L7PBAF4001",
|
|
SourceKey: normalizedSourceKey("JT808", "13307765812", "", "115.231.168.135:20215"),
|
|
Phone: "13307765812",
|
|
SourceEndpoint: "115.231.168.135:20215",
|
|
TotalKM: 4123.9,
|
|
}
|
|
sourceB := dailySourceLast{
|
|
VIN: "LA9GG64L7PBAF4001",
|
|
SourceKey: normalizedSourceKey("JT808", "13307765812", "", "115.231.168.135:42630"),
|
|
Phone: "13307765812",
|
|
SourceEndpoint: "115.231.168.135:42630",
|
|
TotalKM: 4100.8,
|
|
}
|
|
if sourceA.SourceKey != sourceB.SourceKey {
|
|
t.Fatalf("same source IP should produce same source key: %q vs %q", sourceA.SourceKey, sourceB.SourceKey)
|
|
}
|
|
}
|
|
|
|
func TestAggregateFromDailySourceUsesOlderHistoricalBaseline(t *testing.T) {
|
|
current := dailySourceLast{
|
|
VIN: "LMRKH9AC2R1004087",
|
|
SourceKey: normalizedSourceKey("YUTONG_MQTT", "", "LMRKH9AC2R1004087", "mqtt://yutong/ytforward/shln/3"),
|
|
DeviceID: "LMRKH9AC2R1004087",
|
|
SourceEndpoint: "mqtt://yutong/ytforward/shln/3",
|
|
FirstTS: time.Date(2026, 7, 8, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
|
|
TS: time.Date(2026, 7, 8, 17, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
|
|
FirstTotalKM: 120778,
|
|
TotalKM: 120788,
|
|
RawSampleCount: 15,
|
|
}
|
|
previous := dailySourceLast{
|
|
VIN: "LMRKH9AC2R1004087",
|
|
SourceKey: current.SourceKey,
|
|
DeviceID: "LMRKH9AC2R1004087",
|
|
SourceEndpoint: "mqtt://yutong/ytforward/shln/3",
|
|
TS: time.Date(2026, 7, 4, 23, 58, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
|
|
TotalKM: 120672,
|
|
}
|
|
|
|
agg := aggregateFromDailySource("2026-07-08", envelope.ProtocolYutongMQTT, current, previous, true)
|
|
|
|
if agg.FirstKM != 120672 || agg.LatestKM != 120788 {
|
|
t.Fatalf("km range = %v -> %v", agg.FirstKM, agg.LatestKM)
|
|
}
|
|
if agg.FirstEventTime != previous.TS || agg.LatestEventTime != current.TS {
|
|
t.Fatalf("event range = %v -> %v", agg.FirstEventTime, agg.LatestEventTime)
|
|
}
|
|
if agg.QualityStatus != stats.QualityOK || agg.QualityReason != stats.QualityReasonHistorical {
|
|
t.Fatalf("quality = %s/%s", agg.QualityStatus, agg.QualityReason)
|
|
}
|
|
if agg.Count != 15 {
|
|
t.Fatalf("sample count = %d, want 15", agg.Count)
|
|
}
|
|
}
|
|
|
|
func TestAggregateFromDailySourceRejectsHistoricalBaselineJump(t *testing.T) {
|
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
|
current := dailySourceLast{
|
|
VIN: "LNXNEGRR6SR319464",
|
|
SourceKey: normalizedSourceKey("GB32960", "", "", "8.134.95.166:49206"),
|
|
SourceEndpoint: "8.134.95.166:49206",
|
|
FirstTS: time.Date(2026, 7, 12, 8, 5, 42, 0, loc),
|
|
TS: time.Date(2026, 7, 12, 10, 44, 56, 0, loc),
|
|
FirstTotalKM: 28004.2,
|
|
TotalKM: 40009.7,
|
|
RawSampleCount: 1938,
|
|
}
|
|
previous := dailySourceLast{
|
|
VIN: current.VIN,
|
|
SourceKey: current.SourceKey,
|
|
SourceEndpoint: current.SourceEndpoint,
|
|
TS: time.Date(2026, 7, 3, 19, 0, 39, 0, loc),
|
|
TotalKM: 10009.7,
|
|
}
|
|
|
|
agg := aggregateFromDailySource("2026-07-12", envelope.ProtocolGB32960, current, previous, true)
|
|
|
|
if agg.FirstKM != previous.TotalKM || agg.LatestKM != current.TotalKM {
|
|
t.Fatalf("km range = %v -> %v", agg.FirstKM, agg.LatestKM)
|
|
}
|
|
if !agg.FirstEventTime.Equal(previous.TS) || !agg.LatestEventTime.Equal(current.TS) {
|
|
t.Fatalf("event range = %v -> %v", agg.FirstEventTime, agg.LatestEventTime)
|
|
}
|
|
if agg.QualityStatus != stats.QualityInvalidDelta || agg.QualityReason != "outside_daily_range" {
|
|
t.Fatalf("quality = %s/%s", agg.QualityStatus, agg.QualityReason)
|
|
}
|
|
}
|
|
|
|
func TestAggregateFromDailySourceUsesCurrentDayFirstWhenHistoryIsEmpty(t *testing.T) {
|
|
current := dailySourceLast{
|
|
VIN: "LMRKH9AC2R1004087",
|
|
SourceKey: normalizedSourceKey("YUTONG_MQTT", "", "LMRKH9AC2R1004087", "mqtt://yutong/ytforward/shln/3"),
|
|
DeviceID: "LMRKH9AC2R1004087",
|
|
SourceEndpoint: "mqtt://yutong/ytforward/shln/3",
|
|
FirstTS: time.Date(2026, 7, 8, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
|
|
TS: time.Date(2026, 7, 8, 17, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
|
|
FirstTotalKM: 120778,
|
|
TotalKM: 120788,
|
|
RawSampleCount: 15,
|
|
}
|
|
|
|
agg := aggregateFromDailySource("2026-07-08", envelope.ProtocolYutongMQTT, current, dailySourceLast{}, false)
|
|
|
|
if agg.FirstKM != 120778 || agg.LatestKM != 120788 {
|
|
t.Fatalf("km range = %v -> %v", agg.FirstKM, agg.LatestKM)
|
|
}
|
|
if agg.FirstEventTime != current.FirstTS || agg.LatestEventTime != current.TS {
|
|
t.Fatalf("event range = %v -> %v", agg.FirstEventTime, agg.LatestEventTime)
|
|
}
|
|
if agg.QualityStatus != stats.QualityOK || agg.QualityReason != stats.QualityReasonCurrentDayFirst {
|
|
t.Fatalf("quality = %s/%s", agg.QualityStatus, agg.QualityReason)
|
|
}
|
|
}
|
|
|
|
func TestBuildLastDiffAggregatesCarriesNearestHistoryAcrossEmptyDays(t *testing.T) {
|
|
tdDB, mock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatalf("sqlmock.New() error = %v", err)
|
|
}
|
|
defer tdDB.Close()
|
|
mock.MatchExpectationsInOrder(true)
|
|
|
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
|
vin := "LMRKH9AC2R1004087"
|
|
endpoint := "mqtt://yutong/ytforward/shln/3"
|
|
dayOneTS := time.Date(2026, 7, 9, 23, 50, 0, 0, loc)
|
|
dayThreeTS := time.Date(2026, 7, 11, 17, 30, 0, 0, loc)
|
|
currentRows := func() *sqlmock.Rows {
|
|
return sqlmock.NewRows([]string{
|
|
"vin", "phone", "device_id", "source_endpoint", "FIRST(event_time)", "FIRST(parsed_json)", "FIRST(raw_text)", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)",
|
|
})
|
|
}
|
|
previousRows := func() *sqlmock.Rows {
|
|
return sqlmock.NewRows([]string{
|
|
"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)",
|
|
})
|
|
}
|
|
|
|
mock.ExpectQuery("(?s)SELECT vin.*LAST\\(event_time\\).*event_time < '2026-07-09 00:00:00'.*protocol = 'YUTONG_MQTT'").
|
|
WillReturnRows(previousRows())
|
|
mock.ExpectQuery("(?s)SELECT vin.*FIRST\\(event_time\\).*event_time >= '2026-07-09 00:00:00'.*protocol = 'YUTONG_MQTT'").
|
|
WillReturnRows(currentRows().AddRow(vin, "", vin, endpoint, dayOneTS, `{"data":{"TOTAL_MILEAGE":100000}}`, "", dayOneTS, `{"data":{"TOTAL_MILEAGE":100000}}`, "", int64(4)))
|
|
mock.ExpectQuery("(?s)SELECT vin.*FIRST\\(event_time\\).*event_time >= '2026-07-10 00:00:00'.*protocol = 'YUTONG_MQTT'").
|
|
WillReturnRows(currentRows())
|
|
mock.ExpectQuery("(?s)SELECT vin.*FIRST\\(event_time\\).*event_time >= '2026-07-11 00:00:00'.*protocol = 'YUTONG_MQTT'").
|
|
WillReturnRows(currentRows().AddRow(vin, "", vin, endpoint, dayThreeTS, `{"data":{"TOTAL_MILEAGE":120000}}`, "", dayThreeTS, `{"data":{"TOTAL_MILEAGE":120000}}`, "", int64(6)))
|
|
|
|
aggregates, err := buildLastDiffAggregates(context.Background(), nil, tdDB, config{
|
|
TDengineDatabase: "lingniu_vehicle_ts",
|
|
DateFrom: "2026-07-09",
|
|
DateTo: "2026-07-11",
|
|
Protocols: []envelope.Protocol{envelope.ProtocolYutongMQTT},
|
|
Location: loc,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("buildLastDiffAggregates() error = %v", err)
|
|
}
|
|
sourceKey := stats.SourceKeyForSource(envelope.ProtocolYutongMQTT, "", vin, "mqtt", "PLATFORM", "yutong")
|
|
agg := aggregates[vin+"|2026-07-11|YUTONG_MQTT|"+sourceKey]
|
|
if agg == nil {
|
|
t.Fatalf("missing day-three aggregate; keys=%v", aggregateKeys(aggregates))
|
|
}
|
|
if agg.FirstKM != 100 || agg.LatestKM != 120 {
|
|
t.Fatalf("km range = %v -> %v, want 100 -> 120", agg.FirstKM, agg.LatestKM)
|
|
}
|
|
if !agg.FirstEventTime.Equal(dayOneTS) || agg.QualityReason != stats.QualityReasonHistorical {
|
|
t.Fatalf("baseline = %v reason=%q, want day-one historical baseline", agg.FirstEventTime, agg.QualityReason)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("sql expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
func aggregateKeys(aggregates map[string]*metricAgg) []string {
|
|
keys := make([]string, 0, len(aggregates))
|
|
for key := range aggregates {
|
|
keys = append(keys, key)
|
|
}
|
|
return keys
|
|
}
|
|
|
|
func TestQueryRealtimeLocationLastRowsBuildsYutongSourceFromPeer(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)
|
|
eventTime := time.Date(2026, 7, 12, 5, 54, 50, 0, loc)
|
|
mock.ExpectQuery("(?s)FROM vehicle_realtime_location l.*LEFT JOIN vehicle_realtime_snapshot s").
|
|
WithArgs("YUTONG_MQTT", "2026-07-12", "2026-07-12").
|
|
WillReturnRows(sqlmock.NewRows([]string{"vin", "peer", "total_mileage_event_time", "total_mileage_km"}).
|
|
AddRow("LMRKH9AC0R1004086", "mqtt://yutong/ytforward/shln/4", eventTime, 11578.0))
|
|
|
|
rows, err := queryRealtimeLocationLastRows(context.Background(), db, config{Location: loc}, envelope.ProtocolYutongMQTT, "2026-07-12")
|
|
if err != nil {
|
|
t.Fatalf("queryRealtimeLocationLastRows() error = %v", err)
|
|
}
|
|
sourceRows := rows["LMRKH9AC0R1004086"]
|
|
if len(sourceRows) != 1 {
|
|
t.Fatalf("rows = %d, want 1", len(sourceRows))
|
|
}
|
|
row := sourceRows[0]
|
|
wantSourceKey := stats.SourceKeyForSource(envelope.ProtocolYutongMQTT, "", "LMRKH9AC0R1004086", "mqtt", "PLATFORM", "yutong")
|
|
if row.SourceKey != wantSourceKey {
|
|
t.Fatalf("source key = %q", row.SourceKey)
|
|
}
|
|
if row.SourceCode != "yutong" || row.PlatformName != "宇通" || row.SourceKind != "PLATFORM" {
|
|
t.Fatalf("source metadata = code:%q platform:%q kind:%q", row.SourceCode, row.PlatformName, row.SourceKind)
|
|
}
|
|
if row.TotalKM != 11578 || row.DeviceID != "LMRKH9AC0R1004086" || row.RawSampleCount != 1 {
|
|
t.Fatalf("unexpected realtime fallback row: %#v", row)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("sql expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestQueryRealtimeLocationLastRowsCanCarryStationaryYutongMileage(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)
|
|
currentEventTime := time.Date(2026, 7, 19, 6, 5, 42, 0, loc)
|
|
mock.ExpectQuery("(?s)SELECT.*l\\.event_time.*l\\.total_mileage_event_time < \\?.*COALESCE\\(l\\.speed_kmh, 0\\) = 0").
|
|
WithArgs("YUTONG_MQTT", "2026-07-19", "2026-07-19", "2026-07-19").
|
|
WillReturnRows(sqlmock.NewRows([]string{"vin", "peer", "event_time", "total_mileage_km"}).
|
|
AddRow("LMRKH9AC9R1004099", "mqtt://yutong/ytforward/shln/2", currentEventTime, 24245.0))
|
|
|
|
rows, err := queryRealtimeLocationLastRows(context.Background(), db, config{
|
|
Location: loc,
|
|
StationaryCarry: true,
|
|
}, envelope.ProtocolYutongMQTT, "2026-07-19")
|
|
if err != nil {
|
|
t.Fatalf("queryRealtimeLocationLastRows() error = %v", err)
|
|
}
|
|
sourceRows := rows["LMRKH9AC9R1004099"]
|
|
if len(sourceRows) != 1 {
|
|
t.Fatalf("rows = %d, want 1", len(sourceRows))
|
|
}
|
|
if !sourceRows[0].TS.Equal(currentEventTime) || sourceRows[0].TotalKM != 24245 {
|
|
t.Fatalf("stationary carry row = %#v", sourceRows[0])
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("sql expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAddRealtimeLocationFallbackStationaryCarryUsesTrustedHistoricalBaseline(t *testing.T) {
|
|
mysqlDB, mysqlMock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatalf("mysql sqlmock.New() error = %v", err)
|
|
}
|
|
defer mysqlDB.Close()
|
|
tdDB, tdMock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatalf("td sqlmock.New() error = %v", err)
|
|
}
|
|
defer tdDB.Close()
|
|
|
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
|
vin := "LMRKH9AC5R1004133"
|
|
currentTS := time.Date(2026, 7, 19, 10, 20, 20, 0, loc)
|
|
previousTS := time.Date(2026, 7, 17, 8, 49, 55, 0, loc)
|
|
sourceKey := stats.SourceKeyForSource(envelope.ProtocolYutongMQTT, "", vin, "mqtt", "PLATFORM", "yutong")
|
|
mysqlMock.ExpectQuery("(?s)FROM vehicle_realtime_location l.*l\\.event_time.*l\\.total_mileage_event_time < \\?").
|
|
WithArgs("YUTONG_MQTT", "2026-07-19", "2026-07-19", "2026-07-19").
|
|
WillReturnRows(sqlmock.NewRows([]string{"vin", "peer", "event_time", "total_mileage_km"}).
|
|
AddRow(vin, "mqtt://yutong/ytforward/shln/4", currentTS, 55451.0))
|
|
mysqlMock.ExpectQuery("(?s)SELECT latest_total_mileage_km, latest_event_time.*FROM vehicle_daily_mileage_source").
|
|
WithArgs(vin, "2026-07-19", "YUTONG_MQTT", sourceKey).
|
|
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}).AddRow(55466.0, previousTS))
|
|
|
|
aggregates := map[string]*metricAgg{}
|
|
added, err := addRealtimeLocationFallbackAggregates(context.Background(), mysqlDB, tdDB, config{
|
|
TDengineDatabase: "lingniu_vehicle_ts",
|
|
DateFrom: "2026-07-19",
|
|
DateTo: "2026-07-19",
|
|
Protocols: []envelope.Protocol{envelope.ProtocolYutongMQTT},
|
|
Location: loc,
|
|
StationaryCarry: true,
|
|
}, aggregates)
|
|
if err != nil {
|
|
t.Fatalf("addRealtimeLocationFallbackAggregates() error = %v", err)
|
|
}
|
|
if added != 1 || len(aggregates) != 1 {
|
|
t.Fatalf("added=%d aggregates=%d", added, len(aggregates))
|
|
}
|
|
for _, agg := range aggregates {
|
|
if agg.FirstKM != 55466 || agg.LatestKM != 55466 {
|
|
t.Fatalf("stationary km range = %v -> %v", agg.FirstKM, agg.LatestKM)
|
|
}
|
|
if agg.QualityStatus != stats.QualityOK || agg.QualityReason != stats.QualityReasonStationaryCarry {
|
|
t.Fatalf("quality = %s/%s", agg.QualityStatus, agg.QualityReason)
|
|
}
|
|
}
|
|
if err := mysqlMock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("mysql sql expectations: %v", err)
|
|
}
|
|
if err := tdMock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("td sql expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAddRealtimeLocationFallbackAggregatesUsesHistoricalBaseline(t *testing.T) {
|
|
mysqlDB, mysqlMock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatalf("mysql sqlmock.New() error = %v", err)
|
|
}
|
|
defer mysqlDB.Close()
|
|
tdDB, tdMock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatalf("td sqlmock.New() error = %v", err)
|
|
}
|
|
defer tdDB.Close()
|
|
|
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
|
currentTS := time.Date(2026, 7, 12, 5, 54, 50, 0, loc)
|
|
previousTS := time.Date(2026, 7, 11, 23, 58, 0, 0, loc)
|
|
mysqlMock.ExpectQuery("(?s)FROM vehicle_realtime_location l.*l.protocol = \\?").
|
|
WithArgs("YUTONG_MQTT", "2026-07-12", "2026-07-12").
|
|
WillReturnRows(sqlmock.NewRows([]string{"vin", "peer", "total_mileage_event_time", "total_mileage_km"}).
|
|
AddRow("LMRKH9AC0R1004086", "mqtt://yutong/ytforward/shln/4", currentTS, 11578.0))
|
|
sourceKey := stats.SourceKeyForSource(envelope.ProtocolYutongMQTT, "", "LMRKH9AC0R1004086", "mqtt", "PLATFORM", "yutong")
|
|
mysqlMock.ExpectQuery("(?s)SELECT latest_total_mileage_km, latest_event_time.*FROM vehicle_daily_mileage_source").
|
|
WithArgs("LMRKH9AC0R1004086", "2026-07-12", "YUTONG_MQTT", sourceKey).
|
|
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}).AddRow(11500.0, previousTS))
|
|
|
|
aggregates := map[string]*metricAgg{}
|
|
added, err := addRealtimeLocationFallbackAggregates(context.Background(), mysqlDB, tdDB, config{
|
|
TDengineDatabase: "lingniu_vehicle_ts",
|
|
DateFrom: "2026-07-12",
|
|
DateTo: "2026-07-12",
|
|
Protocols: []envelope.Protocol{envelope.ProtocolYutongMQTT},
|
|
Location: loc,
|
|
}, aggregates)
|
|
if err != nil {
|
|
t.Fatalf("addRealtimeLocationFallbackAggregates() error = %v", err)
|
|
}
|
|
if added != 1 || len(aggregates) != 1 {
|
|
t.Fatalf("added=%d aggregates=%d", added, len(aggregates))
|
|
}
|
|
for _, agg := range aggregates {
|
|
if agg.FirstKM != 11500 || agg.LatestKM != 11578 {
|
|
t.Fatalf("km range = %v -> %v", agg.FirstKM, agg.LatestKM)
|
|
}
|
|
if agg.SourceKey != sourceKey {
|
|
t.Fatalf("source key = %q", agg.SourceKey)
|
|
}
|
|
if agg.SourceCode != "yutong" || agg.PlatformName != "宇通" || agg.SourceKind != "PLATFORM" {
|
|
t.Fatalf("source metadata = code:%q platform:%q kind:%q", agg.SourceCode, agg.PlatformName, agg.SourceKind)
|
|
}
|
|
if agg.QualityReason != "realtime_location_fallback_historical_baseline" {
|
|
t.Fatalf("quality reason = %q", agg.QualityReason)
|
|
}
|
|
}
|
|
if err := mysqlMock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("mysql sql expectations: %v", err)
|
|
}
|
|
if err := tdMock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("td sql expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAddRealtimeLocationFallbackReusesAggregateHistoryAcrossEmptyDays(t *testing.T) {
|
|
mysqlDB, mysqlMock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatalf("mysql sqlmock.New() error = %v", err)
|
|
}
|
|
defer mysqlDB.Close()
|
|
tdDB, tdMock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatalf("td sqlmock.New() error = %v", err)
|
|
}
|
|
defer tdDB.Close()
|
|
|
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
|
vin := "LMRKH9AC2R1004087"
|
|
endpoint := "mqtt://yutong/ytforward/shln/3"
|
|
sourceKey := stats.SourceKeyForSource(envelope.ProtocolYutongMQTT, "", vin, "mqtt", "PLATFORM", "yutong")
|
|
dayOneTS := time.Date(2026, 7, 9, 23, 50, 0, 0, loc)
|
|
dayThreeTS := time.Date(2026, 7, 11, 17, 30, 0, 0, loc)
|
|
|
|
for _, date := range []string{"2026-07-09", "2026-07-10"} {
|
|
mysqlMock.ExpectQuery("(?s)FROM vehicle_realtime_location l.*l.protocol = \\?").
|
|
WithArgs("YUTONG_MQTT", date, date).
|
|
WillReturnRows(sqlmock.NewRows([]string{"vin", "peer", "total_mileage_event_time", "total_mileage_km"}))
|
|
}
|
|
mysqlMock.ExpectQuery("(?s)FROM vehicle_realtime_location l.*l.protocol = \\?").
|
|
WithArgs("YUTONG_MQTT", "2026-07-11", "2026-07-11").
|
|
WillReturnRows(sqlmock.NewRows([]string{"vin", "peer", "total_mileage_event_time", "total_mileage_km"}).
|
|
AddRow(vin, endpoint, dayThreeTS, 120.0))
|
|
|
|
aggregates := map[string]*metricAgg{
|
|
vin + "|2026-07-09|YUTONG_MQTT|" + sourceKey: {
|
|
VIN: vin,
|
|
Date: "2026-07-09",
|
|
Protocol: envelope.ProtocolYutongMQTT,
|
|
LatestKM: 100,
|
|
Count: 4,
|
|
SourceKey: sourceKey,
|
|
DeviceID: vin,
|
|
SourceEndpoint: endpoint,
|
|
SourceCode: "yutong",
|
|
PlatformName: "宇通",
|
|
SourceKind: "PLATFORM",
|
|
LatestEventTime: dayOneTS,
|
|
},
|
|
}
|
|
added, err := addRealtimeLocationFallbackAggregates(context.Background(), mysqlDB, tdDB, config{
|
|
TDengineDatabase: "lingniu_vehicle_ts",
|
|
DateFrom: "2026-07-09",
|
|
DateTo: "2026-07-11",
|
|
Protocols: []envelope.Protocol{envelope.ProtocolYutongMQTT},
|
|
Location: loc,
|
|
}, aggregates)
|
|
if err != nil {
|
|
t.Fatalf("addRealtimeLocationFallbackAggregates() error = %v", err)
|
|
}
|
|
if added != 1 || len(aggregates) != 2 {
|
|
t.Fatalf("added=%d aggregates=%d, want 1 and 2", added, len(aggregates))
|
|
}
|
|
agg := aggregates[vin+"|2026-07-11|YUTONG_MQTT|"+sourceKey]
|
|
if agg == nil {
|
|
t.Fatal("missing realtime-location fallback aggregate")
|
|
}
|
|
if agg.FirstKM != 100 || agg.LatestKM != 120 || !agg.FirstEventTime.Equal(dayOneTS) {
|
|
t.Fatalf("fallback range = %v@%v -> %v, want 100@day-one -> 120", agg.FirstKM, agg.FirstEventTime, agg.LatestKM)
|
|
}
|
|
if agg.QualityReason != "realtime_location_fallback_historical_baseline" {
|
|
t.Fatalf("quality reason = %q", agg.QualityReason)
|
|
}
|
|
if err := mysqlMock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("mysql sql expectations: %v", err)
|
|
}
|
|
if err := tdMock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("td sql expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestQueryDailyLastSourceRowsFiltersYutongToMileageFrames(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)
|
|
firstTS := time.Date(2026, 7, 8, 8, 0, 0, 0, loc)
|
|
lastTS := time.Date(2026, 7, 8, 18, 0, 0, 0, loc)
|
|
mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*protocol = 'YUTONG_MQTT'.*parsed_json LIKE '%yutong_mqtt\\.data\\.total_mileage%'.*parsed_json LIKE '%TOTAL_MILEAGE%'").
|
|
WillReturnRows(sqlmock.NewRows([]string{
|
|
"vin", "phone", "device_id", "source_endpoint", "FIRST(event_time)", "FIRST(parsed_json)", "FIRST(raw_text)", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)",
|
|
}).AddRow(
|
|
"LMRKH9AC6R1004108",
|
|
"",
|
|
"LMRKH9AC6R1004108",
|
|
"mqtt://yutong/ytforward/shln/3",
|
|
firstTS,
|
|
`{"yutong_mqtt.data.latitude":"30.0"}`,
|
|
`{"data":{"TOTAL_MILEAGE":65422000}}`,
|
|
lastTS,
|
|
`{"yutong_mqtt.data.latitude":"30.1"}`,
|
|
`{"data":{"TOTAL_MILEAGE":65423000}}`,
|
|
int64(42),
|
|
))
|
|
|
|
rows, err := queryDailyLastSourceRows(context.Background(), db, config{
|
|
TDengineDatabase: "lingniu_vehicle_ts",
|
|
Location: loc,
|
|
}, envelope.ProtocolYutongMQTT, "2026-07-08")
|
|
if err != nil {
|
|
t.Fatalf("queryDailyLastSourceRows() error = %v", err)
|
|
}
|
|
sourceRows := rows["LMRKH9AC6R1004108"]
|
|
if len(sourceRows) != 1 {
|
|
t.Fatalf("rows = %d, want 1", len(sourceRows))
|
|
}
|
|
if sourceRows[0].FirstTotalKM != 65422 || sourceRows[0].TotalKM != 65423 {
|
|
t.Fatalf("km range = %v -> %v", sourceRows[0].FirstTotalKM, sourceRows[0].TotalKM)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("sql expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestQueryPreviousLastSourceRowsFiltersYutongToMileageFrames(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)
|
|
lastTS := time.Date(2026, 7, 7, 23, 58, 0, 0, loc)
|
|
mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*event_time < '2026-07-08 00:00:00'.*protocol = 'YUTONG_MQTT'.*parsed_json LIKE '%yutong_mqtt\\.data\\.total_mileage%'.*parsed_json LIKE '%TOTAL_MILEAGE%'").
|
|
WillReturnRows(sqlmock.NewRows([]string{
|
|
"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)",
|
|
}).AddRow(
|
|
"LMRKH9AC6R1004108",
|
|
"",
|
|
"LMRKH9AC6R1004108",
|
|
"mqtt://yutong/ytforward/shln/3",
|
|
lastTS,
|
|
`{"yutong_mqtt.data.latitude":"30.0"}`,
|
|
`{"data":{"TOTAL_MILEAGE":65377000}}`,
|
|
int64(31),
|
|
))
|
|
|
|
rows, err := queryPreviousLastSourceRows(context.Background(), db, config{
|
|
TDengineDatabase: "lingniu_vehicle_ts",
|
|
Location: loc,
|
|
}, envelope.ProtocolYutongMQTT, "2026-07-08")
|
|
if err != nil {
|
|
t.Fatalf("queryPreviousLastSourceRows() error = %v", err)
|
|
}
|
|
sourceRows := rows["LMRKH9AC6R1004108"]
|
|
if len(sourceRows) != 1 {
|
|
t.Fatalf("rows = %d, want 1", len(sourceRows))
|
|
}
|
|
if sourceRows[0].TotalKM != 65377 {
|
|
t.Fatalf("previous total = %v, want 65377", sourceRows[0].TotalKM)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("sql expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBackfillBeforePredicatesBoundsHistoryScan(t *testing.T) {
|
|
where := strings.Join(backfillBeforePredicates(config{BaselineLookback: 7}, "2026-07-08"), " AND ")
|
|
if !strings.Contains(where, "event_time < '2026-07-08 00:00:00'") {
|
|
t.Fatalf("pre-window predicate missing exclusive upper bound: %s", where)
|
|
}
|
|
for _, predicate := range []string{
|
|
"event_time >= '2026-07-01 00:00:00'",
|
|
"ts >= '2026-06-30 00:00:00'",
|
|
"ts < '2026-07-09 00:00:00'",
|
|
} {
|
|
if !strings.Contains(where, predicate) {
|
|
t.Fatalf("pre-window predicate missing %q: %s", predicate, where)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestQueryPreviousLastSourceRowsNormalizesScannedTimestampToBusinessTimezone(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)
|
|
utcInstant := time.Date(2026, 7, 12, 15, 59, 59, 0, time.UTC)
|
|
mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*protocol = 'YUTONG_MQTT'").
|
|
WillReturnRows(sqlmock.NewRows([]string{
|
|
"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)",
|
|
}).AddRow(
|
|
"LMRKH9AC7R1004098",
|
|
"",
|
|
"LMRKH9AC7R1004098",
|
|
"mqtt://yutong/ytforward/shln/4",
|
|
utcInstant,
|
|
`{"yutong_mqtt.data.total_mileage":"41249000"}`,
|
|
"",
|
|
int64(1),
|
|
))
|
|
|
|
rows, err := queryPreviousLastSourceRows(context.Background(), db, config{
|
|
TDengineDatabase: "lingniu_vehicle_ts",
|
|
Location: loc,
|
|
}, envelope.ProtocolYutongMQTT, "2026-07-13")
|
|
if err != nil {
|
|
t.Fatalf("queryPreviousLastSourceRows() error = %v", err)
|
|
}
|
|
sourceRows := rows["LMRKH9AC7R1004098"]
|
|
if len(sourceRows) != 1 {
|
|
t.Fatalf("rows = %d, want 1", len(sourceRows))
|
|
}
|
|
want := time.Date(2026, 7, 12, 23, 59, 59, 0, loc)
|
|
if !sourceRows[0].TS.Equal(want) || sourceRows[0].TS.Location().String() != loc.String() {
|
|
t.Fatalf("previous event time = %s (%s), want %s (%s)", sourceRows[0].TS, sourceRows[0].TS.Location(), want, want.Location())
|
|
}
|
|
}
|
|
|
|
func TestFieldsForStatsExtractsRawYutongTotalMileage(t *testing.T) {
|
|
fields := fieldsForStats(envelope.ProtocolYutongMQTT, "LMRKH9AC6R1004108", `{
|
|
"data": {
|
|
"TOTAL_MILEAGE": 65423000,
|
|
"METER_SPEED": 12.3
|
|
},
|
|
"root": {
|
|
"device": "LMRKH9AC6R1004108"
|
|
}
|
|
}`)
|
|
|
|
if got := fields["yutong_mqtt.data.total_mileage"]; got == nil {
|
|
t.Fatalf("fields missing raw yutong total mileage: %#v", fields)
|
|
}
|
|
|
|
env := envelope.FrameEnvelope{
|
|
Protocol: envelope.ProtocolYutongMQTT,
|
|
VIN: "LMRKH9AC6R1004108",
|
|
EventTimeMS: time.Date(2026, 7, 8, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).UnixMilli(),
|
|
ReceivedAtMS: time.Date(2026, 7, 8, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).UnixMilli(),
|
|
Fields: fields,
|
|
}
|
|
samples, err := stats.SamplesFromEnvelope(env, time.FixedZone("Asia/Shanghai", 8*3600))
|
|
if err != nil {
|
|
t.Fatalf("SamplesFromEnvelope() error = %v", err)
|
|
}
|
|
if len(samples) != 1 {
|
|
t.Fatalf("samples = %d, want 1", len(samples))
|
|
}
|
|
if samples[0].TotalMileageKM != 65423 {
|
|
t.Fatalf("total mileage km = %v, want 65423", samples[0].TotalMileageKM)
|
|
}
|
|
}
|
|
|
|
func TestClearBackfillTargetMileageClearsExactKey(t *testing.T) {
|
|
db, mock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatalf("sqlmock.New() error = %v", err)
|
|
}
|
|
defer db.Close()
|
|
mock.ExpectExec(`DELETE FROM vehicle_daily_mileage_source WHERE vin = \? AND stat_date = \? AND protocol = \?`).
|
|
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808").
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
mock.ExpectExec(`DELETE FROM vehicle_daily_mileage WHERE vin = \? AND stat_date = \? AND protocol = \?`).
|
|
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808").
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
if err := clearBackfillTargetMileage(context.Background(), db, "LA9GG64L7PBAF4001", "2026-07-08", envelope.ProtocolJT808); err != nil {
|
|
t.Fatalf("clearBackfillTargetMileage() error = %v", err)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("sql expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWriteAggregatesClearsTargetRowsBeforeStaleCandidateUpsert(t *testing.T) {
|
|
db, mock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatalf("sqlmock.New() error = %v", err)
|
|
}
|
|
defer db.Close()
|
|
mock.MatchExpectationsInOrder(true)
|
|
|
|
projNow := time.Date(2026, 7, 8, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
|
|
aggregates := map[string]*metricAgg{
|
|
"LA9GG64L7PBAF4001|2026-07-08|JT808|JT808:13307765812@115.231.168.135": {
|
|
VIN: "LA9GG64L7PBAF4001",
|
|
Date: "2026-07-08",
|
|
Protocol: envelope.ProtocolJT808,
|
|
FirstKM: 4100,
|
|
LatestKM: 4101,
|
|
Count: 1,
|
|
SourceKey: "JT808:13307765812@115.231.168.135",
|
|
Phone: "13307765812",
|
|
SourceEndpoint: "115.231.168.135:20215",
|
|
FirstEventTime: projNow,
|
|
LatestEventTime: projNow,
|
|
QualityStatus: stats.QualityInvalidDelta,
|
|
QualityReason: "outside_daily_range",
|
|
},
|
|
}
|
|
|
|
mock.ExpectExec(`DELETE FROM vehicle_daily_mileage_source WHERE vin = \? AND stat_date = \? AND protocol = \?`).
|
|
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808").
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
mock.ExpectExec(`DELETE FROM vehicle_daily_mileage WHERE vin = \? AND stat_date = \? AND protocol = \?`).
|
|
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808").
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
mock.ExpectExec(`INSERT INTO vehicle_data_source`).
|
|
WithArgs("JT808", "115.231.168.135", "115.231.168.135:20215", sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage_source`).
|
|
WithArgs(
|
|
"LA9GG64L7PBAF4001",
|
|
"2026-07-08",
|
|
"JT808",
|
|
"JT808:13307765812@115.231.168.135",
|
|
"115.231.168.135",
|
|
"115.231.168.135:20215",
|
|
"13307765812",
|
|
"",
|
|
"",
|
|
float64(4100),
|
|
float64(4101),
|
|
float64(1),
|
|
int64(1),
|
|
projNow,
|
|
projNow,
|
|
stats.QualityInvalidDelta,
|
|
"outside_daily_range",
|
|
).
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
mock.ExpectBegin()
|
|
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage`).
|
|
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", int64(2500)).
|
|
WillReturnResult(sqlmock.NewResult(1, 0))
|
|
mock.ExpectExec(`UPDATE vehicle_daily_mileage_source s`).
|
|
WithArgs(
|
|
"LA9GG64L7PBAF4001",
|
|
"2026-07-08",
|
|
"JT808",
|
|
int64(2500),
|
|
"LA9GG64L7PBAF4001",
|
|
"2026-07-08",
|
|
"JT808",
|
|
).
|
|
WillReturnResult(sqlmock.NewResult(0, 0))
|
|
mock.ExpectExec(`DELETE FROM vehicle_daily_mileage`).
|
|
WithArgs(
|
|
"LA9GG64L7PBAF4001",
|
|
"2026-07-08",
|
|
"JT808",
|
|
"LA9GG64L7PBAF4001",
|
|
"2026-07-08",
|
|
"JT808",
|
|
).
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
mock.ExpectCommit()
|
|
|
|
written, err := writeAggregates(context.Background(), db, aggregates, 500)
|
|
if err != nil {
|
|
t.Fatalf("writeAggregates() error = %v", err)
|
|
}
|
|
if written != 1 {
|
|
t.Fatalf("written = %d, want 1", written)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("sql expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWriteAggregatesSkipsBlankSourceBeforeClearingTarget(t *testing.T) {
|
|
db, mock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatalf("sqlmock.New() error = %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
written, err := writeAggregates(context.Background(), db, map[string]*metricAgg{
|
|
"LA9GG64L7PBAF4001|2026-07-08|JT808|JT808:13307765812@": {
|
|
VIN: "LA9GG64L7PBAF4001",
|
|
Date: "2026-07-08",
|
|
Protocol: envelope.ProtocolJT808,
|
|
FirstKM: 4100,
|
|
LatestKM: 4101,
|
|
Count: 1,
|
|
SourceKey: "JT808:13307765812@",
|
|
Phone: "13307765812",
|
|
SourceEndpoint: "",
|
|
FirstEventTime: time.Date(2026, 7, 8, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
|
|
LatestEventTime: time.Date(2026, 7, 8, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
|
|
QualityStatus: stats.QualityNoPreviousBaseline,
|
|
QualityReason: "missing_previous_source",
|
|
},
|
|
}, 500)
|
|
if err != nil {
|
|
t.Fatalf("writeAggregates() error = %v", err)
|
|
}
|
|
if written != 0 {
|
|
t.Fatalf("written = %d, want 0", written)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("sql expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAddSamplesUsesCurrentDayFirstSampleBaseline(t *testing.T) {
|
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
|
firstSamples, err := stats.SamplesFromEnvelope(envelope.FrameEnvelope{
|
|
Protocol: envelope.ProtocolJT808,
|
|
VIN: "LA9GG64L7PBAF4001",
|
|
Phone: "13307765812",
|
|
SourceEndpoint: "115.231.168.135:20215",
|
|
EventTimeMS: time.Date(2026, 7, 8, 12, 0, 0, 0, loc).UnixMilli(),
|
|
Fields: map[string]any{
|
|
"jt808.location.total_mileage_km": 4123.9,
|
|
},
|
|
}, loc)
|
|
if err != nil {
|
|
t.Fatalf("SamplesFromEnvelope() error = %v", err)
|
|
}
|
|
secondSamples, err := stats.SamplesFromEnvelope(envelope.FrameEnvelope{
|
|
Protocol: envelope.ProtocolJT808,
|
|
VIN: "LA9GG64L7PBAF4001",
|
|
Phone: "13307765812",
|
|
SourceEndpoint: "115.231.168.135:20215",
|
|
EventTimeMS: time.Date(2026, 7, 8, 13, 0, 0, 0, loc).UnixMilli(),
|
|
Fields: map[string]any{
|
|
"jt808.location.total_mileage_km": 4129.9,
|
|
},
|
|
}, loc)
|
|
if err != nil {
|
|
t.Fatalf("SamplesFromEnvelope() error = %v", err)
|
|
}
|
|
aggregates := map[string]*metricAgg{}
|
|
addSamples(aggregates, firstSamples)
|
|
addSamples(aggregates, secondSamples)
|
|
if len(aggregates) != 1 {
|
|
t.Fatalf("aggregate count = %d, want 1", len(aggregates))
|
|
}
|
|
for _, agg := range aggregates {
|
|
if agg.QualityStatus != stats.QualityOK {
|
|
t.Fatalf("quality = %q, want %q", agg.QualityStatus, stats.QualityOK)
|
|
}
|
|
if agg.QualityReason != "current_day_first_sample" {
|
|
t.Fatalf("quality reason = %q", agg.QualityReason)
|
|
}
|
|
if agg.FirstKM != 4123.9 || agg.LatestKM != 4129.9 {
|
|
t.Fatalf("km range = %v -> %v", agg.FirstKM, agg.LatestKM)
|
|
}
|
|
if agg.FirstEventTime != time.Date(2026, 7, 8, 12, 0, 0, 0, loc) {
|
|
t.Fatalf("first event time = %v", agg.FirstEventTime)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLoadConfigDefaultsBackfillMethodToLastDiff(t *testing.T) {
|
|
t.Setenv("BACKFILL_METHOD", "")
|
|
t.Setenv("BACKFILL_DAYS_BACK", "")
|
|
t.Setenv("BACKFILL_WINDOW_DAYS", "")
|
|
t.Setenv("BACKFILL_DATE_FROM", "2026-07-08")
|
|
t.Setenv("BACKFILL_DATE_TO", "2026-07-08")
|
|
t.Setenv("BACKFILL_PROTOCOLS", "JT808")
|
|
t.Setenv("LOCAL_TZ", "Asia/Shanghai")
|
|
|
|
cfg, err := loadConfig()
|
|
if err != nil {
|
|
t.Fatalf("loadConfig() error = %v", err)
|
|
}
|
|
if cfg.Method != "last_diff" {
|
|
t.Fatalf("method = %q, want last_diff", cfg.Method)
|
|
}
|
|
if cfg.EventTimeFullScan {
|
|
t.Fatal("event-time full scan should be opt-in")
|
|
}
|
|
}
|
|
|
|
func TestBackfillTimePredicatesUsePrimaryTimeForCoarseScanAndEventTimeForBusinessDay(t *testing.T) {
|
|
where := strings.Join(backfillTimePredicates(config{}, "2026-07-13", "2026-07-14"), " AND ")
|
|
for _, want := range []string{
|
|
"ts >= '2026-07-12 00:00:00'",
|
|
"ts < '2026-07-15 00:00:00'",
|
|
"event_time >= '2026-07-13 00:00:00'",
|
|
"event_time < '2026-07-14 00:00:00'",
|
|
} {
|
|
if !strings.Contains(where, want) {
|
|
t.Fatalf("time predicates missing %q: %s", want, where)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBackfillTimePredicatesAllowExplicitDeepEventTimeScan(t *testing.T) {
|
|
where := strings.Join(backfillTimePredicates(config{EventTimeFullScan: true}, "2026-07-13", "2026-07-14"), " AND ")
|
|
if strings.Contains(where, "ts >=") || strings.Contains(where, "ts <") {
|
|
t.Fatalf("deep event-time scan must not apply storage-time bounds: %s", where)
|
|
}
|
|
for _, want := range []string{
|
|
"event_time >= '2026-07-13 00:00:00'",
|
|
"event_time < '2026-07-14 00:00:00'",
|
|
} {
|
|
if !strings.Contains(where, want) {
|
|
t.Fatalf("deep event-time scan missing %q: %s", want, where)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolveBackfillDateRangeDefaultsToToday(t *testing.T) {
|
|
t.Setenv("BACKFILL_DATE_FROM", "")
|
|
t.Setenv("BACKFILL_DATE_TO", "")
|
|
t.Setenv("BACKFILL_DAYS_BACK", "")
|
|
t.Setenv("BACKFILL_WINDOW_DAYS", "")
|
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
|
|
|
dateFrom, dateTo := resolveBackfillDateRange(time.Date(2026, 7, 12, 10, 0, 0, 0, loc), loc)
|
|
if dateFrom != "2026-07-12" || dateTo != "2026-07-12" {
|
|
t.Fatalf("date range = %s -> %s", dateFrom, dateTo)
|
|
}
|
|
}
|
|
|
|
func TestAddGPSCoordinateFallbackAggregatesOnlyMissingPositiveOdometerVehicles(t *testing.T) {
|
|
mysqlDB, mysqlMock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatalf("mysql sqlmock.New() error = %v", err)
|
|
}
|
|
defer mysqlDB.Close()
|
|
tdDB, tdMock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatalf("tdengine sqlmock.New() error = %v", err)
|
|
}
|
|
defer tdDB.Close()
|
|
|
|
mysqlMock.ExpectQuery("SELECT DISTINCT vin, DATE_FORMAT").
|
|
WithArgs("JT808", "2026-07-19", "2026-07-19", stats.QualityOK, stats.QualityReasonGPSCoordinate).
|
|
WillReturnRows(sqlmock.NewRows([]string{"vin", "stat_date"}).
|
|
AddRow("VIN-WITH-ODOMETER", "2026-07-19"))
|
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
|
start := time.Date(2026, 7, 19, 8, 0, 0, 0, loc)
|
|
tdMock.ExpectQuery("SELECT vin, ts, longitude, latitude").
|
|
WillReturnRows(sqlmock.NewRows([]string{"vin", "ts", "longitude", "latitude"}).
|
|
AddRow("VIN-GPS-FALLBACK", start, 121.4737, 31.2304).
|
|
AddRow("VIN-WITH-ODOMETER", start, 121.4737, 31.2304).
|
|
AddRow("VIN-GPS-FALLBACK", start.Add(5*time.Minute), 121.4837, 31.2304).
|
|
AddRow("VIN-WITH-ODOMETER", start.Add(5*time.Minute), 121.4837, 31.2304))
|
|
|
|
aggregates := map[string]*metricAgg{}
|
|
added, err := addGPSCoordinateFallbackAggregates(context.Background(), mysqlDB, tdDB, config{
|
|
DateFrom: "2026-07-19",
|
|
DateTo: "2026-07-19",
|
|
Protocols: []envelope.Protocol{envelope.ProtocolJT808},
|
|
GPSFallback: true,
|
|
Location: loc,
|
|
TDengineDatabase: "vehicle_ts",
|
|
}, aggregates)
|
|
if err != nil {
|
|
t.Fatalf("addGPSCoordinateFallbackAggregates() error = %v", err)
|
|
}
|
|
if added != 1 || len(aggregates) != 1 {
|
|
t.Fatalf("added=%d aggregates=%d", added, len(aggregates))
|
|
}
|
|
for _, aggregate := range aggregates {
|
|
if aggregate.VIN != "VIN-GPS-FALLBACK" || aggregate.QualityReason != stats.QualityReasonGPSCoordinate {
|
|
t.Fatalf("aggregate = %+v", aggregate)
|
|
}
|
|
if aggregate.LatestKM < 0.9 || aggregate.LatestKM > 1.1 || aggregate.Count != 2 {
|
|
t.Fatalf("GPS aggregate distance/count = %.3f/%d", aggregate.LatestKM, aggregate.Count)
|
|
}
|
|
}
|
|
if err := mysqlMock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("mysql expectations: %v", err)
|
|
}
|
|
if err := tdMock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("tdengine expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAddGPSCoordinateFallbackAggregatesUsesMovingYutongPoints(t *testing.T) {
|
|
mysqlDB, mysqlMock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatalf("mysql sqlmock.New() error = %v", err)
|
|
}
|
|
defer mysqlDB.Close()
|
|
tdDB, tdMock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatalf("tdengine sqlmock.New() error = %v", err)
|
|
}
|
|
defer tdDB.Close()
|
|
|
|
mysqlMock.ExpectQuery("SELECT DISTINCT vin, DATE_FORMAT").
|
|
WithArgs("YUTONG_MQTT", "2026-07-19", "2026-07-19", stats.QualityOK, stats.QualityReasonGPSCoordinate).
|
|
WillReturnRows(sqlmock.NewRows([]string{"vin", "stat_date"}))
|
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
|
start := time.Date(2026, 7, 19, 8, 0, 0, 0, loc)
|
|
tdMock.ExpectQuery("protocol = 'YUTONG_MQTT'.*speed_kmh > 0").
|
|
WillReturnRows(sqlmock.NewRows([]string{"vin", "ts", "longitude", "latitude"}).
|
|
AddRow("VIN-YUTONG-GPS", start, 121.4737, 31.2304).
|
|
AddRow("VIN-YUTONG-GPS", start.Add(time.Minute), 121.4837, 31.2304))
|
|
|
|
aggregates := map[string]*metricAgg{}
|
|
added, err := addGPSCoordinateFallbackAggregates(context.Background(), mysqlDB, tdDB, config{
|
|
DateFrom: "2026-07-19",
|
|
DateTo: "2026-07-19",
|
|
Protocols: []envelope.Protocol{envelope.ProtocolYutongMQTT},
|
|
GPSFallback: true,
|
|
Location: loc,
|
|
TDengineDatabase: "vehicle_ts",
|
|
}, aggregates)
|
|
if err != nil {
|
|
t.Fatalf("addGPSCoordinateFallbackAggregates() error = %v", err)
|
|
}
|
|
if added != 1 || len(aggregates) != 1 {
|
|
t.Fatalf("added=%d aggregates=%d", added, len(aggregates))
|
|
}
|
|
for _, aggregate := range aggregates {
|
|
if aggregate.Protocol != envelope.ProtocolYutongMQTT ||
|
|
aggregate.PlatformName != "宇通 GPS轨迹估算" ||
|
|
aggregate.QualityReason != stats.QualityReasonGPSCoordinate {
|
|
t.Fatalf("aggregate = %+v", aggregate)
|
|
}
|
|
}
|
|
if err := mysqlMock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("mysql expectations: %v", err)
|
|
}
|
|
if err := tdMock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("tdengine expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestResolveBackfillDateRangeUsesRelativeWindow(t *testing.T) {
|
|
t.Setenv("BACKFILL_DATE_FROM", "")
|
|
t.Setenv("BACKFILL_DATE_TO", "")
|
|
t.Setenv("BACKFILL_DAYS_BACK", "1")
|
|
t.Setenv("BACKFILL_WINDOW_DAYS", "3")
|
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
|
|
|
dateFrom, dateTo := resolveBackfillDateRange(time.Date(2026, 7, 12, 10, 0, 0, 0, loc), loc)
|
|
if dateFrom != "2026-07-09" || dateTo != "2026-07-11" {
|
|
t.Fatalf("date range = %s -> %s", dateFrom, dateTo)
|
|
}
|
|
}
|
|
|
|
func TestResolveBackfillDateRangePrefersExplicitDates(t *testing.T) {
|
|
t.Setenv("BACKFILL_DATE_FROM", "2026-07-01")
|
|
t.Setenv("BACKFILL_DATE_TO", "2026-07-03")
|
|
t.Setenv("BACKFILL_DAYS_BACK", "1")
|
|
t.Setenv("BACKFILL_WINDOW_DAYS", "3")
|
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
|
|
|
dateFrom, dateTo := resolveBackfillDateRange(time.Date(2026, 7, 12, 10, 0, 0, 0, loc), loc)
|
|
if dateFrom != "2026-07-01" || dateTo != "2026-07-03" {
|
|
t.Fatalf("date range = %s -> %s", dateFrom, dateTo)
|
|
}
|
|
}
|
|
|
|
func TestBackfillEnvFilesPrefersExplicitList(t *testing.T) {
|
|
got := backfillEnvFiles("/tmp/a.env,/tmp/b.env", "/tmp/legacy.env", []string{"/tmp/default.env"})
|
|
if got != "/tmp/a.env,/tmp/b.env" {
|
|
t.Fatalf("env files = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestBackfillEnvFilesUsesExistingDefaults(t *testing.T) {
|
|
dir := t.TempDir()
|
|
missing := filepath.Join(dir, "missing.env")
|
|
base := filepath.Join(dir, "base.env")
|
|
stat := filepath.Join(dir, "stat-writer.env")
|
|
if err := os.WriteFile(base, []byte("A=1\n"), 0600); err != nil {
|
|
t.Fatalf("write base env: %v", err)
|
|
}
|
|
if err := os.WriteFile(stat, []byte("B=2\n"), 0600); err != nil {
|
|
t.Fatalf("write stat env: %v", err)
|
|
}
|
|
|
|
got := backfillEnvFiles("", "", []string{missing, base, stat})
|
|
want := base + "," + stat
|
|
if got != want {
|
|
t.Fatalf("env files = %q, want %q", got, want)
|
|
}
|
|
}
|