Files
lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats/daily_metric_test.go
2026-07-08 15:41:59 +08:00

592 lines
19 KiB
Go

package stats
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"math"
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
func TestSamplesFromEnvelopeDerivesDailyMileageSample(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*3600)
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
VIN: "LNBVIN00000000001",
EventTimeMS: time.Date(2026, 7, 1, 8, 30, 0, 0, loc).UnixMilli(),
Fields: map[string]any{
envelope.FieldTotalMileageKM: 10241.2,
},
}
samples, err := SamplesFromEnvelope(env, loc)
if err != nil {
t.Fatalf("SamplesFromEnvelope() error = %v", err)
}
if len(samples) != 1 {
t.Fatalf("sample count = %d", len(samples))
}
if samples[0].VIN != "LNBVIN00000000001" {
t.Fatalf("vin = %q", samples[0].VIN)
}
if samples[0].TotalMileageKM != 10241.2 {
t.Fatalf("total mileage = %v", samples[0].TotalMileageKM)
}
if samples[0].StatDate != "2026-07-01" {
t.Fatalf("stat date = %q", samples[0].StatDate)
}
}
func TestSamplesFromEnvelopeMapsProtocolMileageFields(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*3600)
tests := []struct {
name string
protocol envelope.Protocol
fields map[string]any
wantKM float64
}{
{
name: "gb32960",
protocol: envelope.ProtocolGB32960,
fields: map[string]any{"gb32960.vehicle.total_mileage_km": "38587"},
wantKM: 38587,
},
{
name: "jt808",
protocol: envelope.ProtocolJT808,
fields: map[string]any{"jt808.location.total_mileage_km": "12432.4"},
wantKM: 12432.4,
},
{
name: "yutong mqtt meters",
protocol: envelope.ProtocolYutongMQTT,
fields: map[string]any{"yutong_mqtt.data.total_mileage": "120756000"},
wantKM: 120756,
},
{
name: "yutong mqtt root meters",
protocol: envelope.ProtocolYutongMQTT,
fields: map[string]any{"yutong_mqtt.root.data.total_mileage": float64(22858000)},
wantKM: 22858,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
samples, err := SamplesFromEnvelope(envelope.FrameEnvelope{
Protocol: tt.protocol,
VIN: "LNBVIN00000000001",
EventTimeMS: time.Date(2026, 7, 1, 8, 30, 0, 0, loc).UnixMilli(),
Fields: tt.fields,
}, loc)
if err != nil {
t.Fatalf("SamplesFromEnvelope() error = %v", err)
}
if len(samples) != 1 {
t.Fatalf("sample count = %d", len(samples))
}
if samples[0].TotalMileageKM != tt.wantKM {
t.Fatalf("total mileage = %v, want %v", samples[0].TotalMileageKM, tt.wantKM)
}
})
}
}
func TestSamplesFromEnvelopeSkipsMissingVIN(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*3600)
samples, err := SamplesFromEnvelope(envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "13307811254",
EventTimeMS: time.Date(2026, 7, 2, 0, 3, 0, 0, loc).UnixMilli(),
Fields: map[string]any{
envelope.FieldTotalMileageKM: 10985.7,
},
}, loc)
if err != nil {
t.Fatalf("SamplesFromEnvelope() error = %v", err)
}
if len(samples) != 0 {
t.Fatalf("expected no samples without VIN, got %#v", samples)
}
}
func TestSamplesFromEnvelopeSkipsMissingVINOrMileage(t *testing.T) {
samples, err := SamplesFromEnvelope(envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Fields: map[string]any{envelope.FieldTotalMileageKM: 1.2},
}, nil)
if err != nil {
t.Fatalf("SamplesFromEnvelope() error = %v", err)
}
if len(samples) != 0 {
t.Fatalf("expected no samples without VIN, got %#v", samples)
}
samples, err = SamplesFromEnvelope(envelope.FrameEnvelope{
Protocol: envelope.ProtocolGB32960,
VIN: "LNBVIN00000000002",
Fields: map[string]any{},
}, nil)
if err != nil {
t.Fatalf("SamplesFromEnvelope() error = %v", err)
}
if len(samples) != 0 {
t.Fatalf("expected no samples without mileage, got %#v", samples)
}
}
func TestSamplesFromEnvelopeSkipsNonPositiveMileage(t *testing.T) {
for _, value := range []any{0, 0.0, -1.0, "0"} {
samples, err := SamplesFromEnvelope(envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
VIN: "LNBVIN00000000001",
EventTimeMS: time.Date(2026, 7, 1, 9, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).UnixMilli(),
Fields: map[string]any{
envelope.FieldTotalMileageKM: value,
},
}, nil)
if err != nil {
t.Fatalf("SamplesFromEnvelope(%#v) error = %v", value, err)
}
if len(samples) != 0 {
t.Fatalf("expected no samples for non-positive mileage %#v, got %#v", value, samples)
}
}
}
func TestWriterAppendWritesSourceCandidateAndProjection(t *testing.T) {
exec := &recordingExec{}
writer := NewWriter(exec, time.FixedZone("Asia/Shanghai", 8*3600))
event := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
VIN: "LA9GG64L7PBAF4001",
Phone: "13307765812",
SourceEndpoint: "115.231.168.135:20215",
EventTimeMS: time.Date(2026, 7, 8, 13, 20, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).UnixMilli(),
Fields: map[string]any{
"jt808.location.total_mileage_km": 4123.9,
},
}
if err := writer.Append(context.Background(), event); err != nil {
t.Fatalf("Append() error = %v", err)
}
if len(exec.calls) != 6 {
t.Fatalf("exec calls = %d, want source + candidate + clear + project + mark + cleanup", len(exec.calls))
}
if !strings.Contains(exec.calls[0].query, "INSERT INTO vehicle_data_source") {
t.Fatalf("first call should upsert source: %s", exec.calls[0].query)
}
if !strings.Contains(exec.calls[1].query, "INSERT INTO vehicle_daily_mileage_source") {
t.Fatalf("second call should upsert candidate: %s", exec.calls[1].query)
}
if !strings.Contains(exec.calls[3].query, "INSERT INTO vehicle_daily_mileage") {
t.Fatalf("fourth call should project final: %s", exec.calls[3].query)
}
}
func TestWriterAppendDedupesRealtimeMileagePerSource(t *testing.T) {
exec := &recordingExec{}
writer := NewWriter(exec, time.FixedZone("Asia/Shanghai", 8*3600))
base := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
VIN: "LA9GG64L7PBAF4001",
Phone: "13307765812",
EventTimeMS: time.Date(2026, 7, 8, 13, 20, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).UnixMilli(),
Fields: map[string]any{
"jt808.location.total_mileage_km": 4123.9,
},
}
sourceA := base
sourceA.SourceEndpoint = "115.231.168.135:20215"
if err := writer.Append(context.Background(), sourceA); err != nil {
t.Fatalf("Append(sourceA) error = %v", err)
}
sourceB := base
sourceB.SourceEndpoint = "115.159.85.149:28316"
sourceB.ReceivedAtMS = sourceB.EventTimeMS + 1000
if err := writer.Append(context.Background(), sourceB); err != nil {
t.Fatalf("Append(sourceB) error = %v", err)
}
sourceADuplicate := sourceA
sourceADuplicate.ReceivedAtMS = sourceA.EventTimeMS + 2000
if err := writer.Append(context.Background(), sourceADuplicate); err != nil {
t.Fatalf("Append(sourceADuplicate) error = %v", err)
}
if got := countExecQueries(exec.calls, "INSERT INTO vehicle_daily_mileage_source"); got != 2 {
t.Fatalf("candidate upserts = %d, want 2", got)
}
if got := countExecQueries(exec.calls, "INSERT INTO vehicle_data_source"); got != 2 {
t.Fatalf("source upserts = %d, want 2", got)
}
}
func TestWriterAppendWritesNoPreviousBaselineCandidateAndCleansProjection(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)
writer := NewWriter(db, loc)
eventTime := time.Date(2026, 7, 8, 13, 20, 0, 0, loc)
event := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
VIN: "LA9GG64L7PBAF4001",
Phone: "13307765812",
SourceEndpoint: "115.231.168.135:20215",
EventTimeMS: eventTime.UnixMilli(),
Fields: map[string]any{
"jt808.location.total_mileage_km": 4123.9,
},
}
mock.ExpectExec(`INSERT INTO vehicle_data_source`).
WithArgs("JT808", "115.231.168.135", "115.231.168.135:20215", sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`).
WithArgs("LA9GG64L7PBAF4001", "2026-07-07", "JT808", "JT808:13307765812@115.231.168.135").
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}))
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(4123.9),
float64(4123.9),
float64(0),
int64(1),
eventTime,
eventTime,
QualityNoPreviousBaseline,
"missing_previous_source",
).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`UPDATE vehicle_daily_mileage_source`).
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage`).
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", int64(1000)).
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec(`UPDATE vehicle_daily_mileage_source s`).
WithArgs(
"LA9GG64L7PBAF4001",
"2026-07-08",
"JT808",
int64(1000),
"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))
if err := writer.Append(context.Background(), event); err != nil {
t.Fatalf("Append() error = %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestWriterAppendUsesPreviousSourceBaselineForRealtimeCandidate(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)
writer := NewWriter(db, loc)
previousTime := time.Date(2026, 7, 7, 23, 58, 0, 0, loc)
currentTime := time.Date(2026, 7, 8, 13, 20, 0, 0, loc)
event := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
VIN: "LA9GG64L7PBAF4001",
Phone: "13307765812",
SourceEndpoint: "115.231.168.135:20215",
EventTimeMS: currentTime.UnixMilli(),
Fields: map[string]any{
"jt808.location.total_mileage_km": 4123.9,
},
}
mock.ExpectExec(`INSERT INTO vehicle_data_source`).
WithArgs("JT808", "115.231.168.135", "115.231.168.135:20215", sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`).
WithArgs("LA9GG64L7PBAF4001", "2026-07-07", "JT808", "JT808:13307765812@115.231.168.135").
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}).
AddRow(4100.8, previousTime))
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.8),
float64(4123.9),
approxFloat64{want: 23.1, tolerance: 0.000001},
int64(1),
previousTime,
currentTime,
QualityOK,
"same_source_previous_day",
).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`UPDATE vehicle_daily_mileage_source`).
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage`).
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", int64(1000)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`UPDATE vehicle_daily_mileage_source s`).
WithArgs(
"LA9GG64L7PBAF4001",
"2026-07-08",
"JT808",
int64(1000),
"LA9GG64L7PBAF4001",
"2026-07-08",
"JT808",
).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`DELETE FROM vehicle_daily_mileage`).
WithArgs(
"LA9GG64L7PBAF4001",
"2026-07-08",
"JT808",
"LA9GG64L7PBAF4001",
"2026-07-08",
"JT808",
).
WillReturnResult(sqlmock.NewResult(0, 0))
if err := writer.Append(context.Background(), event); err != nil {
t.Fatalf("Append() error = %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestWriterEnsuresSchemaAndUpsertsDailyMileage(t *testing.T) {
exec := &recordingExec{}
writer := NewWriter(exec, time.FixedZone("Asia/Shanghai", 8*3600))
if err := writer.EnsureSchema(context.Background()); err != nil {
t.Fatalf("EnsureSchema() error = %v", err)
}
if err := writer.Append(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolGB32960,
VIN: "LNBVIN00000000002",
Phone: "13307765812",
SourceEndpoint: "115.231.168.135:20215",
EventTimeMS: time.Date(2026, 7, 1, 9, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).UnixMilli(),
Fields: map[string]any{
envelope.FieldTotalMileageKM: "10000.0",
},
}); err != nil {
t.Fatalf("Append() error = %v", err)
}
if len(exec.calls) != 12 {
t.Fatalf("exec calls = %d", len(exec.calls))
}
for i, want := range []string{
"CREATE TABLE IF NOT EXISTS vehicle_data_source",
"CREATE TABLE IF NOT EXISTS vehicle_daily_mileage_source",
"CREATE TABLE IF NOT EXISTS vehicle_daily_mileage",
} {
if !strings.Contains(exec.calls[i].query, want) {
t.Fatalf("schema call %d = %s, want %s", i, exec.calls[i].query, want)
}
}
for _, column := range []string{"vehicle_key", "id BIGINT", "AUTO_INCREMENT", "created_at"} {
if strings.Contains(exec.calls[2].query, column) {
t.Fatalf("schema should not include %s: %s", column, exec.calls[2].query)
}
}
if !strings.Contains(exec.calls[2].query, "PRIMARY KEY (vin, stat_date, protocol)") {
t.Fatalf("daily mileage table should key by vin/stat_date/protocol: %s", exec.calls[2].query)
}
if strings.Contains(exec.calls[2].query, "KEY idx_vin (vin)") {
t.Fatalf("daily mileage table should not keep redundant vin index covered by the primary key: %s", exec.calls[2].query)
}
projectCall := exec.calls[9]
if !strings.Contains(projectCall.query, "INSERT INTO vehicle_daily_mileage") {
t.Fatalf("unexpected project sql: %s", projectCall.query)
}
if !strings.Contains(projectCall.query, "ON DUPLICATE KEY UPDATE") {
t.Fatalf("unexpected upsert sql: %s", projectCall.query)
}
if strings.Contains(projectCall.query, "metric_key") || strings.Contains(projectCall.query, "metric_unit") {
t.Fatalf("daily mileage upsert should not use generic metric columns: %s", projectCall.query)
}
if !strings.Contains(projectCall.query, "LEFT JOIN vehicle_data_source ds") {
t.Fatalf("project sql should join data source: %s", projectCall.query)
}
if !strings.Contains(projectCall.query, "trusted_source_key") {
t.Fatalf("upsert should track trusted source: %s", projectCall.query)
}
if got := projectCall.args[0]; got != "LNBVIN00000000002" {
t.Fatalf("first upsert arg should be vin, got %#v", got)
}
if !strings.Contains(exec.calls[6].query, "INSERT INTO vehicle_data_source") {
t.Fatalf("source upsert query missing: %s", exec.calls[6].query)
}
if !strings.Contains(exec.calls[7].query, "INSERT INTO vehicle_daily_mileage_source") {
t.Fatalf("candidate upsert query missing: %s", exec.calls[7].query)
}
}
func TestWriterSkipsConsecutiveDuplicateMileageSamples(t *testing.T) {
exec := &recordingExec{}
writer := NewWriter(exec, time.FixedZone("Asia/Shanghai", 8*3600))
event := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
VIN: "LNBVIN00000000001",
EventTimeMS: time.Date(2026, 7, 1, 9, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).UnixMilli(),
Fields: map[string]any{
envelope.FieldTotalMileageKM: 10241.2,
},
}
if err := writer.Append(context.Background(), event); err != nil {
t.Fatalf("first Append() error = %v", err)
}
event.ReceivedAtMS += 1000
if err := writer.Append(context.Background(), event); err != nil {
t.Fatalf("duplicate Append() error = %v", err)
}
if len(exec.calls) != 4 {
t.Fatalf("exec calls = %d, want 4", len(exec.calls))
}
}
func TestWriterDoesNotCacheMileageWhenUpsertFails(t *testing.T) {
exec := &recordingExec{errs: []error{errors.New("mysql unavailable"), nil}}
writer := NewWriter(exec, time.FixedZone("Asia/Shanghai", 8*3600))
event := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
VIN: "LNBVIN00000000001",
EventTimeMS: time.Date(2026, 7, 1, 9, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).UnixMilli(),
Fields: map[string]any{
envelope.FieldTotalMileageKM: 10241.2,
},
}
if err := writer.Append(context.Background(), event); err == nil {
t.Fatalf("first Append() error = nil, want mysql error")
}
if err := writer.Append(context.Background(), event); err != nil {
t.Fatalf("retry Append() error = %v", err)
}
if len(exec.calls) != 5 {
t.Fatalf("exec calls = %d, want 5", len(exec.calls))
}
}
func TestWriterKeepsOnlyLatestDateInMileageCache(t *testing.T) {
exec := &recordingExec{}
writer := NewWriter(exec, time.FixedZone("Asia/Shanghai", 8*3600))
event := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
VIN: "LNBVIN00000000001",
Fields: map[string]any{
envelope.FieldTotalMileageKM: 10241.2,
},
}
event.EventTimeMS = time.Date(2026, 7, 1, 23, 59, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).UnixMilli()
if err := writer.Append(context.Background(), event); err != nil {
t.Fatalf("first Append() error = %v", err)
}
event.EventTimeMS = time.Date(2026, 7, 2, 0, 1, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).UnixMilli()
if err := writer.Append(context.Background(), event); err != nil {
t.Fatalf("next-day Append() error = %v", err)
}
if len(exec.calls) != 8 {
t.Fatalf("exec calls = %d, want 8", len(exec.calls))
}
if len(writer.lastTotalMileage) != 1 {
t.Fatalf("cache entries = %d, want 1", len(writer.lastTotalMileage))
}
}
func countExecQueries(calls []execCall, fragment string) int {
count := 0
for _, call := range calls {
if strings.Contains(call.query, fragment) {
count++
}
}
return count
}
type execCall struct {
query string
args []any
}
type recordingExec struct {
calls []execCall
errs []error
}
func (e *recordingExec) ExecContext(_ context.Context, query string, args ...any) (sql.Result, error) {
e.calls = append(e.calls, execCall{query: query, args: args})
if len(e.errs) > 0 {
err := e.errs[0]
e.errs = e.errs[1:]
return nil, err
}
return nil, nil
}
type approxFloat64 struct {
want float64
tolerance float64
}
func (m approxFloat64) Match(value driver.Value) bool {
got, ok := value.(float64)
if !ok {
return false
}
return math.Abs(got-m.want) <= m.tolerance
}