638 lines
24 KiB
Go
638 lines
24 KiB
Go
package stats
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"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[13]; got != previous.Truncate(time.Second) {
|
|
t.Fatalf("first event arg = %v, want %v", got, previous.Truncate(time.Second))
|
|
}
|
|
if got := exec.calls[0].args[14]; 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",
|
|
"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 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 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 + "')",
|
|
"WHEN MIN(s.first_event_time) < 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 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",
|
|
"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 + "'",
|
|
"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))",
|
|
} {
|
|
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",
|
|
"sample_count)",
|
|
"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) = '')",
|
|
"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",
|
|
"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)
|
|
}
|
|
}
|
|
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))
|
|
}
|