Add monitoring metrics, numeric sorting, report-time display, vehicle number labels, and map layout fixes. Include accumulated mileage, hydrogen, OneOS authentication, open platform, ingestion, deployment, and UI updates. Exclude temporary files, generated reports, and credentials. Validate frontend 632 tests and production build, API packages, vehicle-map tests, and release installer tests. Gateway suite has one pre-existing date-sensitive identity throttling test failure reproduced on the parent commit.
797 lines
32 KiB
Go
797 lines
32 KiB
Go
package stats
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"math"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/DATA-DOG/go-sqlmock"
|
|
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
|
)
|
|
|
|
func TestSourceKeyUsesProtocolDeviceAndSourceIP(t *testing.T) {
|
|
got := SourceKey(envelope.ProtocolJT808, "13307765812", "", "115.231.168.135")
|
|
if got != "JT808:13307765812@115.231.168.135" {
|
|
t.Fatalf("source key = %q", got)
|
|
}
|
|
|
|
got = SourceKey(envelope.ProtocolGB32960, "", "iccid-1", "202.98.117.132")
|
|
if got != "GB32960:iccid-1@202.98.117.132" {
|
|
t.Fatalf("source key = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestSourceKeyForDirectUsesStableDeviceIdentity(t *testing.T) {
|
|
first := SourceKeyForKind(envelope.ProtocolJT808, "13307765812", "", "115.231.168.135", "DIRECT")
|
|
second := SourceKeyForKind(envelope.ProtocolJT808, "13307765812", "", "39.144.3.22", "DIRECT")
|
|
if first != "JT808:13307765812@DIRECT" || second != first {
|
|
t.Fatalf("direct source keys = %q/%q, want stable phone key", first, second)
|
|
}
|
|
|
|
fallback := SourceKeyForKind(envelope.ProtocolJT808, "", "", "39.144.3.22", "DIRECT")
|
|
if fallback != "JT808:unknown@39.144.3.22" {
|
|
t.Fatalf("direct fallback source key = %q", fallback)
|
|
}
|
|
}
|
|
|
|
func TestSourceKeyForPlatformUsesStableSourceCode(t *testing.T) {
|
|
first := SourceKeyForSource(envelope.ProtocolJT808, "41456413943", "", "117.132.194.167", "PLATFORM", "guangan_beidou")
|
|
second := SourceKeyForSource(envelope.ProtocolJT808, "41456413943", "", "117.132.198.90", "PLATFORM", "guangan_beidou")
|
|
if first != "JT808:41456413943@PLATFORM:guangan_beidou" || second != first {
|
|
t.Fatalf("platform source keys = %q/%q, want stable source_code key", first, second)
|
|
}
|
|
|
|
withoutSourceCode := SourceKeyForSource(envelope.ProtocolJT808, "41456413943", "", "117.132.198.90", "PLATFORM", "")
|
|
if withoutSourceCode != "JT808:41456413943@117.132.198.90" {
|
|
t.Fatalf("platform fallback source key = %q", withoutSourceCode)
|
|
}
|
|
|
|
withoutDeviceIdentity := SourceKeyForSource(envelope.ProtocolJT808, "", "", "117.132.198.90", "PLATFORM", "guangan_beidou")
|
|
if withoutDeviceIdentity != "JT808:unknown@117.132.198.90" {
|
|
t.Fatalf("unknown platform source key = %q", withoutDeviceIdentity)
|
|
}
|
|
}
|
|
|
|
func TestSourceMileageSampleFromMetricUsesDirectSourceKey(t *testing.T) {
|
|
sample := MetricSample{
|
|
VIN: "LA9GG64L7PBAF4001",
|
|
StatDate: "2026-07-12",
|
|
Protocol: envelope.ProtocolJT808,
|
|
Phone: "13307765812",
|
|
TotalMileageKM: 4123.9,
|
|
EventTime: time.Date(2026, 7, 12, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
|
|
}
|
|
candidate := SourceMileageSampleFromMetric(sample, SourceIdentity{
|
|
Protocol: envelope.ProtocolJT808,
|
|
SourceIP: "115.231.168.135",
|
|
SourceEndpoint: "115.231.168.135:43625",
|
|
SourceKind: "DIRECT",
|
|
})
|
|
if candidate.SourceKey != "JT808:13307765812@DIRECT" {
|
|
t.Fatalf("source key = %q", candidate.SourceKey)
|
|
}
|
|
if candidate.SourceIP != "115.231.168.135" {
|
|
t.Fatalf("source ip = %q", candidate.SourceIP)
|
|
}
|
|
}
|
|
|
|
func TestSourceMileageSampleFromMetricUsesPlatformSourceCode(t *testing.T) {
|
|
sample := MetricSample{
|
|
VIN: "LNXNEGRR0SR321372",
|
|
StatDate: "2026-07-12",
|
|
Protocol: envelope.ProtocolJT808,
|
|
Phone: "41456413943",
|
|
TotalMileageKM: 46484.2,
|
|
EventTime: time.Date(2026, 7, 12, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
|
|
}
|
|
candidate := SourceMileageSampleFromMetric(sample, SourceIdentity{
|
|
Protocol: envelope.ProtocolJT808,
|
|
SourceIP: "117.132.194.167",
|
|
SourceEndpoint: "117.132.194.167:9806",
|
|
SourceCode: "guangan_beidou",
|
|
PlatformName: "广安北斗",
|
|
SourceKind: "PLATFORM",
|
|
})
|
|
if candidate.SourceKey != "JT808:41456413943@PLATFORM:guangan_beidou" {
|
|
t.Fatalf("source key = %q", candidate.SourceKey)
|
|
}
|
|
if candidate.PlatformName != "广安北斗" {
|
|
t.Fatalf("platform name = %q", candidate.PlatformName)
|
|
}
|
|
}
|
|
|
|
func TestUpsertSourceMileageWritesCandidateRow(t *testing.T) {
|
|
exec := &recordingExec{}
|
|
sample := SourceMileageSample{
|
|
VIN: "LA9GG64L7PBAF4001",
|
|
StatDate: "2026-07-08",
|
|
Protocol: envelope.ProtocolJT808,
|
|
SourceKey: "JT808:13307765812@115.231.168.135",
|
|
SourceIP: "115.231.168.135",
|
|
SourceEndpoint: "115.231.168.135:20215",
|
|
Phone: "13307765812",
|
|
FirstTotalKM: 4100.8,
|
|
LatestTotalKM: 4123.9,
|
|
DailyKM: 23.1,
|
|
SampleCount: 2,
|
|
FirstEventTime: time.Date(2026, 7, 8, 0, 1, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
|
|
LatestEventTime: time.Date(2026, 7, 8, 23, 59, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
|
|
QualityStatus: QualityOK,
|
|
QualityReason: "same_source_continuous",
|
|
}
|
|
if err := UpsertSourceMileage(context.Background(), exec, sample); err != nil {
|
|
t.Fatalf("UpsertSourceMileage() error = %v", err)
|
|
}
|
|
if len(exec.calls) != 1 {
|
|
t.Fatalf("exec calls = %d", len(exec.calls))
|
|
}
|
|
sql := exec.calls[0].query
|
|
for _, want := range []string{
|
|
"INSERT INTO vehicle_daily_mileage_source",
|
|
"ON DUPLICATE KEY UPDATE",
|
|
"daily_mileage_km = CASE",
|
|
"VALUES(latest_event_time) >= latest_event_time",
|
|
"VALUES(first_event_time) <= first_event_time",
|
|
"quality_status = CASE",
|
|
"THEN '" + QualityInvalidDelta + "'",
|
|
"quality_reason = CASE",
|
|
"THEN 'outside_daily_range'",
|
|
"platform_name = COALESCE(NULLIF(TRIM(VALUES(platform_name)), ''), platform_name)",
|
|
} {
|
|
if !strings.Contains(sql, want) {
|
|
t.Fatalf("candidate upsert missing %q: %s", want, sql)
|
|
}
|
|
}
|
|
if got := exec.calls[0].args[0]; got != "LA9GG64L7PBAF4001" {
|
|
t.Fatalf("first arg = %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestUpsertSourceMileageTruncatesSubsecondEventTimesAtDayBoundary(t *testing.T) {
|
|
exec := &recordingExec{}
|
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
|
previous := time.Date(2026, 7, 12, 23, 59, 59, 999_000_000, loc)
|
|
current := time.Date(2026, 7, 13, 0, 0, 1, 999_000_000, loc)
|
|
sample := SourceMileageSample{
|
|
VIN: "LMRKH9AC7R1004098",
|
|
StatDate: "2026-07-13",
|
|
Protocol: envelope.ProtocolYutongMQTT,
|
|
SourceKey: "YUTONG_MQTT:LMRKH9AC7R1004098@PLATFORM:yutong",
|
|
SourceIP: "mqtt",
|
|
FirstTotalKM: 41249,
|
|
LatestTotalKM: 41250,
|
|
DailyKM: 1,
|
|
SampleCount: 1,
|
|
FirstEventTime: previous,
|
|
LatestEventTime: current,
|
|
QualityStatus: QualityOK,
|
|
QualityReason: "historical_source_baseline",
|
|
}
|
|
if err := UpsertSourceMileage(context.Background(), exec, sample); err != nil {
|
|
t.Fatalf("UpsertSourceMileage() error = %v", err)
|
|
}
|
|
if len(exec.calls) != 1 {
|
|
t.Fatalf("exec calls = %d, want 1", len(exec.calls))
|
|
}
|
|
if got := exec.calls[0].args[15]; got != previous.Truncate(time.Second) {
|
|
t.Fatalf("first event arg = %v, want %v", got, previous.Truncate(time.Second))
|
|
}
|
|
if got := exec.calls[0].args[16]; got != current.Truncate(time.Second) {
|
|
t.Fatalf("latest event arg = %v, want %v", got, current.Truncate(time.Second))
|
|
}
|
|
}
|
|
|
|
func TestUpsertSourceMileageUsesEventTimeBoundaries(t *testing.T) {
|
|
for _, want := range []string{
|
|
"first_total_mileage_km = CASE",
|
|
"VALUES(first_event_time) >= first_event_time",
|
|
"VALUES(first_event_time) <= first_event_time",
|
|
"VALUES(first_event_time) < CAST(CONCAT(VALUES(stat_date), ' 00:00:00') AS DATETIME)",
|
|
"latest_total_mileage_km = CASE",
|
|
"VALUES(latest_event_time) >= latest_event_time",
|
|
"daily_mileage_km = CASE",
|
|
"VALUES(latest_total_mileage_km)",
|
|
"VALUES(first_total_mileage_km)",
|
|
} {
|
|
if !strings.Contains(upsertSourceMileageSQL, want) {
|
|
t.Fatalf("event-time boundary upsert SQL missing %q:\n%s", want, upsertSourceMileageSQL)
|
|
}
|
|
}
|
|
for _, forbidden := range []string{
|
|
"GREATEST(latest_total_mileage_km, VALUES(latest_total_mileage_km))",
|
|
"LEAST(first_total_mileage_km, VALUES(first_total_mileage_km))",
|
|
"current_day_fallback_after_invalid_baseline",
|
|
} {
|
|
if strings.Contains(upsertSourceMileageSQL, forbidden) {
|
|
t.Fatalf("event-time boundary upsert must not contain %q:\n%s", forbidden, upsertSourceMileageSQL)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUpsertSourceMileageAttributesDeltaToCurrentPureHydrogenMode(t *testing.T) {
|
|
for _, want := range []string{
|
|
"pure_hydrogen_mileage_km = pure_hydrogen_mileage_km + CASE",
|
|
"VALUES(latest_event_time) > latest_event_time",
|
|
"VALUES(latest_pure_hydrogen_mode_known) = 1",
|
|
"VALUES(latest_pure_hydrogen_active) = 1",
|
|
"VALUES(latest_total_mileage_km) - latest_total_mileage_km",
|
|
} {
|
|
if !strings.Contains(upsertSourceMileageSQL, want) {
|
|
t.Fatalf("pure hydrogen accumulation SQL missing %q:\n%s", want, upsertSourceMileageSQL)
|
|
}
|
|
}
|
|
pureUpdate := strings.Index(upsertSourceMileageSQL, "pure_hydrogen_mileage_km = pure_hydrogen_mileage_km + CASE")
|
|
latestTotalUpdate := strings.Index(upsertSourceMileageSQL, "latest_total_mileage_km = CASE")
|
|
if pureUpdate < 0 || latestTotalUpdate < 0 || pureUpdate > latestTotalUpdate {
|
|
t.Fatalf("pure hydrogen increment must use the previous odometer before MySQL updates latest_total_mileage_km:\n%s", upsertSourceMileageSQL)
|
|
}
|
|
}
|
|
|
|
func TestUpsertSourceMileageRechecksMergedDailyRange(t *testing.T) {
|
|
if maxSelectedDailyMileageKMSQL != "2500" || maxNegativeMileageJitterKMSQL != "1" {
|
|
t.Fatalf("SQL mileage limits drifted from Go quality constants: selected=%s jitter=%s", maxSelectedDailyMileageKMSQL, maxNegativeMileageJitterKMSQL)
|
|
}
|
|
for _, want := range []string{
|
|
"quality_status = CASE",
|
|
">= -" + maxNegativeMileageJitterKMSQL,
|
|
"DATEDIFF(DATE(",
|
|
"* " + maxSelectedDailyMileageKMSQL,
|
|
"THEN '" + QualityInvalidDelta + "'",
|
|
"quality_reason = CASE",
|
|
"THEN 'outside_daily_range'",
|
|
"THEN '" + QualityReasonCurrentDayFirst + "'",
|
|
"ELSE '" + QualityReasonHistorical + "'",
|
|
} {
|
|
if !strings.Contains(upsertSourceMileageSQL, want) {
|
|
t.Fatalf("merged range guard missing %q:\n%s", want, upsertSourceMileageSQL)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPreviousSourceBaselineUsesLatestEarlierCalendarDay(t *testing.T) {
|
|
if !strings.Contains(previousSourceBaselineSQL, "stat_date < ?") {
|
|
t.Fatalf("previous baseline should search earlier calendar days:\n%s", previousSourceBaselineSQL)
|
|
}
|
|
if !strings.Contains(previousSourceBaselineSQL, "ORDER BY stat_date DESC, latest_event_time DESC") {
|
|
t.Fatalf("previous baseline should prefer the nearest earlier sample:\n%s", previousSourceBaselineSQL)
|
|
}
|
|
if strings.Contains(previousSourceBaselineSQL, "quality_status =") {
|
|
t.Fatalf("previous day's last odometer must remain usable even when that day's delta has no baseline:\n%s", previousSourceBaselineSQL)
|
|
}
|
|
for _, want := range []string{
|
|
"latest_total_mileage_km > 0",
|
|
"latest_event_time >= CAST(CONCAT(stat_date, ' 00:00:00') AS DATETIME)",
|
|
"latest_event_time < DATE_ADD(CAST(CONCAT(stat_date, ' 00:00:00') AS DATETIME), INTERVAL 1 DAY)",
|
|
} {
|
|
if !strings.Contains(previousSourceBaselineSQL, want) {
|
|
t.Fatalf("previous baseline SQL missing %q:\n%s", want, previousSourceBaselineSQL)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDailyMileageFromDayBoundaryUsesCurrentMinusPrevious(t *testing.T) {
|
|
got := DailyMileageFromDayBoundary(14989.0, 15369.0)
|
|
if got != 380.0 {
|
|
t.Fatalf("daily mileage = %v, want current latest minus previous last = 380", got)
|
|
}
|
|
}
|
|
|
|
func TestIsUsableDailyMileageBoundaryAcceptsOlderOfflineGap(t *testing.T) {
|
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
|
for _, test := range []struct {
|
|
name string
|
|
baseline time.Time
|
|
want bool
|
|
}{
|
|
{name: "current day cache", baseline: time.Date(2026, 8, 4, 19, 1, 26, 0, loc), want: true},
|
|
{name: "previous natural day", baseline: time.Date(2026, 8, 3, 23, 59, 59, 0, loc), want: true},
|
|
{name: "older offline gap", baseline: time.Date(2026, 5, 21, 23, 13, 2, 0, loc), want: true},
|
|
{name: "future boundary", baseline: time.Date(2026, 8, 5, 0, 0, 0, 0, loc), want: false},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
if got := IsUsableDailyMileageBoundary("2026-08-04", test.baseline, loc); got != test.want {
|
|
t.Fatalf("IsUsableDailyMileageBoundary() = %v, want %v", got, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUpsertSourceMileageSkipsBlankSourceIP(t *testing.T) {
|
|
exec := &recordingExec{}
|
|
sample := SourceMileageSample{
|
|
VIN: "LA9GG64L7PBAF4001",
|
|
StatDate: "2026-07-08",
|
|
Protocol: envelope.ProtocolJT808,
|
|
SourceKey: "JT808:13307765812@",
|
|
SourceIP: " ",
|
|
Phone: "13307765812",
|
|
QualityStatus: QualityOK,
|
|
}
|
|
if err := UpsertSourceMileage(context.Background(), exec, sample); err != nil {
|
|
t.Fatalf("UpsertSourceMileage() error = %v", err)
|
|
}
|
|
if len(exec.calls) != 0 {
|
|
t.Fatalf("exec calls = %d, want 0", len(exec.calls))
|
|
}
|
|
}
|
|
|
|
func TestNormalizePlatformSourceMileageMergesLegacyIPKeys(t *testing.T) {
|
|
exec := &recordingExec{}
|
|
err := NormalizePlatformSourceMileage(context.Background(), exec, "LNXNEGRR0SR321372", "2026-07-12", envelope.ProtocolJT808)
|
|
if err != nil {
|
|
t.Fatalf("NormalizePlatformSourceMileage() error = %v", err)
|
|
}
|
|
if len(exec.calls) != 2 {
|
|
t.Fatalf("exec calls = %d, want insert merge + delete legacy", len(exec.calls))
|
|
}
|
|
insertSQL := exec.calls[0].query
|
|
for _, want := range []string{
|
|
"INSERT INTO vehicle_daily_mileage_source",
|
|
"CONCAT(s.protocol, ':', COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')), '@PLATFORM:', COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code))",
|
|
"LEFT JOIN vehicle_data_source ds",
|
|
"LEFT JOIN (",
|
|
"FROM vehicle_identifier",
|
|
"identifier_type = 'JT808_PHONE'",
|
|
"HAVING COUNT(DISTINCT TRIM(source_code)) = 1",
|
|
"ds.source_kind = 'PLATFORM'",
|
|
"MAX(TRIM(COALESCE(ds.platform_name, vi.platform_name)))",
|
|
"s.source_key <> CONCAT",
|
|
"GROUP BY s.vin, s.stat_date, s.protocol, stable_source_key",
|
|
"ON DUPLICATE KEY UPDATE",
|
|
"sample_count = sample_count + VALUES(sample_count)",
|
|
"s.quality_status IN ('" + QualityOK + "', '" + QualityNoPreviousBaseline + "')",
|
|
normalizePlatformFirstEventSQL + " < CAST(CONCAT(s.stat_date, ' 00:00:00') AS DATETIME)",
|
|
"THEN '" + QualityInvalidDelta + "'",
|
|
"ELSE '" + QualityOK + "'",
|
|
"THEN 'negative_jitter_clamped'",
|
|
"THEN '" + QualityReasonHistorical + "'",
|
|
"ELSE '" + QualityReasonCurrentDayFirst + "'",
|
|
upsertSourceMergedMissingPreviousBaselineSQL,
|
|
} {
|
|
if !strings.Contains(insertSQL, want) {
|
|
t.Fatalf("normalize insert SQL missing %q:\n%s", want, insertSQL)
|
|
}
|
|
}
|
|
deleteSQL := exec.calls[1].query
|
|
for _, want := range []string{
|
|
"DELETE s",
|
|
"FROM vehicle_daily_mileage_source s",
|
|
"LEFT JOIN vehicle_data_source ds",
|
|
"FROM vehicle_identifier",
|
|
"ds.source_kind = 'PLATFORM'",
|
|
"s.source_key <> CONCAT",
|
|
} {
|
|
if !strings.Contains(deleteSQL, want) {
|
|
t.Fatalf("normalize delete SQL missing %q:\n%s", want, deleteSQL)
|
|
}
|
|
}
|
|
for i, call := range exec.calls {
|
|
if len(call.args) != 3 || call.args[0] != "LNXNEGRR0SR321372" || call.args[1] != "2026-07-12" || call.args[2] != "JT808" {
|
|
t.Fatalf("call %d args = %#v", i, call.args)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNormalizePlatformSourceMileageExcludesIncompleteLegacyTotals(t *testing.T) {
|
|
for name, query := range map[string]string{
|
|
"insert": normalizePlatformSourceMileageInsertSQL,
|
|
"delete": normalizePlatformSourceMileageDeleteSQL,
|
|
} {
|
|
for _, predicate := range []string{
|
|
"s.first_total_mileage_km IS NOT NULL",
|
|
"s.latest_total_mileage_km IS NOT NULL",
|
|
} {
|
|
if !strings.Contains(query, predicate) {
|
|
t.Fatalf("%s query missing incomplete-total guard %q:\n%s", name, predicate, query)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNormalizePlatformSourceMileagePreservesTheNewerBoundaryPair(t *testing.T) {
|
|
// A stale IP-key candidate must not change the canonical baseline's time
|
|
// back to September while retaining October's first_total_mileage_km.
|
|
for _, expression := range []string{normalizePlatformFirstTotalSQL, normalizePlatformFirstEventSQL} {
|
|
if !strings.Contains(expression, "ORDER BY "+normalizePlatformFirstOrderSQL) {
|
|
t.Fatal("baseline total and timestamp must select the same legacy row")
|
|
}
|
|
}
|
|
if !strings.Contains(normalizePlatformFirstOrderSQL, "THEN s.first_event_time ELSE NULL END DESC") {
|
|
t.Fatal("prior-day boundaries must prefer the newest timestamp")
|
|
}
|
|
if !strings.Contains(normalizePlatformSourceMileageInsertSQL, "first_event_time = "+upsertSourceMergedFirstEventSQL) {
|
|
t.Fatal("legacy upsert must use the same boundary preference as its odometer")
|
|
}
|
|
if strings.Contains(normalizePlatformSourceMileageInsertSQL, "MIN(s.first_event_time)") {
|
|
t.Fatal("an old forwarding alias must not restore an older boundary")
|
|
}
|
|
}
|
|
|
|
func TestNormalizePlatformSourceMileageForDateNormalizesAndProjectsLegacyVINs(t *testing.T) {
|
|
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
|
|
if err != nil {
|
|
t.Fatalf("sqlmock.New() error = %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
statDate := "2026-07-12"
|
|
protocol := envelope.ProtocolJT808
|
|
rows := sqlmock.NewRows([]string{"vin"}).
|
|
AddRow("LA9HE60A0PBAF4002").
|
|
AddRow("LA9HE60A1PBAF4008")
|
|
mock.ExpectQuery(selectPlatformSourceMileageLegacyVINSQL).
|
|
WithArgs(statDate, string(protocol)).
|
|
WillReturnRows(rows)
|
|
for _, vin := range []string{"LA9HE60A0PBAF4002", "LA9HE60A1PBAF4008"} {
|
|
mock.ExpectExec(normalizePlatformSourceMileageInsertSQL).
|
|
WithArgs(vin, statDate, string(protocol)).
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
mock.ExpectExec(normalizePlatformSourceMileageDeleteSQL).
|
|
WithArgs(vin, statDate, string(protocol)).
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
expectProjectDailyMileageSQL(mock, vin, statDate, protocol)
|
|
mock.ExpectCommit()
|
|
}
|
|
|
|
normalized, err := NormalizePlatformSourceMileageForDate(context.Background(), db, statDate, protocol)
|
|
if err != nil {
|
|
t.Fatalf("NormalizePlatformSourceMileageForDate() error = %v", err)
|
|
}
|
|
if normalized != 2 {
|
|
t.Fatalf("normalized = %d, want 2", normalized)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("sql expectations were not met: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestShouldNormalizePlatformSourceMileage(t *testing.T) {
|
|
if !ShouldNormalizePlatformSourceMileage(SourceMileageSample{SourceKey: "JT808:41456413943@PLATFORM:guangan_beidou"}) {
|
|
t.Fatal("platform source key should trigger legacy key normalization")
|
|
}
|
|
for _, sourceKey := range []string{"JT808:41456413943@DIRECT", "JT808:41456413943@117.132.194.167", ""} {
|
|
if ShouldNormalizePlatformSourceMileage(SourceMileageSample{SourceKey: sourceKey}) {
|
|
t.Fatalf("source key %q should not trigger platform normalization", sourceKey)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestApplyMileageQualityRulesClampsSmallNegativeJitter(t *testing.T) {
|
|
sample := SourceMileageSample{
|
|
DailyKM: -0.1,
|
|
QualityStatus: QualityOK,
|
|
QualityReason: "historical_source_baseline",
|
|
}
|
|
ApplyMileageQualityRules(&sample)
|
|
if sample.DailyKM != 0 {
|
|
t.Fatalf("daily km = %v, want 0", sample.DailyKM)
|
|
}
|
|
if sample.QualityStatus != QualityOK || sample.QualityReason != "negative_jitter_clamped" {
|
|
t.Fatalf("quality = %s/%s", sample.QualityStatus, sample.QualityReason)
|
|
}
|
|
}
|
|
|
|
func TestApplyMileageQualityRulesRejectsLargeNegativeDelta(t *testing.T) {
|
|
sample := SourceMileageSample{
|
|
DailyKM: -55,
|
|
QualityStatus: QualityOK,
|
|
QualityReason: "historical_source_baseline",
|
|
}
|
|
ApplyMileageQualityRules(&sample)
|
|
if sample.DailyKM != -55 {
|
|
t.Fatalf("daily km = %v, want original invalid delta", sample.DailyKM)
|
|
}
|
|
if sample.QualityStatus != QualityInvalidDelta || sample.QualityReason != "outside_daily_range" {
|
|
t.Fatalf("quality = %s/%s", sample.QualityStatus, sample.QualityReason)
|
|
}
|
|
}
|
|
|
|
func TestApplyMileageQualityRulesAcceptsPlausibleMultiDayFallbackDelta(t *testing.T) {
|
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
|
sample := SourceMileageSample{
|
|
DailyKM: 7833.5,
|
|
FirstEventTime: time.Date(2026, 7, 4, 11, 7, 58, 0, loc),
|
|
LatestEventTime: time.Date(2026, 7, 12, 2, 52, 47, 0, loc),
|
|
QualityStatus: QualityOK,
|
|
QualityReason: "historical_source_baseline",
|
|
}
|
|
ApplyMileageQualityRules(&sample)
|
|
if sample.QualityStatus != QualityOK || sample.QualityReason != "historical_source_baseline" {
|
|
t.Fatalf("quality = %s/%s", sample.QualityStatus, sample.QualityReason)
|
|
}
|
|
if sample.DailyKM != 7833.5 {
|
|
t.Fatalf("daily km = %v", sample.DailyKM)
|
|
}
|
|
if got := MileageQualityWindowDays(sample.FirstEventTime, sample.LatestEventTime); got != 8 {
|
|
t.Fatalf("window days = %d, want 8", got)
|
|
}
|
|
if got := MileageQualityLimitKM(sample.FirstEventTime, sample.LatestEventTime); got != 8*maxSelectedDailyMileageKM {
|
|
t.Fatalf("quality limit = %d, want 8-day limit %d", got, 8*maxSelectedDailyMileageKM)
|
|
}
|
|
}
|
|
|
|
func TestApplyMileageQualityRulesAcceptsContinuousHighUtilizationDay(t *testing.T) {
|
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
|
sample := SourceMileageSample{
|
|
DailyKM: 1895.7,
|
|
FirstEventTime: time.Date(2026, 7, 12, 23, 59, 37, 0, loc),
|
|
LatestEventTime: time.Date(2026, 7, 13, 20, 32, 42, 0, loc),
|
|
QualityStatus: QualityOK,
|
|
QualityReason: QualityReasonHistorical,
|
|
}
|
|
ApplyMileageQualityRules(&sample)
|
|
if sample.QualityStatus != QualityOK || sample.QualityReason != QualityReasonHistorical {
|
|
t.Fatalf("quality = %s/%s", sample.QualityStatus, sample.QualityReason)
|
|
}
|
|
if got := MileageQualityLimitKM(sample.FirstEventTime, sample.LatestEventTime); got != 2500 {
|
|
t.Fatalf("quality limit = %d, want 2500", got)
|
|
}
|
|
}
|
|
|
|
func TestApplyMileageQualityRulesRejectsImplausibleSingleDayJump(t *testing.T) {
|
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
|
sample := SourceMileageSample{
|
|
DailyKM: 4000,
|
|
FirstEventTime: time.Date(2026, 7, 12, 23, 59, 37, 0, loc),
|
|
LatestEventTime: time.Date(2026, 7, 13, 20, 32, 42, 0, loc),
|
|
QualityStatus: QualityOK,
|
|
QualityReason: QualityReasonHistorical,
|
|
}
|
|
ApplyMileageQualityRules(&sample)
|
|
if sample.QualityStatus != QualityInvalidDelta || sample.QualityReason != "outside_daily_range" {
|
|
t.Fatalf("quality = %s/%s", sample.QualityStatus, sample.QualityReason)
|
|
}
|
|
}
|
|
|
|
func TestProjectDailyMileageSelectsCandidateAndMarksSource(t *testing.T) {
|
|
exec := &recordingExec{}
|
|
err := ProjectDailyMileage(context.Background(), exec, "LA9GG64L7PBAF4001", "2026-07-08", envelope.ProtocolJT808)
|
|
if err != nil {
|
|
t.Fatalf("ProjectDailyMileage() error = %v", err)
|
|
}
|
|
if len(exec.calls) != 3 {
|
|
t.Fatalf("exec calls = %d, want 3", len(exec.calls))
|
|
}
|
|
projectFinal := exec.calls[0].query
|
|
markSelected := exec.calls[1].query
|
|
cleanupFinal := exec.calls[2].query
|
|
for _, want := range []string{
|
|
"INSERT INTO vehicle_daily_mileage",
|
|
"FROM vehicle_daily_mileage_source s",
|
|
"LEFT JOIN vehicle_data_source ds",
|
|
"ds.id",
|
|
"WHEN COALESCE(s.daily_mileage_km, 0) > 0",
|
|
"AND COALESCE(s.quality_reason, '') <> '" + QualityReasonGPSCoordinate + "' THEN 0",
|
|
"AND COALESCE(s.quality_reason, '') = '" + QualityReasonGPSCoordinate + "' THEN 1",
|
|
"WHEN COALESCE(s.quality_reason, '') <> '" + QualityReasonGPSCoordinate + "' THEN 2",
|
|
"CASE COALESCE(NULLIF(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN'), CASE WHEN s.source_key LIKE '%@PLATFORM:%' THEN 'PLATFORM' WHEN s.source_key LIKE '%@DIRECT' THEN 'DIRECT' ELSE 'UNKNOWN' END)",
|
|
"WHEN 'PLATFORM' THEN 0",
|
|
"WHEN 'DIRECT' THEN 1",
|
|
"WHEN 'UNKNOWN' THEN 2",
|
|
"ds.trust_priority",
|
|
"s.quality_status = '" + QualityOK + "'",
|
|
"s.latest_total_mileage_km IS NOT NULL",
|
|
"COALESCE(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN') = 'UNKNOWN'",
|
|
"AND (ds.source_code IS NULL OR TRIM(ds.source_code) = '')",
|
|
"AND (ds.platform_name IS NULL OR TRIM(ds.platform_name) = '')",
|
|
"s.daily_mileage_km BETWEEN 0 AND",
|
|
"DATEDIFF(DATE(s.latest_event_time), DATE(s.first_event_time))",
|
|
"SUM(identity_source.sample_count)",
|
|
"identity_source.vin = s.vin",
|
|
"COALESCE(NULLIF(TRIM(identity_source.phone), ''), NULLIF(TRIM(identity_source.device_id), ''), identity_source.source_key)",
|
|
} {
|
|
if !strings.Contains(projectFinal, want) {
|
|
t.Fatalf("project query missing %q: %s", want, projectFinal)
|
|
}
|
|
}
|
|
for _, forbidden := range []string{
|
|
"trusted_source_key",
|
|
"trusted_phone",
|
|
"trusted_source_endpoint",
|
|
"first_total_mileage_km",
|
|
"ds.latest_source_endpoint",
|
|
} {
|
|
if strings.Contains(projectFinal, forbidden) {
|
|
t.Fatalf("project query should not use final-table field %q: %s", forbidden, projectFinal)
|
|
}
|
|
}
|
|
if !strings.Contains(markSelected, "UPDATE vehicle_daily_mileage_source s") ||
|
|
!strings.Contains(markSelected, "LEFT JOIN (") ||
|
|
!strings.Contains(markSelected, "SET s.is_selected = CASE WHEN selected_source.source_key IS NULL THEN 0 ELSE 1 END") {
|
|
t.Fatalf("mark query should reconcile the elected source: %s", markSelected)
|
|
}
|
|
if strings.Contains(markSelected, "JOIN vehicle_daily_mileage m") {
|
|
t.Fatalf("mark query should not rejoin final mileage for candidate selection: %s", markSelected)
|
|
}
|
|
for _, want := range []string{
|
|
"JOIN (",
|
|
"SELECT",
|
|
"s2.source_key",
|
|
"s2.quality_status = '" + QualityOK + "'",
|
|
"s2.daily_mileage_km BETWEEN 0 AND",
|
|
"DATEDIFF(DATE(s2.latest_event_time), DATE(s2.first_event_time))",
|
|
"COALESCE(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN') = 'UNKNOWN'",
|
|
"AND (ds.source_code IS NULL OR TRIM(ds.source_code) = '')",
|
|
"AND (ds.platform_name IS NULL OR TRIM(ds.platform_name) = '')",
|
|
"WHEN COALESCE(s2.daily_mileage_km, 0) > 0",
|
|
"AND COALESCE(s2.quality_reason, '') <> '" + QualityReasonGPSCoordinate + "' THEN 0",
|
|
"AND COALESCE(s2.quality_reason, '') = '" + QualityReasonGPSCoordinate + "' THEN 1",
|
|
"WHEN COALESCE(s2.quality_reason, '') <> '" + QualityReasonGPSCoordinate + "' THEN 2",
|
|
"CASE COALESCE(NULLIF(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN'), CASE WHEN s2.source_key LIKE '%@PLATFORM:%' THEN 'PLATFORM' WHEN s2.source_key LIKE '%@DIRECT' THEN 'DIRECT' ELSE 'UNKNOWN' END)",
|
|
"WHEN 'PLATFORM' THEN 0",
|
|
"WHEN 'DIRECT' THEN 1",
|
|
"WHEN 'UNKNOWN' THEN 2",
|
|
"ds.trust_priority",
|
|
"SUM(identity_source.sample_count)",
|
|
"identity_source.vin = s2.vin",
|
|
"COALESCE(NULLIF(TRIM(identity_source.phone), ''), NULLIF(TRIM(identity_source.device_id), ''), identity_source.source_key)",
|
|
"LIMIT 1",
|
|
} {
|
|
if !strings.Contains(markSelected, want) {
|
|
t.Fatalf("mark query missing %q: %s", want, markSelected)
|
|
}
|
|
}
|
|
if len(exec.calls[1].args) != 7 {
|
|
t.Fatalf("mark query args = %d, want 7", len(exec.calls[1].args))
|
|
}
|
|
for _, forbidden := range []string{
|
|
"WHEN 'UNKNOWN' THEN 1",
|
|
"WHEN 'DIRECT' THEN 2",
|
|
} {
|
|
if strings.Contains(projectFinal, forbidden) || strings.Contains(markSelected, forbidden) {
|
|
t.Fatalf("source selection should prefer classified DIRECT before UNKNOWN, found %q", forbidden)
|
|
}
|
|
}
|
|
for queryName, query := range map[string]string{
|
|
"project": projectFinal,
|
|
"mark": markSelected,
|
|
} {
|
|
// The project statement also contains an independent day-end odometer
|
|
// subquery. Check the daily-mileage election order after its main source
|
|
// FROM clause, rather than mistaking the odometer fallback's freshness
|
|
// order for the elected daily-distance source order.
|
|
if queryName == "project" {
|
|
if index := strings.Index(query, "FROM vehicle_daily_mileage_source s\nLEFT JOIN"); index >= 0 {
|
|
query = query[index:]
|
|
}
|
|
}
|
|
identityScore := strings.Index(query, "SUM(identity_source.sample_count)")
|
|
freshness := strings.Index(query, "latest_event_time DESC")
|
|
rowSamples := strings.LastIndex(query, "sample_count DESC")
|
|
if identityScore < 0 || freshness < identityScore || rowSamples < freshness {
|
|
t.Fatalf("%s query should rank terminal evidence before forwarding freshness and row samples: %s", queryName, query)
|
|
}
|
|
}
|
|
if got := exec.calls[1].args[3]; got != maxSelectedDailyMileageKM {
|
|
t.Fatalf("mark query max mileage arg = %#v, want %d", got, maxSelectedDailyMileageKM)
|
|
}
|
|
for _, want := range []string{
|
|
"DELETE FROM vehicle_daily_mileage",
|
|
"NOT EXISTS (",
|
|
"FROM vehicle_daily_mileage_source",
|
|
"is_selected = 1",
|
|
} {
|
|
if !strings.Contains(cleanupFinal, want) {
|
|
t.Fatalf("cleanup query missing %q: %s", want, cleanupFinal)
|
|
}
|
|
}
|
|
if len(exec.calls[2].args) != 6 {
|
|
t.Fatalf("cleanup query args = %d, want 6", len(exec.calls[2].args))
|
|
}
|
|
}
|
|
|
|
func TestProjectDailyMileageCommitsTransactionForSQLDB(t *testing.T) {
|
|
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
|
|
if err != nil {
|
|
t.Fatalf("sqlmock.New() error = %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
vin := "LA9GG64L7PBAF4001"
|
|
statDate := "2026-07-08"
|
|
protocol := envelope.ProtocolJT808
|
|
expectProjectDailyMileageSQL(mock, vin, statDate, protocol)
|
|
mock.ExpectCommit()
|
|
|
|
if err := ProjectDailyMileage(context.Background(), db, vin, statDate, protocol); err != nil {
|
|
t.Fatalf("ProjectDailyMileage() error = %v", err)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("sql expectations were not met: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProjectDailyMileageRollsBackTransactionOnFailure(t *testing.T) {
|
|
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
|
|
if err != nil {
|
|
t.Fatalf("sqlmock.New() error = %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
vin := "LA9GG64L7PBAF4001"
|
|
statDate := "2026-07-08"
|
|
protocol := envelope.ProtocolJT808
|
|
wantErr := errors.New("mark selected failed")
|
|
mock.ExpectBegin()
|
|
mock.ExpectExec(projectDailyMileageSQL).
|
|
WithArgs(vin, statDate, string(protocol), maxSelectedDailyMileageKM).
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
mock.ExpectExec(markSelectedSourceSQL).
|
|
WithArgs(vin, statDate, string(protocol), maxSelectedDailyMileageKM, vin, statDate, string(protocol)).
|
|
WillReturnError(wantErr)
|
|
mock.ExpectRollback()
|
|
|
|
err = ProjectDailyMileage(context.Background(), db, vin, statDate, protocol)
|
|
if !errors.Is(err, wantErr) {
|
|
t.Fatalf("ProjectDailyMileage() error = %v, want %v", err, wantErr)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("sql expectations were not met: %v", err)
|
|
}
|
|
}
|
|
|
|
func expectProjectDailyMileageSQL(mock sqlmock.Sqlmock, vin string, statDate string, protocol envelope.Protocol) {
|
|
mock.ExpectBegin()
|
|
mock.ExpectExec(projectDailyMileageSQL).
|
|
WithArgs(vin, statDate, string(protocol), maxSelectedDailyMileageKM).
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
mock.ExpectExec(markSelectedSourceSQL).
|
|
WithArgs(vin, statDate, string(protocol), maxSelectedDailyMileageKM, vin, statDate, string(protocol)).
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
mock.ExpectExec(cleanupProjectedDailyMileageSQL).
|
|
WithArgs(vin, statDate, string(protocol), vin, statDate, string(protocol)).
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
}
|
|
|
|
func TestProjectDailyMileagePersistsIndependentDayEndTotal(t *testing.T) {
|
|
for _, want := range []string{
|
|
"day_end_total_mileage_km",
|
|
"total_candidate.quality_status = 'OK'",
|
|
"total_candidate.quality_reason, '') = 'gps_coordinate_accumulation'",
|
|
"day_end_total_mileage_km = VALUES(day_end_total_mileage_km)",
|
|
} {
|
|
if !strings.Contains(projectDailyMileageSQL, want) {
|
|
t.Fatalf("project SQL missing %q:\n%s", want, projectDailyMileageSQL)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGB32960ReturningFeedUsesSelectedVehicleBaseline(t *testing.T) {
|
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
|
for _, tc := range []struct {
|
|
name string
|
|
current, baseline, want float64
|
|
quality string
|
|
}{
|
|
{"stale April baseline must not repeat 35042 km", 75429.6, 75429.6, 0, QualityOK},
|
|
{"source switch only counts new vehicle mileage", 42077, 42006.3, 70.7, QualityOK},
|
|
{"rollback stays invalid", 10, 75429.6, -75419.6, QualityInvalidDelta},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
db, mock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
stamp := time.Date(2026, 9, 9, 19, 9, 35, 0, loc)
|
|
mock.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source.*source_key = \? OR is_selected = 1.*quality_status = 'OK'.*ORDER BY stat_date DESC, is_selected DESC`).
|
|
WithArgs("LB9A32A26R0LS1055", "2026-09-15", "GB32960", "GB32960:unknown@returning").
|
|
WillReturnRows(sqlmock.NewRows([]string{"total", "time"}).AddRow(tc.baseline, stamp))
|
|
candidate := SourceMileageSample{VIN: "LB9A32A26R0LS1055", StatDate: "2026-09-15", Protocol: envelope.ProtocolGB32960, SourceKey: "GB32960:unknown@returning", FirstTotalKM: 40386.7, LatestTotalKM: tc.current, FirstEventTime: time.Date(2026, 4, 24, 12, 14, 51, 0, loc), LatestEventTime: time.Date(2026, 9, 15, 11, 27, 37, 0, loc)}
|
|
if err := NewWriter(db, loc).applyRealtimeBaseline(context.Background(), &candidate); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if math.Abs(candidate.DailyKM-tc.want) > 0.0001 || candidate.QualityStatus != tc.quality || !candidate.FirstEventTime.Equal(stamp) {
|
|
t.Fatalf("%+v", candidate)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
}
|
|
}
|