2584 lines
93 KiB
Go
2584 lines
93 KiB
Go
package stats
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"database/sql/driver"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/DATA-DOG/go-sqlmock"
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
|
)
|
|
|
|
func managedTestSource(env envelope.FrameEnvelope) envelope.FrameEnvelope {
|
|
if strings.TrimSpace(env.SourceKind) == "" {
|
|
env.SourceKind = "PLATFORM"
|
|
}
|
|
return env
|
|
}
|
|
|
|
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{
|
|
"jt808.location.total_mileage_km": 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 km",
|
|
protocol: envelope.ProtocolYutongMQTT,
|
|
fields: map[string]any{"yutong_mqtt.data.total_mileage_km": "120756"},
|
|
wantKM: 120756,
|
|
},
|
|
{
|
|
name: "yutong mqtt root meters",
|
|
protocol: envelope.ProtocolYutongMQTT,
|
|
fields: map[string]any{"yutong_mqtt.root.data.total_mileage": float64(22858000)},
|
|
wantKM: 22858,
|
|
},
|
|
{
|
|
name: "yutong mqtt root km",
|
|
protocol: envelope.ProtocolYutongMQTT,
|
|
fields: map[string]any{"yutong_mqtt.root.data.total_mileage_km": float64(22858)},
|
|
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 TestSamplesFromEnvelopeFallsBackToReceivedTimeWhenEventTimeIsFuture(t *testing.T) {
|
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
|
received := time.Date(2026, 7, 8, 23, 59, 0, 0, loc)
|
|
futureEvent := received.Add(48 * time.Hour)
|
|
samples, err := SamplesFromEnvelope(envelope.FrameEnvelope{
|
|
Protocol: envelope.ProtocolJT808,
|
|
VIN: "LA9GG64L7PBAF4001",
|
|
EventTimeMS: futureEvent.UnixMilli(),
|
|
ReceivedAtMS: received.UnixMilli(),
|
|
Fields: map[string]any{
|
|
"jt808.location.total_mileage_km": 4136.8,
|
|
},
|
|
}, loc)
|
|
if err != nil {
|
|
t.Fatalf("SamplesFromEnvelope() error = %v", err)
|
|
}
|
|
if len(samples) != 1 {
|
|
t.Fatalf("sample count = %d", len(samples))
|
|
}
|
|
if samples[0].StatDate != "2026-07-08" {
|
|
t.Fatalf("stat date = %q, want received date", samples[0].StatDate)
|
|
}
|
|
if !samples[0].EventTime.Equal(received) {
|
|
t.Fatalf("event time = %s, want received time %s", samples[0].EventTime, received)
|
|
}
|
|
_, result, err := samplesFromEnvelopeWithResult(envelope.FrameEnvelope{
|
|
Protocol: envelope.ProtocolJT808,
|
|
VIN: "LA9GG64L7PBAF4001",
|
|
EventTimeMS: futureEvent.UnixMilli(),
|
|
ReceivedAtMS: received.UnixMilli(),
|
|
Fields: map[string]any{
|
|
"jt808.location.total_mileage_km": 4136.8,
|
|
},
|
|
}, loc)
|
|
if err != nil {
|
|
t.Fatalf("samplesFromEnvelopeWithResult() error = %v", err)
|
|
}
|
|
if result.SamplesAdjustedFutureEventTime != 1 {
|
|
t.Fatalf("future event adjustment count = %d, want 1", result.SamplesAdjustedFutureEventTime)
|
|
}
|
|
}
|
|
|
|
func TestSamplesFromEnvelopeKeepsSmallFutureClockSkew(t *testing.T) {
|
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
|
received := time.Date(2026, 7, 8, 23, 59, 0, 0, loc)
|
|
eventTime := received.Add(2 * time.Minute)
|
|
samples, err := SamplesFromEnvelope(envelope.FrameEnvelope{
|
|
Protocol: envelope.ProtocolJT808,
|
|
VIN: "LA9GG64L7PBAF4001",
|
|
EventTimeMS: eventTime.UnixMilli(),
|
|
ReceivedAtMS: received.UnixMilli(),
|
|
Fields: map[string]any{
|
|
"jt808.location.total_mileage_km": 4136.8,
|
|
},
|
|
}, loc)
|
|
if err != nil {
|
|
t.Fatalf("SamplesFromEnvelope() error = %v", err)
|
|
}
|
|
if len(samples) != 1 {
|
|
t.Fatalf("sample count = %d", len(samples))
|
|
}
|
|
if samples[0].StatDate != "2026-07-09" {
|
|
t.Fatalf("stat date = %q, want event date within skew", samples[0].StatDate)
|
|
}
|
|
if !samples[0].EventTime.Equal(eventTime) {
|
|
t.Fatalf("event time = %s, want event time %s", samples[0].EventTime, eventTime)
|
|
}
|
|
}
|
|
|
|
func TestWriterSourceSeenAtUsesReceivedTimeOverDeviceEventTime(t *testing.T) {
|
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
|
writer := NewWriter(&recordingExec{}, loc)
|
|
received := time.Date(2026, 7, 8, 13, 20, 0, 0, loc)
|
|
staleDeviceEvent := received.Add(-72 * time.Hour)
|
|
|
|
got := writer.sourceSeenAt(envelope.FrameEnvelope{
|
|
EventTimeMS: staleDeviceEvent.UnixMilli(),
|
|
ReceivedAtMS: received.UnixMilli(),
|
|
})
|
|
if !got.Equal(received) {
|
|
t.Fatalf("sourceSeenAt = %s, want received time %s", got, received)
|
|
}
|
|
}
|
|
|
|
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{
|
|
"jt808.location.total_mileage_km": 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{"jt808.location.total_mileage_km": 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{
|
|
"jt808.location.total_mileage_km": 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",
|
|
SourceCode: "g7s",
|
|
PlatformName: "G7s",
|
|
SourceKind: "PLATFORM",
|
|
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) != 7 {
|
|
t.Fatalf("exec calls = %d, want source + candidate + normalize insert/delete + project + reconcile + 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 exec.calls[0].args[3] != "G7s" || exec.calls[0].args[4] != "g7s" || exec.calls[0].args[5] != "PLATFORM" {
|
|
t.Fatalf("source metadata args = %#v", exec.calls[0].args)
|
|
}
|
|
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 exec.calls[1].args[8] != "G7s" {
|
|
t.Fatalf("source mileage platform arg = %#v", exec.calls[1].args)
|
|
}
|
|
if !strings.Contains(exec.calls[2].query, "INSERT INTO vehicle_daily_mileage_source") ||
|
|
!strings.Contains(exec.calls[2].query, "SELECT") ||
|
|
!strings.Contains(exec.calls[2].query, "@PLATFORM:") {
|
|
t.Fatalf("third call should normalize legacy platform source rows: %s", exec.calls[2].query)
|
|
}
|
|
if !strings.Contains(exec.calls[3].query, "DELETE s") ||
|
|
!strings.Contains(exec.calls[3].query, "@PLATFORM:") {
|
|
t.Fatalf("fourth call should delete normalized legacy platform rows: %s", exec.calls[3].query)
|
|
}
|
|
if !strings.Contains(exec.calls[4].query, "INSERT INTO vehicle_daily_mileage") {
|
|
t.Fatalf("fifth call should project final: %s", exec.calls[4].query)
|
|
}
|
|
}
|
|
|
|
func TestWriterAppendWithResultReportsWrittenSample(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,
|
|
},
|
|
}
|
|
|
|
result, err := writer.AppendWithResult(context.Background(), event)
|
|
if err != nil {
|
|
t.Fatalf("AppendWithResult() error = %v", err)
|
|
}
|
|
if result.SamplesFound != 1 || result.SamplesWritten != 1 {
|
|
t.Fatalf("result = %+v, want one found and written sample", result)
|
|
}
|
|
if result.ProjectionsAttempted != 1 || result.ProjectionsWritten != 1 || result.ProjectionsSkippedThrottled != 0 {
|
|
t.Fatalf("projection result = %+v, want one final projection", result)
|
|
}
|
|
if result.SourceTouchesSkippedUnmanaged != 1 || result.SourceTouchesAttempted != 0 || result.SourceTouchesWritten != 0 || result.SourceTouchesSkippedMissing != 0 {
|
|
t.Fatalf("source touch result = %+v, want unmanaged source skipped", result)
|
|
}
|
|
if result.SamplesSkippedMissingVIN != 0 ||
|
|
result.SamplesSkippedMissingMileage != 0 ||
|
|
result.SamplesSkippedNonPositiveMileage != 0 ||
|
|
result.SamplesSkippedSameMileage != 0 ||
|
|
result.SamplesSkippedMissingSource != 0 {
|
|
t.Fatalf("unexpected skipped result counters: %+v", result)
|
|
}
|
|
}
|
|
|
|
func TestWriterAppendSkipsMileageWithoutSourceIdentity(t *testing.T) {
|
|
exec := &recordingExec{}
|
|
writer := NewWriter(exec, time.FixedZone("Asia/Shanghai", 8*3600))
|
|
event := 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,
|
|
},
|
|
}
|
|
|
|
if err := writer.Append(context.Background(), event); err != nil {
|
|
t.Fatalf("Append() error = %v", err)
|
|
}
|
|
if len(exec.calls) != 0 {
|
|
t.Fatalf("exec calls = %d, want 0 without source identity", len(exec.calls))
|
|
}
|
|
}
|
|
|
|
func TestWriterAppendWithResultReportsMissingSource(t *testing.T) {
|
|
exec := &recordingExec{}
|
|
writer := NewWriter(exec, time.FixedZone("Asia/Shanghai", 8*3600))
|
|
event := 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,
|
|
},
|
|
}
|
|
|
|
result, err := writer.AppendWithResult(context.Background(), event)
|
|
if err != nil {
|
|
t.Fatalf("AppendWithResult() error = %v", err)
|
|
}
|
|
if result.SamplesFound != 1 || result.SamplesSkippedMissingSource != 1 || result.SamplesWritten != 0 {
|
|
t.Fatalf("result = %+v, want found sample skipped by missing source", result)
|
|
}
|
|
if result.SourceTouchesSkippedMissing != 1 || result.SourceTouchesAttempted != 0 || result.SourceTouchesWritten != 0 {
|
|
t.Fatalf("source touch result = %+v, want missing source endpoint", result)
|
|
}
|
|
if result.ProjectionsAttempted != 0 || result.ProjectionsWritten != 0 || result.ProjectionsSkippedThrottled != 0 {
|
|
t.Fatalf("projection result = %+v, want no projection without source", result)
|
|
}
|
|
if len(exec.calls) != 0 {
|
|
t.Fatalf("exec calls = %d, want 0 without source identity", len(exec.calls))
|
|
}
|
|
}
|
|
|
|
func TestWriterAppendTracksSourceWithoutMileageSample(t *testing.T) {
|
|
exec := &recordingExec{}
|
|
writer := NewWriter(exec, time.FixedZone("Asia/Shanghai", 8*3600))
|
|
event := managedTestSource(envelope.FrameEnvelope{
|
|
Protocol: envelope.ProtocolJT808,
|
|
Phone: "13307795518",
|
|
SourceEndpoint: "222.66.200.68:43614",
|
|
EventTimeMS: time.Date(2026, 7, 8, 16, 30, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).UnixMilli(),
|
|
Fields: map[string]any{
|
|
"jt808.location.speed_kmh": 38.5,
|
|
},
|
|
})
|
|
|
|
if err := writer.Append(context.Background(), event); err != nil {
|
|
t.Fatalf("Append() error = %v", err)
|
|
}
|
|
if len(exec.calls) != 1 {
|
|
t.Fatalf("exec calls = %d, want source only", len(exec.calls))
|
|
}
|
|
if !strings.Contains(exec.calls[0].query, "INSERT INTO vehicle_data_source") {
|
|
t.Fatalf("source upsert query missing: %s", exec.calls[0].query)
|
|
}
|
|
if got := exec.calls[0].args[1]; got != "222.66.200.68" {
|
|
t.Fatalf("source ip arg = %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestWriterAppendWithResultReportsMissingMileage(t *testing.T) {
|
|
exec := &recordingExec{}
|
|
writer := NewWriter(exec, time.FixedZone("Asia/Shanghai", 8*3600))
|
|
event := managedTestSource(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.speed_kmh": 38.5,
|
|
},
|
|
})
|
|
|
|
result, err := writer.AppendWithResult(context.Background(), event)
|
|
if err != nil {
|
|
t.Fatalf("AppendWithResult() error = %v", err)
|
|
}
|
|
if result.SamplesFound != 0 || result.SamplesSkippedMissingMileage != 1 || result.SamplesWritten != 0 {
|
|
t.Fatalf("result = %+v, want missing mileage", result)
|
|
}
|
|
if result.SourceTouchesAttempted != 1 || result.SourceTouchesWritten != 1 || result.SourceTouchesSkippedThrottled != 0 {
|
|
t.Fatalf("source touch result = %+v, want source touch before missing mileage skip", result)
|
|
}
|
|
if result.ProjectionsAttempted != 0 || result.ProjectionsWritten != 0 || result.ProjectionsSkippedThrottled != 0 {
|
|
t.Fatalf("projection result = %+v, want no projection for missing mileage", result)
|
|
}
|
|
}
|
|
|
|
func TestWriterAppendWithResultClassifiesGB32960VendorFrameAsNonMileage(t *testing.T) {
|
|
exec := &recordingExec{}
|
|
writer := NewWriter(exec, time.FixedZone("Asia/Shanghai", 8*3600))
|
|
event := managedTestSource(envelope.FrameEnvelope{
|
|
Protocol: envelope.ProtocolGB32960,
|
|
VIN: "LB9A32A24R0LS1426",
|
|
SourceEndpoint: "115.29.187.205:32960",
|
|
EventTimeMS: time.Date(2026, 7, 8, 13, 20, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).UnixMilli(),
|
|
Fields: map[string]any{
|
|
"gb32960.gd_fc_stack.stack_water_outlet_temp_c": 63,
|
|
"gb32960.gd_fc_stack.hydrogen_inlet_pressure_kpa": 130,
|
|
},
|
|
})
|
|
|
|
result, err := writer.AppendWithResult(context.Background(), event)
|
|
if err != nil {
|
|
t.Fatalf("AppendWithResult() error = %v", err)
|
|
}
|
|
if result.SamplesFound != 0 || result.SamplesSkippedNonMileageFrame != 1 || result.SamplesSkippedMissingMileage != 0 || result.SamplesWritten != 0 {
|
|
t.Fatalf("result = %+v, want non-mileage frame", result)
|
|
}
|
|
if result.SourceTouchesAttempted != 1 || result.SourceTouchesWritten != 1 {
|
|
t.Fatalf("source touch result = %+v, want source touch before non-mileage skip", result)
|
|
}
|
|
}
|
|
|
|
func TestWriterAppendWithResultKeepsGB32960VehicleFrameMissingMileageActionable(t *testing.T) {
|
|
exec := &recordingExec{}
|
|
writer := NewWriter(exec, time.FixedZone("Asia/Shanghai", 8*3600))
|
|
event := envelope.FrameEnvelope{
|
|
Protocol: envelope.ProtocolGB32960,
|
|
VIN: "LB9A32A24R0LS1426",
|
|
SourceEndpoint: "115.29.187.205:32960",
|
|
EventTimeMS: time.Date(2026, 7, 8, 13, 20, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).UnixMilli(),
|
|
Fields: map[string]any{
|
|
"gb32960.vehicle.speed_kmh": 38.5,
|
|
"gb32960.vehicle.soc_percent": 81.2,
|
|
},
|
|
}
|
|
|
|
result, err := writer.AppendWithResult(context.Background(), event)
|
|
if err != nil {
|
|
t.Fatalf("AppendWithResult() error = %v", err)
|
|
}
|
|
if result.SamplesFound != 0 || result.SamplesSkippedMissingMileage != 1 || result.SamplesSkippedNonMileageFrame != 0 || result.SamplesWritten != 0 {
|
|
t.Fatalf("result = %+v, want actionable missing mileage", result)
|
|
}
|
|
}
|
|
|
|
func TestWriterAppendWithResultClassifiesYutongSparseFrameAsMissingMileage(t *testing.T) {
|
|
exec := &recordingExec{}
|
|
writer := NewWriter(exec, time.FixedZone("Asia/Shanghai", 8*3600))
|
|
event := envelope.FrameEnvelope{
|
|
Protocol: envelope.ProtocolYutongMQTT,
|
|
VIN: "LMRKH9AC2R1004087",
|
|
SourceEndpoint: "mqtt://yutong/ytforward/shln/3",
|
|
EventTimeMS: time.Date(2026, 7, 8, 13, 20, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).UnixMilli(),
|
|
Fields: map[string]any{
|
|
"yutong_mqtt.data.latitude": 30.590921,
|
|
"yutong_mqtt.data.longitude": 121.075044,
|
|
},
|
|
}
|
|
|
|
result, err := writer.AppendWithResult(context.Background(), event)
|
|
if err != nil {
|
|
t.Fatalf("AppendWithResult() error = %v", err)
|
|
}
|
|
if result.SamplesFound != 0 || result.SamplesSkippedMissingMileage != 1 || result.SamplesSkippedNonMileageFrame != 0 || result.SamplesWritten != 0 {
|
|
t.Fatalf("result = %+v, want missing mileage", result)
|
|
}
|
|
}
|
|
|
|
func TestWriterAppendWithResultClassifiesYutongMetadataOnlyFrameAsNonMileage(t *testing.T) {
|
|
exec := &recordingExec{}
|
|
writer := NewWriter(exec, time.FixedZone("Asia/Shanghai", 8*3600))
|
|
event := envelope.FrameEnvelope{
|
|
Protocol: envelope.ProtocolYutongMQTT,
|
|
VIN: "LMRKH9AC2R1004087",
|
|
SourceEndpoint: "mqtt://yutong/ytforward/shln/3",
|
|
EventTimeMS: time.Date(2026, 7, 8, 13, 20, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).UnixMilli(),
|
|
Fields: map[string]any{
|
|
"yutong_mqtt.metadata.topic": "/ytforward/shln/3",
|
|
"yutong_mqtt.root.device": "LMRKH9AC2R1004087",
|
|
},
|
|
}
|
|
|
|
result, err := writer.AppendWithResult(context.Background(), event)
|
|
if err != nil {
|
|
t.Fatalf("AppendWithResult() error = %v", err)
|
|
}
|
|
if result.SamplesFound != 0 || result.SamplesSkippedNonMileageFrame != 1 || result.SamplesSkippedMissingMileage != 0 || result.SamplesWritten != 0 {
|
|
t.Fatalf("result = %+v, want non-mileage frame", result)
|
|
}
|
|
}
|
|
|
|
func TestWriterAppendWithResultReportsMissingTime(t *testing.T) {
|
|
exec := &recordingExec{}
|
|
writer := NewWriter(exec, time.FixedZone("Asia/Shanghai", 8*3600))
|
|
event := managedTestSource(envelope.FrameEnvelope{
|
|
Protocol: envelope.ProtocolJT808,
|
|
VIN: "LA9GG64L7PBAF4001",
|
|
Phone: "13307765812",
|
|
SourceEndpoint: "115.231.168.135:20215",
|
|
Fields: map[string]any{
|
|
"jt808.location.total_mileage_km": 4123.9,
|
|
},
|
|
})
|
|
|
|
result, err := writer.AppendWithResult(context.Background(), event)
|
|
if err != nil {
|
|
t.Fatalf("AppendWithResult() error = %v", err)
|
|
}
|
|
if result.SamplesFound != 0 || result.SamplesSkippedMissingTime != 1 || result.SamplesWritten != 0 {
|
|
t.Fatalf("result = %+v, want missing time skip", result)
|
|
}
|
|
if result.SourceTouchesAttempted != 1 || result.SourceTouchesWritten != 1 || result.SourceTouchesSkippedThrottled != 0 {
|
|
t.Fatalf("source touch result = %+v, want source touch before missing time skip", result)
|
|
}
|
|
if result.ProjectionsAttempted != 0 || result.ProjectionsWritten != 0 || result.ProjectionsSkippedThrottled != 0 {
|
|
t.Fatalf("projection result = %+v, want no projection for missing time", result)
|
|
}
|
|
}
|
|
|
|
func TestWriterAppendWithResultReportsMissingFieldsAndDoesNotTouchSource(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(),
|
|
Parsed: map[string]any{
|
|
"location": map[string]any{"total_mileage_km": 4123.9},
|
|
},
|
|
}
|
|
|
|
result, err := writer.AppendWithResult(context.Background(), event)
|
|
if err != nil {
|
|
t.Fatalf("AppendWithResult() error = %v", err)
|
|
}
|
|
if result.SamplesSkippedMissingFields != 1 || result.SamplesSkippedMissingMileage != 0 || result.SamplesWritten != 0 {
|
|
t.Fatalf("result = %+v, want missing fields only", result)
|
|
}
|
|
if result.SourceTouchesAttempted != 0 || result.SourceTouchesWritten != 0 || result.SourceTouchesSkippedMissing != 0 {
|
|
t.Fatalf("source touch result = %+v, want no source touch without fields", result)
|
|
}
|
|
if len(exec.calls) != 0 {
|
|
t.Fatalf("exec calls = %d, want 0 for missing fields", len(exec.calls))
|
|
}
|
|
}
|
|
|
|
func TestSamplesFromEnvelopeUsesFieldsOnly(t *testing.T) {
|
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
|
_, result, err := samplesFromEnvelopeWithResult(envelope.FrameEnvelope{
|
|
Protocol: envelope.ProtocolJT808,
|
|
VIN: "LA9GG64L7PBAF4001",
|
|
EventTimeMS: time.Date(2026, 7, 8, 13, 20, 0, 0, loc).UnixMilli(),
|
|
Parsed: map[string]any{
|
|
"location": map[string]any{"total_mileage_km": 4123.9},
|
|
},
|
|
}, loc)
|
|
if err != nil {
|
|
t.Fatalf("samplesFromEnvelopeWithResult() error = %v", err)
|
|
}
|
|
if result.SamplesSkippedMissingFields != 1 || result.SamplesFound != 0 {
|
|
t.Fatalf("result = %+v, want missing fields and no extracted samples", result)
|
|
}
|
|
}
|
|
|
|
func TestWriterAppendDedupesRealtimeMileagePerSource(t *testing.T) {
|
|
exec := &recordingExec{}
|
|
writer := NewWriter(exec, time.FixedZone("Asia/Shanghai", 8*3600))
|
|
base := managedTestSource(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 TestWriterAppendCollapsesDirectSourceAcrossChangingIPs(t *testing.T) {
|
|
exec := &recordingExec{}
|
|
writer := NewWriter(exec, time.FixedZone("Asia/Shanghai", 8*3600))
|
|
base := envelope.FrameEnvelope{
|
|
Protocol: envelope.ProtocolJT808,
|
|
VIN: "LA9GG64L7PBAF4001",
|
|
Phone: "13307765812",
|
|
SourceKind: "DIRECT",
|
|
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,
|
|
},
|
|
}
|
|
|
|
first := base
|
|
first.SourceEndpoint = "115.231.168.135:20215"
|
|
if err := writer.Append(context.Background(), first); err != nil {
|
|
t.Fatalf("Append(first) error = %v", err)
|
|
}
|
|
|
|
second := base
|
|
second.SourceEndpoint = "39.144.3.22:60177"
|
|
second.ReceivedAtMS = second.EventTimeMS + 1000
|
|
if err := writer.Append(context.Background(), second); err != nil {
|
|
t.Fatalf("Append(second) error = %v", err)
|
|
}
|
|
|
|
var sourceKeys []string
|
|
for _, call := range exec.calls {
|
|
if strings.Contains(call.query, "INSERT INTO vehicle_daily_mileage_source") && len(call.args) > 3 {
|
|
sourceKeys = append(sourceKeys, call.args[3].(string))
|
|
}
|
|
}
|
|
if len(sourceKeys) != 1 {
|
|
t.Fatalf("source mileage upserts = %d, want one because identical mileage from same direct device should be deduplicated: %#v", len(sourceKeys), sourceKeys)
|
|
}
|
|
if sourceKeys[0] != "JT808:13307765812@DIRECT" {
|
|
t.Fatalf("source key = %q", sourceKeys[0])
|
|
}
|
|
}
|
|
|
|
func TestWriterAppendCollapsesPlatformSourceAcrossChangingIPs(t *testing.T) {
|
|
exec := &recordingExec{}
|
|
writer := NewWriter(exec, time.FixedZone("Asia/Shanghai", 8*3600))
|
|
base := envelope.FrameEnvelope{
|
|
Protocol: envelope.ProtocolJT808,
|
|
VIN: "LNXNEGRR0SR321372",
|
|
Phone: "41456413943",
|
|
SourceCode: "guangan_beidou",
|
|
PlatformName: "广安北斗",
|
|
SourceKind: "PLATFORM",
|
|
EventTimeMS: time.Date(2026, 7, 12, 8, 40, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).UnixMilli(),
|
|
Fields: map[string]any{
|
|
"jt808.location.total_mileage_km": 46484.2,
|
|
},
|
|
}
|
|
|
|
first := base
|
|
first.SourceEndpoint = "117.132.194.167:9806"
|
|
if err := writer.Append(context.Background(), first); err != nil {
|
|
t.Fatalf("Append(first) error = %v", err)
|
|
}
|
|
|
|
second := base
|
|
second.SourceEndpoint = "117.132.198.90:12084"
|
|
second.EventTimeMS = second.EventTimeMS + 1000
|
|
second.Fields = map[string]any{"jt808.location.total_mileage_km": 46484.3}
|
|
if err := writer.Append(context.Background(), second); err != nil {
|
|
t.Fatalf("Append(second) error = %v", err)
|
|
}
|
|
|
|
var sourceKeys []string
|
|
for _, call := range exec.calls {
|
|
if strings.Contains(call.query, "INSERT INTO vehicle_daily_mileage_source") && len(call.args) > 3 {
|
|
sourceKeys = append(sourceKeys, call.args[3].(string))
|
|
}
|
|
}
|
|
if len(sourceKeys) != 2 {
|
|
t.Fatalf("source mileage upserts = %d, want both changing mileage samples written: %#v", len(sourceKeys), sourceKeys)
|
|
}
|
|
for _, sourceKey := range sourceKeys {
|
|
if sourceKey != "JT808:41456413943@PLATFORM:guangan_beidou" {
|
|
t.Fatalf("source key = %q", sourceKey)
|
|
}
|
|
}
|
|
if got := countExecQueries(exec.calls, "INSERT INTO vehicle_data_source"); got != 2 {
|
|
t.Fatalf("source touch upserts = %d, want both platform IPs still observed", got)
|
|
}
|
|
}
|
|
|
|
func TestWriterAppendThrottlesDailyProjectionPerVehicleProtocolDate(t *testing.T) {
|
|
exec := &recordingExec{}
|
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
|
writer := NewWriter(exec, loc)
|
|
base := managedTestSource(envelope.FrameEnvelope{
|
|
Protocol: envelope.ProtocolJT808,
|
|
VIN: "LA9GG64L7PBAF4001",
|
|
Phone: "13307765812",
|
|
SourceEndpoint: "115.231.168.135:20215",
|
|
Fields: map[string]any{
|
|
"jt808.location.total_mileage_km": 4123.9,
|
|
},
|
|
})
|
|
|
|
first := base
|
|
first.EventTimeMS = time.Date(2026, 7, 8, 13, 20, 0, 0, loc).UnixMilli()
|
|
if err := writer.Append(context.Background(), first); err != nil {
|
|
t.Fatalf("first Append() error = %v", err)
|
|
}
|
|
second := base
|
|
second.EventTimeMS = time.Date(2026, 7, 8, 13, 20, 5, 0, loc).UnixMilli()
|
|
second.Fields = map[string]any{"jt808.location.total_mileage_km": 4124.2}
|
|
if err := writer.Append(context.Background(), second); err != nil {
|
|
t.Fatalf("second Append() error = %v", err)
|
|
}
|
|
third := base
|
|
third.EventTimeMS = time.Date(2026, 7, 8, 13, 20, 16, 0, loc).UnixMilli()
|
|
third.Fields = map[string]any{"jt808.location.total_mileage_km": 4125.0}
|
|
if err := writer.Append(context.Background(), third); err != nil {
|
|
t.Fatalf("third Append() error = %v", err)
|
|
}
|
|
|
|
if got := countExecQueries(exec.calls, "INSERT INTO vehicle_daily_mileage_source"); got != 3 {
|
|
t.Fatalf("source candidate upserts = %d, want 3", got)
|
|
}
|
|
if got := countExecQueries(exec.calls, "INSERT INTO vehicle_daily_mileage\n"); got != 2 {
|
|
t.Fatalf("daily projections = %d, want 2", got)
|
|
}
|
|
}
|
|
|
|
func TestWriterFlushesFinalProjectionWhenVehicleStopsInsideThrottleWindow(t *testing.T) {
|
|
exec := &recordingExec{}
|
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
|
writer := NewWriter(exec, loc)
|
|
base := managedTestSource(envelope.FrameEnvelope{
|
|
Protocol: envelope.ProtocolJT808,
|
|
VIN: "LMRKH9AC7R1004120",
|
|
Phone: "64341233712",
|
|
SourceEndpoint: "115.159.85.149:7339",
|
|
Fields: map[string]any{
|
|
"jt808.location.total_mileage_km": 4732.6,
|
|
},
|
|
})
|
|
|
|
first := base
|
|
first.EventTimeMS = time.Date(2026, 7, 19, 15, 4, 45, 0, loc).UnixMilli()
|
|
if err := writer.Append(context.Background(), first); err != nil {
|
|
t.Fatalf("first Append() error = %v", err)
|
|
}
|
|
final := base
|
|
final.EventTimeMS = time.Date(2026, 7, 19, 15, 4, 52, 0, loc).UnixMilli()
|
|
final.Fields = map[string]any{"jt808.location.total_mileage_km": 4740.7}
|
|
if err := writer.Append(context.Background(), final); err != nil {
|
|
t.Fatalf("final Append() error = %v", err)
|
|
}
|
|
|
|
if got := writer.CacheStats().PendingProjectionEntries; got != 1 {
|
|
t.Fatalf("pending projections = %d, want final throttled sample queued", got)
|
|
}
|
|
beforeDue, err := writer.FlushPendingProjections(context.Background(), time.Now().Add(-time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("early FlushPendingProjections() error = %v", err)
|
|
}
|
|
if beforeDue.Attempted != 0 || beforeDue.Written != 0 {
|
|
t.Fatalf("early flush result = %+v, want no eligible projection", beforeDue)
|
|
}
|
|
|
|
flushed, err := writer.FlushPendingProjections(context.Background(), time.Time{})
|
|
if err != nil {
|
|
t.Fatalf("FlushPendingProjections() error = %v", err)
|
|
}
|
|
if flushed.Attempted != 1 || flushed.Written != 1 {
|
|
t.Fatalf("flush result = %+v, want final projection written", flushed)
|
|
}
|
|
if got := countExecQueries(exec.calls, "INSERT INTO vehicle_daily_mileage\n"); got != 2 {
|
|
t.Fatalf("daily projections = %d, want initial plus eventual final projection", got)
|
|
}
|
|
if got := writer.CacheStats().PendingProjectionEntries; got != 0 {
|
|
t.Fatalf("pending projections after flush = %d, want 0", got)
|
|
}
|
|
key := projectionCacheKey(MetricSample{
|
|
VIN: "LMRKH9AC7R1004120",
|
|
Protocol: envelope.ProtocolJT808,
|
|
StatDate: "2026-07-19",
|
|
})
|
|
wantProjectedAt := time.UnixMilli(final.EventTimeMS).In(loc)
|
|
if got := writer.lastProjection[key].projectedAt; !got.Equal(wantProjectedAt) {
|
|
t.Fatalf("last projected event = %s, want %s", got, wantProjectedAt)
|
|
}
|
|
}
|
|
|
|
func TestWriterPendingProjectionFailureDoesNotBlockOtherVehicles(t *testing.T) {
|
|
exec := &selectiveFailingExec{failVIN: "FAIL-VIN"}
|
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
|
writer := NewWriter(exec, loc)
|
|
writer.projectionInterval = time.Hour
|
|
|
|
for _, vin := range []string{"FAIL-VIN", "OK-VIN"} {
|
|
sample := MetricSample{
|
|
VIN: vin,
|
|
Protocol: envelope.ProtocolJT808,
|
|
StatDate: "2026-07-19",
|
|
SourceKey: "jt808:test",
|
|
EventTime: time.Date(2026, 7, 19, 15, 4, 52, 0, loc),
|
|
TotalMileageKM: 100,
|
|
}
|
|
writer.markProjectionPending(sample, time.Now().Add(-time.Hour))
|
|
}
|
|
|
|
result, err := writer.FlushPendingProjections(context.Background(), time.Time{})
|
|
if err == nil {
|
|
t.Fatal("FlushPendingProjections() error = nil, want failed vehicle reported")
|
|
}
|
|
if result.Attempted != 2 || result.Written != 1 || result.Failed != 1 {
|
|
t.Fatalf("flush result = %+v, want one failure and one successful projection", result)
|
|
}
|
|
if got := writer.CacheStats().PendingProjectionEntries; got != 1 {
|
|
t.Fatalf("pending projections after partial failure = %d, want only failed vehicle retained", got)
|
|
}
|
|
}
|
|
|
|
func TestWriterReconcilesDurableProjectionAfterPendingQueueIsLost(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()
|
|
|
|
writer := NewWriter(db, time.FixedZone("Asia/Shanghai", 8*3600))
|
|
vin := "LMRKH9AC7R1004120"
|
|
statDate := "2026-07-19"
|
|
protocol := envelope.ProtocolJT808
|
|
mock.ExpectQuery(staleDailyMileageProjectionTargetsSQL).
|
|
WithArgs(statDate).
|
|
WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol"}).AddRow(vin, string(protocol)))
|
|
expectProjectDailyMileageSQL(mock, vin, statDate, protocol)
|
|
mock.ExpectCommit()
|
|
|
|
result, err := writer.ReconcileDateProjections(context.Background(), statDate)
|
|
if err != nil {
|
|
t.Fatalf("ReconcileDateProjections() error = %v", err)
|
|
}
|
|
if result.Targets != 1 || result.Attempted != 1 || result.Written != 1 || result.Failed != 0 {
|
|
t.Fatalf("reconcile result = %+v, want one recovered projection", result)
|
|
}
|
|
if got := writer.CacheStats().PendingProjectionEntries; got != 0 {
|
|
t.Fatalf("startup reconciliation should not depend on pending memory, got %d entries", got)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("sql expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWriterProjectionReconciliationRequiresQueryableStore(t *testing.T) {
|
|
writer := NewWriter(&recordingExec{}, time.FixedZone("Asia/Shanghai", 8*3600))
|
|
|
|
result, err := writer.ReconcileDateProjections(context.Background(), "2026-07-19")
|
|
|
|
if err == nil || !strings.Contains(err.Error(), "query support") {
|
|
t.Fatalf("ReconcileDateProjections() error = %v, want query support failure", err)
|
|
}
|
|
if result != (ProjectionReconcileResult{}) {
|
|
t.Fatalf("reconcile result = %+v, want empty", result)
|
|
}
|
|
}
|
|
|
|
func TestWriterAppendProjectsImmediatelyForNewSource(t *testing.T) {
|
|
exec := &recordingExec{}
|
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
|
writer := NewWriter(exec, loc)
|
|
base := managedTestSource(envelope.FrameEnvelope{
|
|
Protocol: envelope.ProtocolJT808,
|
|
VIN: "LA9GG64L7PBAF4001",
|
|
Phone: "13307765812",
|
|
EventTimeMS: time.Date(2026, 7, 8, 13, 20, 0, 0, loc).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.Fields = map[string]any{"jt808.location.total_mileage_km": 4124.0}
|
|
if err := writer.Append(context.Background(), sourceB); err != nil {
|
|
t.Fatalf("Append(sourceB) error = %v", err)
|
|
}
|
|
|
|
if got := countExecQueries(exec.calls, "INSERT INTO vehicle_daily_mileage\n"); got != 2 {
|
|
t.Fatalf("daily projections = %d, want immediate projection for each new source", got)
|
|
}
|
|
}
|
|
|
|
func TestWriterProjectionIntervalZeroKeepsLegacyPerSampleProjection(t *testing.T) {
|
|
exec := &recordingExec{}
|
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
|
writer := NewWriter(exec, loc)
|
|
writer.SetProjectionInterval(0)
|
|
base := managedTestSource(envelope.FrameEnvelope{
|
|
Protocol: envelope.ProtocolJT808,
|
|
VIN: "LA9GG64L7PBAF4001",
|
|
Phone: "13307765812",
|
|
SourceEndpoint: "115.231.168.135:20215",
|
|
Fields: map[string]any{
|
|
"jt808.location.total_mileage_km": 4123.9,
|
|
},
|
|
})
|
|
|
|
for i, mileage := range []float64{4123.9, 4124.0} {
|
|
event := base
|
|
event.EventTimeMS = time.Date(2026, 7, 8, 13, 20, i, 0, loc).UnixMilli()
|
|
event.Fields = map[string]any{"jt808.location.total_mileage_km": mileage}
|
|
if err := writer.Append(context.Background(), event); err != nil {
|
|
t.Fatalf("Append(%d) error = %v", i, err)
|
|
}
|
|
}
|
|
|
|
if got := countExecQueries(exec.calls, "INSERT INTO vehicle_daily_mileage\n"); got != 2 {
|
|
t.Fatalf("daily projections = %d, want 2", got)
|
|
}
|
|
}
|
|
|
|
func TestWriterAppendUsesCurrentSampleWhenNoHistoricalBaseline(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 := managedTestSource(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(), sqlmock.AnyArg(), 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-08", "JT808", "JT808:13307765812@115.231.168.135").
|
|
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}))
|
|
mock.ExpectBegin()
|
|
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,
|
|
QualityOK,
|
|
QualityReasonCurrentDayFirst,
|
|
).
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage`).
|
|
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", int64(maxSelectedDailyMileageKM)).
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
mock.ExpectExec(`UPDATE vehicle_daily_mileage_source s`).
|
|
WithArgs(
|
|
"LA9GG64L7PBAF4001",
|
|
"2026-07-08",
|
|
"JT808",
|
|
int64(maxSelectedDailyMileageKM),
|
|
"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))
|
|
mock.ExpectCommit()
|
|
|
|
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 TestWriterCachesMissingBaselineAfterSuccessfulFirstSample(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)
|
|
firstTime := time.Date(2026, 7, 8, 13, 20, 0, 0, loc)
|
|
secondTime := firstTime.Add(5 * time.Second)
|
|
base := managedTestSource(envelope.FrameEnvelope{
|
|
Protocol: envelope.ProtocolJT808,
|
|
VIN: "LA9GG64L7PBAF4001",
|
|
Phone: "13307765812",
|
|
SourceEndpoint: "115.231.168.135:20215",
|
|
})
|
|
|
|
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.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`).
|
|
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", "JT808:13307765812@115.231.168.135").
|
|
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}))
|
|
mock.ExpectBegin()
|
|
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),
|
|
firstTime,
|
|
firstTime,
|
|
QualityOK,
|
|
QualityReasonCurrentDayFirst,
|
|
).
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage`).
|
|
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", int64(maxSelectedDailyMileageKM)).
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
mock.ExpectExec(`UPDATE vehicle_daily_mileage_source s`).
|
|
WithArgs(
|
|
"LA9GG64L7PBAF4001",
|
|
"2026-07-08",
|
|
"JT808",
|
|
int64(maxSelectedDailyMileageKM),
|
|
"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))
|
|
mock.ExpectCommit()
|
|
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(4124.4),
|
|
approxFloat64{want: 0.5, tolerance: 0.000001},
|
|
int64(1),
|
|
firstTime,
|
|
secondTime,
|
|
QualityOK,
|
|
QualityReasonCurrentDayFirst,
|
|
).
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
|
|
first := base
|
|
first.EventTimeMS = firstTime.UnixMilli()
|
|
first.Fields = map[string]any{"jt808.location.total_mileage_km": 4123.9}
|
|
if err := writer.Append(context.Background(), first); err != nil {
|
|
t.Fatalf("first Append() error = %v", err)
|
|
}
|
|
second := base
|
|
second.EventTimeMS = secondTime.UnixMilli()
|
|
second.Fields = map[string]any{"jt808.location.total_mileage_km": 4124.4}
|
|
if err := writer.Append(context.Background(), second); err != nil {
|
|
t.Fatalf("second Append() error = %v", err)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("sql expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWriterRetriesExpiredMissingPreviousDayBaseline(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)
|
|
writer.SetBaselineMissTTL(0)
|
|
candidate := SourceMileageSample{
|
|
VIN: "LA9GG64L7PBAF4001",
|
|
StatDate: "2026-07-13",
|
|
Protocol: envelope.ProtocolJT808,
|
|
SourceKey: "JT808:64013036430@PLATFORM:g7s",
|
|
}
|
|
mock.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`).
|
|
WithArgs(candidate.VIN, candidate.StatDate, "JT808", candidate.SourceKey).
|
|
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}))
|
|
|
|
if _, found, err := writer.previousBaseline(context.Background(), candidate); err != nil || found {
|
|
t.Fatalf("first previousBaseline() found=%v error=%v, want cacheable miss", found, err)
|
|
}
|
|
|
|
previousTime := time.Date(2026, 7, 12, 23, 58, 0, 0, loc)
|
|
mock.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`).
|
|
WithArgs(candidate.VIN, candidate.StatDate, "JT808", candidate.SourceKey).
|
|
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}).AddRow(42567.9, previousTime))
|
|
|
|
baseline, found, err := writer.previousBaseline(context.Background(), candidate)
|
|
if err != nil || !found {
|
|
t.Fatalf("second previousBaseline() found=%v error=%v, want recovered baseline", found, err)
|
|
}
|
|
if baseline.LatestTotalKM != 42567.9 || !baseline.LatestEventTime.Equal(previousTime) {
|
|
t.Fatalf("baseline = %+v, want previous natural-day last sample", baseline)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("sql expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWriterRefreshesExpiredFoundPreviousDayBaseline(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)
|
|
writer.SetBaselineHitTTL(time.Minute)
|
|
candidate := SourceMileageSample{
|
|
VIN: "LMRKH9AC6R1004111",
|
|
StatDate: "2026-07-17",
|
|
Protocol: envelope.ProtocolYutongMQTT,
|
|
SourceKey: "YUTONG_MQTT:LMRKH9AC6R1004111@PLATFORM:yutong",
|
|
}
|
|
cacheKey := mileageCacheKey(MetricSample{
|
|
VIN: candidate.VIN,
|
|
Protocol: candidate.Protocol,
|
|
StatDate: candidate.StatDate,
|
|
SourceKey: candidate.SourceKey,
|
|
})
|
|
staleTime := time.Date(2026, 7, 12, 4, 12, 20, 0, loc)
|
|
writer.baselineCache[cacheKey] = sourceBaselineCacheEntry{
|
|
baseline: sourceBaseline{LatestTotalKM: 90778, LatestEventTime: staleTime},
|
|
found: true,
|
|
cachedAt: time.Now().Add(-2 * time.Minute),
|
|
}
|
|
|
|
refreshedTime := time.Date(2026, 7, 16, 23, 59, 59, 0, loc)
|
|
mock.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`).
|
|
WithArgs(candidate.VIN, candidate.StatDate, "YUTONG_MQTT", candidate.SourceKey).
|
|
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}).
|
|
AddRow(91302.0, refreshedTime))
|
|
|
|
baseline, found, err := writer.previousBaseline(context.Background(), candidate)
|
|
if err != nil || !found {
|
|
t.Fatalf("previousBaseline() found=%v error=%v, want refreshed baseline", found, err)
|
|
}
|
|
if baseline.LatestTotalKM != 91302 || !baseline.LatestEventTime.Equal(refreshedTime) {
|
|
t.Fatalf("baseline = %+v, want nearest refreshed baseline", baseline)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("sql expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWriterMarkBaselineKeepsCacheAgeForUnchangedBoundary(t *testing.T) {
|
|
db, _, 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)
|
|
candidate := SourceMileageSample{
|
|
VIN: "LMRKH9AC6R1004111",
|
|
StatDate: "2026-07-17",
|
|
Protocol: envelope.ProtocolYutongMQTT,
|
|
SourceKey: "YUTONG_MQTT:LMRKH9AC6R1004111@PLATFORM:yutong",
|
|
FirstTotalKM: 91302,
|
|
FirstEventTime: time.Date(2026, 7, 16, 23, 59, 59, 0, loc),
|
|
}
|
|
sample := MetricSample{
|
|
VIN: candidate.VIN,
|
|
Protocol: candidate.Protocol,
|
|
StatDate: candidate.StatDate,
|
|
SourceKey: candidate.SourceKey,
|
|
}
|
|
cacheKey := mileageCacheKey(sample)
|
|
cachedAt := time.Now().Add(-4 * time.Minute)
|
|
writer.baselineCache[cacheKey] = sourceBaselineCacheEntry{
|
|
baseline: sourceBaseline{LatestTotalKM: candidate.FirstTotalKM, LatestEventTime: candidate.FirstEventTime},
|
|
found: true,
|
|
cachedAt: cachedAt,
|
|
}
|
|
|
|
writer.markBaselineWritten(sample, candidate)
|
|
|
|
if got := writer.baselineCache[cacheKey].cachedAt; !got.Equal(cachedAt) {
|
|
t.Fatalf("cachedAt = %s, want unchanged %s", got, cachedAt)
|
|
}
|
|
}
|
|
|
|
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 := managedTestSource(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(), sqlmock.AnyArg(), 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-08", "JT808", "JT808:13307765812@115.231.168.135").
|
|
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}).
|
|
AddRow(4100.8, previousTime))
|
|
mock.ExpectBegin()
|
|
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,
|
|
QualityReasonHistorical,
|
|
).
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage`).
|
|
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", int64(maxSelectedDailyMileageKM)).
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
mock.ExpectExec(`UPDATE vehicle_daily_mileage_source s`).
|
|
WithArgs(
|
|
"LA9GG64L7PBAF4001",
|
|
"2026-07-08",
|
|
"JT808",
|
|
int64(maxSelectedDailyMileageKM),
|
|
"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))
|
|
mock.ExpectCommit()
|
|
|
|
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 TestWriterAppendUsesOlderHistoricalSourceBaseline(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)
|
|
historicalTime := time.Date(2026, 7, 4, 23, 58, 0, 0, loc)
|
|
currentTime := time.Date(2026, 7, 8, 13, 20, 0, 0, loc)
|
|
event := managedTestSource(envelope.FrameEnvelope{
|
|
Protocol: envelope.ProtocolYutongMQTT,
|
|
VIN: "LMRKH9AC2R1004087",
|
|
DeviceID: "LMRKH9AC2R1004087",
|
|
SourceEndpoint: "mqtt://yutong/ytforward/shln/3",
|
|
EventTimeMS: currentTime.UnixMilli(),
|
|
Fields: map[string]any{
|
|
"yutong_mqtt.data.total_mileage": 120788000,
|
|
},
|
|
})
|
|
|
|
mock.ExpectExec(`INSERT INTO vehicle_data_source`).
|
|
WithArgs("YUTONG_MQTT", "mqtt", "mqtt://yutong/ytforward/shln/3", sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), 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("LMRKH9AC2R1004087", "2026-07-08", "YUTONG_MQTT", "YUTONG_MQTT:LMRKH9AC2R1004087@mqtt").
|
|
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}).
|
|
AddRow(120672.0, historicalTime))
|
|
mock.ExpectBegin()
|
|
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage_source`).
|
|
WithArgs(
|
|
"LMRKH9AC2R1004087",
|
|
"2026-07-08",
|
|
"YUTONG_MQTT",
|
|
"YUTONG_MQTT:LMRKH9AC2R1004087@mqtt",
|
|
"mqtt",
|
|
"mqtt://yutong/ytforward/shln/3",
|
|
"",
|
|
"LMRKH9AC2R1004087",
|
|
"",
|
|
float64(120672.0),
|
|
float64(120788.0),
|
|
approxFloat64{want: 116.0, tolerance: 0.000001},
|
|
int64(1),
|
|
historicalTime,
|
|
currentTime,
|
|
QualityOK,
|
|
QualityReasonHistorical,
|
|
).
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage`).
|
|
WithArgs("LMRKH9AC2R1004087", "2026-07-08", "YUTONG_MQTT", int64(maxSelectedDailyMileageKM)).
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
mock.ExpectExec(`UPDATE vehicle_daily_mileage_source s`).
|
|
WithArgs(
|
|
"LMRKH9AC2R1004087",
|
|
"2026-07-08",
|
|
"YUTONG_MQTT",
|
|
int64(maxSelectedDailyMileageKM),
|
|
"LMRKH9AC2R1004087",
|
|
"2026-07-08",
|
|
"YUTONG_MQTT",
|
|
).
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
mock.ExpectExec(`DELETE FROM vehicle_daily_mileage`).
|
|
WithArgs(
|
|
"LMRKH9AC2R1004087",
|
|
"2026-07-08",
|
|
"YUTONG_MQTT",
|
|
"LMRKH9AC2R1004087",
|
|
"2026-07-08",
|
|
"YUTONG_MQTT",
|
|
).
|
|
WillReturnResult(sqlmock.NewResult(0, 0))
|
|
mock.ExpectCommit()
|
|
|
|
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 TestWriterApplyRealtimeBaselineClampsSmallNegativeMileageJitter(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)
|
|
baselineTime := time.Date(2026, 7, 11, 19, 32, 20, 0, loc)
|
|
currentTime := time.Date(2026, 7, 12, 2, 38, 23, 0, loc)
|
|
candidate := SourceMileageSample{
|
|
VIN: "LB9A32A28R0LS1574",
|
|
StatDate: "2026-07-12",
|
|
Protocol: envelope.ProtocolJT808,
|
|
SourceKey: "JT808:64115156034@115.159.85.149",
|
|
SourceIP: "115.159.85.149",
|
|
LatestTotalKM: 15355.3,
|
|
LatestEventTime: currentTime,
|
|
}
|
|
|
|
mock.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`).
|
|
WithArgs("LB9A32A28R0LS1574", "2026-07-12", "JT808", "JT808:64115156034@115.159.85.149").
|
|
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}).
|
|
AddRow(15355.4, baselineTime))
|
|
|
|
if err := writer.applyRealtimeBaseline(context.Background(), &candidate); err != nil {
|
|
t.Fatalf("applyRealtimeBaseline() error = %v", err)
|
|
}
|
|
if candidate.QualityStatus != QualityOK || candidate.QualityReason != "negative_jitter_clamped" {
|
|
t.Fatalf("quality = %s/%s", candidate.QualityStatus, candidate.QualityReason)
|
|
}
|
|
if candidate.DailyKM != 0 {
|
|
t.Fatalf("daily km = %v, want 0", candidate.DailyKM)
|
|
}
|
|
if candidate.FirstTotalKM != 15355.4 || !candidate.FirstEventTime.Equal(baselineTime) {
|
|
t.Fatalf("baseline not preserved: %#v", candidate)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("sql expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWriterApplyRealtimeBaselineKeepsNegativeHistoricalDeltaInvalid(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)
|
|
baselineTime := time.Date(2026, 7, 11, 19, 32, 20, 0, loc)
|
|
currentTime := time.Date(2026, 7, 12, 2, 38, 23, 0, loc)
|
|
candidate := SourceMileageSample{
|
|
VIN: "LB9A32A28R0LS1574",
|
|
StatDate: "2026-07-12",
|
|
Protocol: envelope.ProtocolJT808,
|
|
SourceKey: "JT808:64115156034@115.159.85.149",
|
|
SourceIP: "115.159.85.149",
|
|
LatestTotalKM: 15300.0,
|
|
LatestEventTime: currentTime,
|
|
}
|
|
|
|
mock.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`).
|
|
WithArgs("LB9A32A28R0LS1574", "2026-07-12", "JT808", "JT808:64115156034@115.159.85.149").
|
|
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}).
|
|
AddRow(15355.4, baselineTime))
|
|
|
|
if err := writer.applyRealtimeBaseline(context.Background(), &candidate); err != nil {
|
|
t.Fatalf("applyRealtimeBaseline() error = %v", err)
|
|
}
|
|
if candidate.QualityStatus != QualityInvalidDelta || candidate.QualityReason != "outside_daily_range" {
|
|
t.Fatalf("quality = %s/%s", candidate.QualityStatus, candidate.QualityReason)
|
|
}
|
|
if candidate.DailyKM > -55 || candidate.DailyKM < -56 {
|
|
t.Fatalf("daily km = %v, want large negative delta", candidate.DailyKM)
|
|
}
|
|
if candidate.FirstTotalKM != 15355.4 || !candidate.FirstEventTime.Equal(baselineTime) {
|
|
t.Fatalf("previous-day baseline not preserved: %#v", candidate)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("sql expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWriterApplyRealtimeBaselineKeepsInvalidWhenCurrentDayBaselineIsStillHigher(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, 11, 19, 32, 20, 0, loc)
|
|
currentTime := time.Date(2026, 7, 12, 2, 38, 23, 0, loc)
|
|
candidate := SourceMileageSample{
|
|
VIN: "LB9A32A28R0LS1574",
|
|
StatDate: "2026-07-12",
|
|
Protocol: envelope.ProtocolJT808,
|
|
SourceKey: "JT808:64115156034@115.159.85.149",
|
|
SourceIP: "115.159.85.149",
|
|
LatestTotalKM: 15300.0,
|
|
LatestEventTime: currentTime,
|
|
}
|
|
|
|
mock.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`).
|
|
WithArgs("LB9A32A28R0LS1574", "2026-07-12", "JT808", "JT808:64115156034@115.159.85.149").
|
|
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}).
|
|
AddRow(15355.4, previousTime))
|
|
|
|
if err := writer.applyRealtimeBaseline(context.Background(), &candidate); err != nil {
|
|
t.Fatalf("applyRealtimeBaseline() error = %v", err)
|
|
}
|
|
if candidate.QualityStatus != QualityInvalidDelta || candidate.QualityReason != "outside_daily_range" {
|
|
t.Fatalf("quality = %s/%s", candidate.QualityStatus, candidate.QualityReason)
|
|
}
|
|
if candidate.DailyKM > -55 || candidate.DailyKM < -56 {
|
|
t.Fatalf("daily km = %v, want large negative delta", candidate.DailyKM)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("sql expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWriterApplyRealtimeBaselineRejectsNegativeDeltaEvenWithLowerCurrentDaySample(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, 11, 19, 32, 20, 0, loc)
|
|
currentTime := time.Date(2026, 7, 12, 2, 38, 23, 0, loc)
|
|
candidate := SourceMileageSample{
|
|
VIN: "LB9A32A28R0LS1574",
|
|
StatDate: "2026-07-12",
|
|
Protocol: envelope.ProtocolJT808,
|
|
SourceKey: "JT808:64115156034@115.159.85.149",
|
|
SourceIP: "115.159.85.149",
|
|
LatestTotalKM: 15300.0,
|
|
LatestEventTime: currentTime,
|
|
}
|
|
|
|
mock.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`).
|
|
WithArgs("LB9A32A28R0LS1574", "2026-07-12", "JT808", "JT808:64115156034@115.159.85.149").
|
|
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}).
|
|
AddRow(15355.4, previousTime))
|
|
|
|
if err := writer.applyRealtimeBaseline(context.Background(), &candidate); err != nil {
|
|
t.Fatalf("applyRealtimeBaseline() error = %v", err)
|
|
}
|
|
if candidate.QualityStatus != QualityInvalidDelta || candidate.QualityReason != "outside_daily_range" {
|
|
t.Fatalf("quality = %s/%s", candidate.QualityStatus, candidate.QualityReason)
|
|
}
|
|
if candidate.DailyKM > -55 || candidate.DailyKM < -56 {
|
|
t.Fatalf("daily km = %v, want large negative delta", candidate.DailyKM)
|
|
}
|
|
if candidate.FirstTotalKM != 15355.4 || !candidate.FirstEventTime.Equal(previousTime) {
|
|
t.Fatalf("previous-day baseline not preserved: %#v", candidate)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("sql expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWriterApplyRealtimeBaselineAcceptsPlausibleMultiDayFallbackDelta(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)
|
|
baselineTime := time.Date(2026, 7, 4, 11, 7, 58, 0, loc)
|
|
currentTime := time.Date(2026, 7, 12, 2, 52, 47, 0, loc)
|
|
candidate := SourceMileageSample{
|
|
VIN: "LNXNEGRR1SR319498",
|
|
StatDate: "2026-07-12",
|
|
Protocol: envelope.ProtocolGB32960,
|
|
SourceKey: "GB32960:unknown@8.134.95.166",
|
|
SourceIP: "8.134.95.166",
|
|
LatestTotalKM: 16665.6,
|
|
LatestEventTime: currentTime,
|
|
}
|
|
|
|
mock.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`).
|
|
WithArgs("LNXNEGRR1SR319498", "2026-07-12", "GB32960", "GB32960:unknown@8.134.95.166").
|
|
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}).
|
|
AddRow(8832.1, baselineTime))
|
|
|
|
if err := writer.applyRealtimeBaseline(context.Background(), &candidate); err != nil {
|
|
t.Fatalf("applyRealtimeBaseline() error = %v", err)
|
|
}
|
|
if candidate.QualityStatus != QualityOK || candidate.QualityReason != QualityReasonHistorical {
|
|
t.Fatalf("quality = %s/%s", candidate.QualityStatus, candidate.QualityReason)
|
|
}
|
|
if candidate.DailyKM < 7833.4 || candidate.DailyKM > 7833.6 {
|
|
t.Fatalf("daily km = %v, want multi-day gap delta", candidate.DailyKM)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("sql expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWriterApplyRealtimeBaselineRejectsHistoricalBaselineJump(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)
|
|
baselineTime := time.Date(2026, 7, 3, 19, 0, 39, 0, loc)
|
|
currentTime := time.Date(2026, 7, 12, 10, 44, 56, 0, loc)
|
|
candidate := SourceMileageSample{
|
|
VIN: "LNXNEGRR6SR319464",
|
|
StatDate: "2026-07-12",
|
|
Protocol: envelope.ProtocolGB32960,
|
|
SourceKey: "GB32960:unknown@8.134.95.166",
|
|
SourceIP: "8.134.95.166",
|
|
LatestTotalKM: 40009.7,
|
|
LatestEventTime: currentTime,
|
|
}
|
|
|
|
mock.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`).
|
|
WithArgs("LNXNEGRR6SR319464", "2026-07-12", "GB32960", "GB32960:unknown@8.134.95.166").
|
|
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}).
|
|
AddRow(10009.7, baselineTime))
|
|
|
|
if err := writer.applyRealtimeBaseline(context.Background(), &candidate); err != nil {
|
|
t.Fatalf("applyRealtimeBaseline() error = %v", err)
|
|
}
|
|
if candidate.QualityStatus != QualityInvalidDelta || candidate.QualityReason != "outside_daily_range" {
|
|
t.Fatalf("quality = %s/%s", candidate.QualityStatus, candidate.QualityReason)
|
|
}
|
|
if candidate.DailyKM < 29999.9 || candidate.DailyKM > 30000.1 {
|
|
t.Fatalf("daily km = %v, want historical jump delta", candidate.DailyKM)
|
|
}
|
|
if candidate.FirstTotalKM != 10009.7 || !candidate.FirstEventTime.Equal(baselineTime) {
|
|
t.Fatalf("previous-day baseline not preserved: %#v", candidate)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("sql expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWriterAppendMarksCandidateNoPreviousBaselineWhenPreviousBaselineMissing(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)
|
|
currentTime := time.Date(2026, 7, 8, 15, 56, 0, 0, loc)
|
|
event := managedTestSource(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": 4136.8,
|
|
},
|
|
})
|
|
|
|
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.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`).
|
|
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", "JT808:13307765812@115.231.168.135").
|
|
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}))
|
|
mock.ExpectBegin()
|
|
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(4136.8),
|
|
float64(4136.8),
|
|
float64(0),
|
|
int64(1),
|
|
currentTime,
|
|
currentTime,
|
|
QualityOK,
|
|
QualityReasonCurrentDayFirst,
|
|
).
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage`).
|
|
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", int64(maxSelectedDailyMileageKM)).
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
mock.ExpectExec(`UPDATE vehicle_daily_mileage_source s`).
|
|
WithArgs(
|
|
"LA9GG64L7PBAF4001",
|
|
"2026-07-08",
|
|
"JT808",
|
|
int64(maxSelectedDailyMileageKM),
|
|
"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))
|
|
mock.ExpectCommit()
|
|
|
|
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(), managedTestSource(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{
|
|
"gb32960.vehicle.total_mileage_km": "10000.0",
|
|
},
|
|
})); err != nil {
|
|
t.Fatalf("Append() error = %v", err)
|
|
}
|
|
|
|
schemaCalls := 4 + len(DailyMileageAlterSQL)
|
|
if len(exec.calls) != schemaCalls+5 {
|
|
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_gps_mileage_state",
|
|
"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", "AUTO_INCREMENT", "created_at", "first_total_mileage_km", "trusted_source_key", "trusted_phone", "trusted_source_endpoint", "sample_count"} {
|
|
if strings.Contains(exec.calls[3].query, column) {
|
|
t.Fatalf("schema should not include %s: %s", column, exec.calls[3].query)
|
|
}
|
|
}
|
|
for _, column := range []string{"source_id BIGINT NULL", "daily_mileage_km", "latest_total_mileage_km", "KEY idx_source_id (source_id)"} {
|
|
if !strings.Contains(exec.calls[3].query, column) {
|
|
t.Fatalf("schema should include %s: %s", column, exec.calls[3].query)
|
|
}
|
|
}
|
|
if !strings.Contains(exec.calls[3].query, "PRIMARY KEY (vin, stat_date, protocol)") {
|
|
t.Fatalf("daily mileage table should key by vin/stat_date/protocol: %s", exec.calls[3].query)
|
|
}
|
|
if strings.Contains(exec.calls[3].query, "KEY idx_vin (vin)") {
|
|
t.Fatalf("daily mileage table should not keep redundant vin index covered by the primary key: %s", exec.calls[3].query)
|
|
}
|
|
projectCall := exec.calls[schemaCalls+2]
|
|
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, "JOIN vehicle_data_source ds") {
|
|
t.Fatalf("project sql should join data source: %s", projectCall.query)
|
|
}
|
|
if !strings.Contains(projectCall.query, "ds.id") {
|
|
t.Fatalf("project sql should write source id: %s", projectCall.query)
|
|
}
|
|
for _, forbidden := range []string{"trusted_source_key", "trusted_phone", "trusted_source_endpoint", "first_total_mileage_km"} {
|
|
if strings.Contains(projectCall.query, forbidden) {
|
|
t.Fatalf("project sql should not write final-table field %q: %s", forbidden, 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[schemaCalls].query, "INSERT INTO vehicle_data_source") {
|
|
t.Fatalf("source upsert query missing: %s", exec.calls[schemaCalls].query)
|
|
}
|
|
if !strings.Contains(exec.calls[schemaCalls+1].query, "INSERT INTO vehicle_daily_mileage_source") {
|
|
t.Fatalf("candidate upsert query missing: %s", exec.calls[schemaCalls+1].query)
|
|
}
|
|
}
|
|
|
|
func TestWriterAppendWithResultReportsProjectionThrottle(t *testing.T) {
|
|
exec := &recordingExec{}
|
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
|
writer := NewWriter(exec, loc)
|
|
base := managedTestSource(envelope.FrameEnvelope{
|
|
Protocol: envelope.ProtocolJT808,
|
|
VIN: "LA9GG64L7PBAF4001",
|
|
Phone: "13307765812",
|
|
SourceEndpoint: "115.231.168.135:20215",
|
|
Fields: map[string]any{
|
|
"jt808.location.total_mileage_km": 4123.9,
|
|
},
|
|
})
|
|
|
|
first := base
|
|
first.EventTimeMS = time.Date(2026, 7, 8, 13, 20, 0, 0, loc).UnixMilli()
|
|
firstResult, err := writer.AppendWithResult(context.Background(), first)
|
|
if err != nil {
|
|
t.Fatalf("first AppendWithResult() error = %v", err)
|
|
}
|
|
if firstResult.ProjectionsAttempted != 1 || firstResult.ProjectionsWritten != 1 || firstResult.ProjectionsSkippedThrottled != 0 {
|
|
t.Fatalf("first projection result = %+v", firstResult)
|
|
}
|
|
|
|
second := base
|
|
second.EventTimeMS = time.Date(2026, 7, 8, 13, 20, 5, 0, loc).UnixMilli()
|
|
second.Fields = map[string]any{"jt808.location.total_mileage_km": 4124.2}
|
|
secondResult, err := writer.AppendWithResult(context.Background(), second)
|
|
if err != nil {
|
|
t.Fatalf("second AppendWithResult() error = %v", err)
|
|
}
|
|
if secondResult.SamplesWritten != 1 || secondResult.ProjectionsAttempted != 0 || secondResult.ProjectionsWritten != 0 || secondResult.ProjectionsSkippedThrottled != 1 {
|
|
t.Fatalf("second projection result = %+v, want source write with throttled final projection", secondResult)
|
|
}
|
|
if secondResult.SourceTouchesSkippedThrottled != 1 || secondResult.SourceTouchesWritten != 0 {
|
|
t.Fatalf("second source result = %+v, want source touch throttled", secondResult)
|
|
}
|
|
|
|
third := base
|
|
third.EventTimeMS = time.Date(2026, 7, 8, 13, 20, 16, 0, loc).UnixMilli()
|
|
third.Fields = map[string]any{"jt808.location.total_mileage_km": 4125.0}
|
|
thirdResult, err := writer.AppendWithResult(context.Background(), third)
|
|
if err != nil {
|
|
t.Fatalf("third AppendWithResult() error = %v", err)
|
|
}
|
|
if thirdResult.ProjectionsAttempted != 1 || thirdResult.ProjectionsWritten != 1 || thirdResult.ProjectionsSkippedThrottled != 0 {
|
|
t.Fatalf("third projection result = %+v", thirdResult)
|
|
}
|
|
}
|
|
|
|
func TestWriterSkipsConsecutiveDuplicateMileageSamples(t *testing.T) {
|
|
exec := &recordingExec{}
|
|
writer := NewWriter(exec, time.FixedZone("Asia/Shanghai", 8*3600))
|
|
event := managedTestSource(envelope.FrameEnvelope{
|
|
Protocol: envelope.ProtocolJT808,
|
|
VIN: "LNBVIN00000000001",
|
|
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{
|
|
"jt808.location.total_mileage_km": 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) != 5 {
|
|
t.Fatalf("exec calls = %d, want 5", len(exec.calls))
|
|
}
|
|
}
|
|
|
|
func TestWriterCarriesPreviousYutongMileageIntoStationaryDay(t *testing.T) {
|
|
exec := &recordingExec{}
|
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
|
writer := NewWriter(exec, loc)
|
|
eventTime := time.Date(2026, 7, 19, 6, 5, 42, 0, loc)
|
|
previousTime := time.Date(2026, 7, 18, 16, 48, 10, 0, loc)
|
|
vin := "LMRKH9AC9R1004099"
|
|
sourceKey := "YUTONG_MQTT:" + vin + "@PLATFORM:yutong"
|
|
sample := MetricSample{
|
|
VIN: vin,
|
|
Protocol: envelope.ProtocolYutongMQTT,
|
|
StatDate: "2026-07-19",
|
|
SourceKey: sourceKey,
|
|
}
|
|
writer.baselineCache[mileageCacheKey(sample)] = sourceBaselineCacheEntry{
|
|
baseline: sourceBaseline{
|
|
LatestTotalKM: 24245,
|
|
LatestEventTime: previousTime,
|
|
QualityReason: QualityReasonHistorical,
|
|
},
|
|
found: true,
|
|
cachedAt: time.Now(),
|
|
}
|
|
event := managedTestSource(envelope.FrameEnvelope{
|
|
Protocol: envelope.ProtocolYutongMQTT,
|
|
VIN: vin,
|
|
DeviceID: vin,
|
|
SourceEndpoint: "mqtt://yutong/ytforward/shln/2",
|
|
SourceCode: "yutong",
|
|
PlatformName: "宇通",
|
|
EventTimeMS: eventTime.UnixMilli(),
|
|
ReceivedAtMS: eventTime.UnixMilli(),
|
|
Fields: map[string]any{
|
|
"yutong_mqtt.data.latitude": "30.645438",
|
|
"yutong_mqtt.data.longitude": "114.435672",
|
|
"yutong_mqtt.data.meter_speed": "0",
|
|
},
|
|
})
|
|
|
|
result, err := writer.AppendWithResult(context.Background(), event)
|
|
if err != nil {
|
|
t.Fatalf("AppendWithResult() error = %v", err)
|
|
}
|
|
if result.SamplesRecoveredStationary != 1 || result.SamplesWritten != 1 || result.ProjectionsWritten != 1 {
|
|
t.Fatalf("stationary carry result = %+v", result)
|
|
}
|
|
if result.SamplesSkippedMissingMileage != 1 {
|
|
t.Fatalf("source-data evidence must retain the missing-mileage counter: %+v", result)
|
|
}
|
|
if len(exec.calls) != 7 {
|
|
t.Fatalf("exec calls = %d, want source touch plus six mileage writes", len(exec.calls))
|
|
}
|
|
sourceWrite := exec.calls[1]
|
|
if !strings.Contains(sourceWrite.query, "INSERT INTO vehicle_daily_mileage_source") {
|
|
t.Fatalf("source mileage write missing: %s", sourceWrite.query)
|
|
}
|
|
if got := sourceWrite.args[9]; got != float64(24245) {
|
|
t.Fatalf("first total mileage = %#v, want carried 24245", got)
|
|
}
|
|
if got := sourceWrite.args[10]; got != float64(24245) {
|
|
t.Fatalf("latest total mileage = %#v, want carried 24245", got)
|
|
}
|
|
if got := sourceWrite.args[11]; got != float64(0) {
|
|
t.Fatalf("daily mileage = %#v, want explicit zero", got)
|
|
}
|
|
if got := sourceWrite.args[16]; got != QualityReasonStationaryCarry {
|
|
t.Fatalf("quality reason = %#v, want %q", got, QualityReasonStationaryCarry)
|
|
}
|
|
}
|
|
|
|
func TestWriterDoesNotCarryMileageForMovingYutongFrame(t *testing.T) {
|
|
exec := &recordingExec{}
|
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
|
writer := NewWriter(exec, loc)
|
|
eventTime := time.Date(2026, 7, 19, 8, 0, 0, 0, loc)
|
|
event := managedTestSource(envelope.FrameEnvelope{
|
|
Protocol: envelope.ProtocolYutongMQTT,
|
|
VIN: "LMRKH9AC9R1004099",
|
|
DeviceID: "LMRKH9AC9R1004099",
|
|
SourceEndpoint: "mqtt://yutong/ytforward/shln/2",
|
|
SourceCode: "yutong",
|
|
EventTimeMS: eventTime.UnixMilli(),
|
|
Fields: map[string]any{
|
|
"yutong_mqtt.data.latitude": "30.645438",
|
|
"yutong_mqtt.data.longitude": "114.435672",
|
|
"yutong_mqtt.data.meter_speed": "12",
|
|
},
|
|
})
|
|
|
|
result, err := writer.AppendWithResult(context.Background(), event)
|
|
if err != nil {
|
|
t.Fatalf("AppendWithResult() error = %v", err)
|
|
}
|
|
if result.SamplesRecoveredStationary != 0 || result.SamplesWritten != 0 || result.SamplesSkippedMissingMileage != 1 {
|
|
t.Fatalf("moving frame must remain missing-mileage evidence: %+v", result)
|
|
}
|
|
if len(exec.calls) != 1 || !strings.Contains(exec.calls[0].query, "INSERT INTO vehicle_data_source") {
|
|
t.Fatalf("moving frame writes = %+v, want source touch only", exec.calls)
|
|
}
|
|
}
|
|
|
|
func TestWriterAppendWithResultReportsDuplicateMileage(t *testing.T) {
|
|
exec := &recordingExec{}
|
|
writer := NewWriter(exec, time.FixedZone("Asia/Shanghai", 8*3600))
|
|
event := managedTestSource(envelope.FrameEnvelope{
|
|
Protocol: envelope.ProtocolJT808,
|
|
VIN: "LNBVIN00000000001",
|
|
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{
|
|
"jt808.location.total_mileage_km": 10241.2,
|
|
},
|
|
})
|
|
|
|
if _, err := writer.AppendWithResult(context.Background(), event); err != nil {
|
|
t.Fatalf("first AppendWithResult() error = %v", err)
|
|
}
|
|
event.ReceivedAtMS += 1000
|
|
result, err := writer.AppendWithResult(context.Background(), event)
|
|
if err != nil {
|
|
t.Fatalf("duplicate AppendWithResult() error = %v", err)
|
|
}
|
|
|
|
if result.SamplesFound != 1 || result.SamplesSkippedSameMileage != 1 || result.SamplesWritten != 0 {
|
|
t.Fatalf("result = %+v, want duplicate mileage skipped", result)
|
|
}
|
|
if result.SourceTouchesSkippedThrottled != 1 || result.SourceTouchesWritten != 0 {
|
|
t.Fatalf("source touch result = %+v, want duplicate to throttle source touch", result)
|
|
}
|
|
if len(exec.calls) != 5 {
|
|
t.Fatalf("exec calls = %d, want duplicate to avoid mysql writes", len(exec.calls))
|
|
}
|
|
}
|
|
|
|
func TestWriterDuplicateYutongOdometerStillEntersGPSFallback(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, 19, 8, 0, 0, 0, loc)
|
|
event := managedTestSource(envelope.FrameEnvelope{
|
|
EventID: "evt-yutong-stale-odometer",
|
|
Protocol: envelope.ProtocolYutongMQTT,
|
|
VIN: "LMRKH9AC9R1004099",
|
|
DeviceID: "LMRKH9AC9R1004099",
|
|
SourceEndpoint: "mqtt://yutong/ytforward/shln/2",
|
|
EventTimeMS: eventTime.UnixMilli(),
|
|
Fields: map[string]any{
|
|
"yutong_mqtt.data.total_mileage": 24245000,
|
|
"yutong_mqtt.data.longitude": 114.435672,
|
|
"yutong_mqtt.data.latitude": 30.645438,
|
|
"yutong_mqtt.data.meter_speed": 18,
|
|
},
|
|
})
|
|
samples, _, err := samplesFromEnvelopeWithResult(event, loc)
|
|
if err != nil || len(samples) != 1 {
|
|
t.Fatalf("samplesFromEnvelopeWithResult() samples=%+v err=%v", samples, err)
|
|
}
|
|
identity, ok := NewSourceIdentityFromEnvelope(event)
|
|
if !ok {
|
|
t.Fatal("expected managed Yutong source identity")
|
|
}
|
|
writer.markSourceTouched(identity, eventTime)
|
|
writer.markMileageWritten(samples[0])
|
|
|
|
mock.ExpectBegin()
|
|
mock.ExpectQuery(`SELECT first_event_time, latest_event_time`).
|
|
WillReturnRows(sqlmock.NewRows([]string{
|
|
"first_event_time", "latest_event_time", "latest_event_id",
|
|
"latest_longitude", "latest_latitude", "daily_mileage_km",
|
|
"point_count", "usable_segment_count", "bad_jump_count",
|
|
"long_gap_count", "out_of_order_count",
|
|
}).AddRow(eventTime.Add(-20*time.Second), eventTime.Add(-20*time.Second), "evt-before", 114.434672, 30.645438, 0, 1, 0, 0, 0, 0))
|
|
mock.ExpectExec(`UPDATE vehicle_daily_gps_mileage_state`).
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage_source`).
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage`).
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
mock.ExpectExec(`UPDATE vehicle_daily_mileage_source`).
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
mock.ExpectExec(`DELETE FROM vehicle_daily_mileage`).
|
|
WillReturnResult(sqlmock.NewResult(0, 0))
|
|
mock.ExpectCommit()
|
|
|
|
result, err := writer.AppendWithResult(context.Background(), event)
|
|
if err != nil {
|
|
t.Fatalf("AppendWithResult() error = %v", err)
|
|
}
|
|
if result.SamplesFound != 1 || result.SamplesSkippedSameMileage != 1 ||
|
|
result.SamplesRecoveredGPSCoordinate != 1 || result.SamplesWritten != 1 ||
|
|
result.ProjectionsAttempted != 1 || result.ProjectionsWritten != 1 {
|
|
t.Fatalf("duplicate odometer evidence = %+v", result)
|
|
}
|
|
if got := writer.CacheStats().GPSAccumulationEntries; got != 1 {
|
|
t.Fatalf("GPS accumulation entries = %d, want stale moving odometer to enter fallback", got)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("unmet SQL expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWriterDoesNotCacheMileageWhenUpsertFails(t *testing.T) {
|
|
exec := &recordingExec{errs: []error{errors.New("mysql unavailable"), nil}}
|
|
writer := NewWriter(exec, time.FixedZone("Asia/Shanghai", 8*3600))
|
|
event := managedTestSource(envelope.FrameEnvelope{
|
|
Protocol: envelope.ProtocolJT808,
|
|
VIN: "LNBVIN00000000001",
|
|
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{
|
|
"jt808.location.total_mileage_km": 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) != 6 {
|
|
t.Fatalf("exec calls = %d, want 6", len(exec.calls))
|
|
}
|
|
}
|
|
|
|
func TestWriterRollsBackMileageTransactionWhenProjectionFails(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, 9, 30, 0, 0, loc)
|
|
event := managedTestSource(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,
|
|
},
|
|
})
|
|
wantErr := errors.New("project final mileage failed")
|
|
|
|
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.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`).
|
|
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", "JT808:13307765812@115.231.168.135").
|
|
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}))
|
|
mock.ExpectBegin()
|
|
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,
|
|
QualityOK,
|
|
QualityReasonCurrentDayFirst,
|
|
).
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage`).
|
|
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", int64(maxSelectedDailyMileageKM)).
|
|
WillReturnError(wantErr)
|
|
mock.ExpectRollback()
|
|
|
|
err = writer.Append(context.Background(), event)
|
|
if !errors.Is(err, wantErr) {
|
|
t.Fatalf("Append() error = %v, want %v", err, wantErr)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("sql expectations: %v", err)
|
|
}
|
|
writer.mu.Lock()
|
|
defer writer.mu.Unlock()
|
|
if len(writer.lastTotalMileage) != 0 || len(writer.lastProjection) != 0 {
|
|
t.Fatalf("writer cache should not mark failed sample, mileage=%d projection=%d", len(writer.lastTotalMileage), len(writer.lastProjection))
|
|
}
|
|
}
|
|
|
|
func TestWriterKeepsOnlyLatestDateInMileageCache(t *testing.T) {
|
|
exec := &recordingExec{}
|
|
writer := NewWriter(exec, time.FixedZone("Asia/Shanghai", 8*3600))
|
|
event := managedTestSource(envelope.FrameEnvelope{
|
|
Protocol: envelope.ProtocolJT808,
|
|
VIN: "LNBVIN00000000001",
|
|
SourceEndpoint: "115.231.168.135:20215",
|
|
Fields: map[string]any{
|
|
"jt808.location.total_mileage_km": 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) != 10 {
|
|
t.Fatalf("exec calls = %d, want 10", len(exec.calls))
|
|
}
|
|
if len(writer.lastTotalMileage) != 1 {
|
|
t.Fatalf("cache entries = %d, want 1", len(writer.lastTotalMileage))
|
|
}
|
|
}
|
|
|
|
func TestWriterCacheLimitEvictsOldestEntries(t *testing.T) {
|
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
|
writer := NewWriter(&recordingExec{}, loc)
|
|
writer.SetMaxCacheEntries(2)
|
|
samples := []struct {
|
|
sample MetricSample
|
|
sourceIP string
|
|
}{
|
|
{sample: MetricSample{VIN: "VIN00000000000001", Protocol: envelope.ProtocolJT808, StatDate: "2026-07-01", SourceKey: "JT808:a@115.231.168.135", EventTime: time.Date(2026, 7, 1, 8, 0, 0, 0, loc)}, sourceIP: "115.231.168.135"},
|
|
{sample: MetricSample{VIN: "VIN00000000000002", Protocol: envelope.ProtocolJT808, StatDate: "2026-07-02", SourceKey: "JT808:b@115.231.168.136", EventTime: time.Date(2026, 7, 2, 8, 0, 0, 0, loc)}, sourceIP: "115.231.168.136"},
|
|
{sample: MetricSample{VIN: "VIN00000000000003", Protocol: envelope.ProtocolJT808, StatDate: "2026-07-03", SourceKey: "JT808:c@115.231.168.137", EventTime: time.Date(2026, 7, 3, 8, 0, 0, 0, loc)}, sourceIP: "115.231.168.137"},
|
|
}
|
|
for index, item := range samples {
|
|
sample := item.sample
|
|
sample.TotalMileageKM = float64(100 + index)
|
|
writer.markMileageWritten(sample)
|
|
writer.markProjected(sample)
|
|
writer.markSourceTouched(SourceIdentity{
|
|
Protocol: sample.Protocol,
|
|
SourceIP: item.sourceIP,
|
|
SourceEndpoint: item.sourceIP + ":20215",
|
|
}, sample.EventTime)
|
|
writer.mu.Lock()
|
|
writer.baselineCache[mileageCacheKey(sample)] = sourceBaselineCacheEntry{found: true, baseline: sourceBaseline{LatestTotalKM: sample.TotalMileageKM, LatestEventTime: sample.EventTime}}
|
|
writer.addCacheKeyLocked(writer.baselineKeysByPrefix, mileageCachePrefix(sample), mileageCacheKey(sample))
|
|
writer.enforceCacheLimitsLocked()
|
|
writer.mu.Unlock()
|
|
}
|
|
|
|
stats := writer.CacheStats()
|
|
if stats.MaxEntries != 2 {
|
|
t.Fatalf("max entries = %d, want 2", stats.MaxEntries)
|
|
}
|
|
if stats.LastTotalMileageEntries != 2 ||
|
|
stats.LastProjectionEntries != 2 ||
|
|
stats.LastSourceSeenEntries != 2 ||
|
|
stats.BaselineEntries != 2 {
|
|
t.Fatalf("cache entries = %+v, want each cache capped at 2", stats)
|
|
}
|
|
if stats.TotalMileageEvictions == 0 ||
|
|
stats.ProjectionEvictions == 0 ||
|
|
stats.SourceSeenEvictions == 0 ||
|
|
stats.BaselineEvictions == 0 {
|
|
t.Fatalf("cache evictions = %+v, want all caches to evict", stats)
|
|
}
|
|
if _, ok := writer.lastTotalMileage[mileageCacheKey(samples[0].sample)]; ok {
|
|
t.Fatalf("oldest mileage cache key was not evicted")
|
|
}
|
|
if _, ok := writer.lastProjection[projectionCacheKey(samples[0].sample)]; ok {
|
|
t.Fatalf("oldest projection cache key was not evicted")
|
|
}
|
|
if _, ok := writer.baselineCache[mileageCacheKey(samples[0].sample)]; ok {
|
|
t.Fatalf("oldest baseline cache key was not evicted")
|
|
}
|
|
if _, ok := writer.lastSourceSeen["JT808|115.231.168.135"]; ok {
|
|
t.Fatalf("oldest source touch cache key was not evicted")
|
|
}
|
|
}
|
|
|
|
func TestWriterCleanupCachesDropsExpiredVehicleState(t *testing.T) {
|
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
|
writer := NewWriter(&recordingExec{}, loc)
|
|
writer.SetCacheRetention(48 * time.Hour)
|
|
writer.SetCacheCleanupInterval(0)
|
|
oldSample := MetricSample{
|
|
VIN: "OLDVIN00000000001",
|
|
Protocol: envelope.ProtocolJT808,
|
|
StatDate: "2026-07-01",
|
|
SourceKey: "JT808:old@115.231.168.135",
|
|
EventTime: time.Date(2026, 7, 1, 12, 0, 0, 0, loc),
|
|
}
|
|
currentSample := MetricSample{
|
|
VIN: "NEWVIN00000000001",
|
|
Protocol: envelope.ProtocolJT808,
|
|
StatDate: "2026-07-04",
|
|
SourceKey: "JT808:new@115.231.168.136",
|
|
EventTime: time.Date(2026, 7, 4, 12, 0, 0, 0, loc),
|
|
}
|
|
oldMileageKey := mileageCacheKey(oldSample)
|
|
currentMileageKey := mileageCacheKey(currentSample)
|
|
oldProjectionKey := projectionCacheKey(oldSample)
|
|
currentProjectionKey := projectionCacheKey(currentSample)
|
|
writer.lastTotalMileage[oldMileageKey] = 100
|
|
writer.lastTotalMileage[currentMileageKey] = 200
|
|
writer.baselineCache[oldMileageKey] = sourceBaselineCacheEntry{found: true}
|
|
writer.baselineCache[currentMileageKey] = sourceBaselineCacheEntry{found: true}
|
|
writer.lastProjection[oldProjectionKey] = projectionCacheEntry{projectedAt: oldSample.EventTime, sourceKeys: map[string]struct{}{oldSample.SourceKey: {}}}
|
|
writer.lastProjection[currentProjectionKey] = projectionCacheEntry{projectedAt: currentSample.EventTime, sourceKeys: map[string]struct{}{currentSample.SourceKey: {}}}
|
|
writer.lastSourceSeen["JT808|115.231.168.135"] = oldSample.EventTime
|
|
writer.lastSourceSeen["JT808|115.231.168.136"] = currentSample.EventTime
|
|
|
|
writer.maybeCleanupCaches(time.Date(2026, 7, 4, 12, 1, 0, 0, loc))
|
|
|
|
if _, ok := writer.lastTotalMileage[oldMileageKey]; ok {
|
|
t.Fatalf("expired mileage cache key was not removed")
|
|
}
|
|
if _, ok := writer.baselineCache[oldMileageKey]; ok {
|
|
t.Fatalf("expired baseline cache key was not removed")
|
|
}
|
|
if _, ok := writer.lastProjection[oldProjectionKey]; ok {
|
|
t.Fatalf("expired projection cache key was not removed")
|
|
}
|
|
if _, ok := writer.lastSourceSeen["JT808|115.231.168.135"]; ok {
|
|
t.Fatalf("expired source touch cache key was not removed")
|
|
}
|
|
if _, ok := writer.lastTotalMileage[currentMileageKey]; !ok {
|
|
t.Fatalf("current mileage cache key was removed")
|
|
}
|
|
if _, ok := writer.baselineCache[currentMileageKey]; !ok {
|
|
t.Fatalf("current baseline cache key was removed")
|
|
}
|
|
if _, ok := writer.lastProjection[currentProjectionKey]; !ok {
|
|
t.Fatalf("current projection cache key was removed")
|
|
}
|
|
if _, ok := writer.lastSourceSeen["JT808|115.231.168.136"]; !ok {
|
|
t.Fatalf("current source touch cache key was removed")
|
|
}
|
|
stats := writer.CacheStats()
|
|
if stats.LastTotalMileageEntries != 1 ||
|
|
stats.BaselineEntries != 1 ||
|
|
stats.LastProjectionEntries != 1 ||
|
|
stats.LastSourceSeenEntries != 1 {
|
|
t.Fatalf("cache stats entries = %+v, want one current entry in each cache", stats)
|
|
}
|
|
if stats.LastCleanupTotalMileage != 1 ||
|
|
stats.LastCleanupBaseline != 1 ||
|
|
stats.LastCleanupProjection != 1 ||
|
|
stats.LastCleanupSourceSeen != 1 {
|
|
t.Fatalf("cache cleanup stats = %+v, want one deleted from each cache", stats)
|
|
}
|
|
if !stats.LastCleanupAt.Equal(time.Date(2026, 7, 4, 12, 1, 0, 0, loc)) {
|
|
t.Fatalf("last cleanup at = %s", stats.LastCleanupAt)
|
|
}
|
|
}
|
|
|
|
func TestWriterCleanupCachesCanBeDisabled(t *testing.T) {
|
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
|
writer := NewWriter(&recordingExec{}, loc)
|
|
writer.SetCacheRetention(0)
|
|
writer.SetCacheCleanupInterval(0)
|
|
oldSample := MetricSample{
|
|
VIN: "OLDVIN00000000001",
|
|
Protocol: envelope.ProtocolJT808,
|
|
StatDate: "2026-07-01",
|
|
SourceKey: "JT808:old@115.231.168.135",
|
|
EventTime: time.Date(2026, 7, 1, 12, 0, 0, 0, loc),
|
|
}
|
|
key := mileageCacheKey(oldSample)
|
|
writer.lastTotalMileage[key] = 100
|
|
writer.baselineCache[key] = sourceBaselineCacheEntry{found: true}
|
|
writer.lastProjection[projectionCacheKey(oldSample)] = projectionCacheEntry{projectedAt: oldSample.EventTime}
|
|
writer.lastSourceSeen["JT808|115.231.168.135"] = oldSample.EventTime
|
|
|
|
writer.maybeCleanupCaches(time.Date(2026, 7, 10, 12, 0, 0, 0, loc))
|
|
|
|
if len(writer.lastTotalMileage) != 1 || len(writer.baselineCache) != 1 || len(writer.lastProjection) != 1 || len(writer.lastSourceSeen) != 1 {
|
|
t.Fatalf("cache cleanup should be disabled, got mileage=%d baseline=%d projection=%d source=%d", len(writer.lastTotalMileage), len(writer.baselineCache), len(writer.lastProjection), len(writer.lastSourceSeen))
|
|
}
|
|
}
|
|
|
|
func countExecQueries(calls []execCall, fragment string) int {
|
|
count := 0
|
|
for _, call := range calls {
|
|
if strings.Contains(call.query, fragment) {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func TestWriterSupportsConcurrentPartitionConsumers(t *testing.T) {
|
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
|
exec := &concurrentExec{}
|
|
writer := NewWriter(exec, loc)
|
|
writer.SetSourceTouchInterval(0)
|
|
writer.SetProjectionInterval(0)
|
|
|
|
const workers = 32
|
|
var wait sync.WaitGroup
|
|
errorsCh := make(chan error, workers)
|
|
for index := 0; index < workers; index++ {
|
|
wait.Add(1)
|
|
go func(index int) {
|
|
defer wait.Done()
|
|
env := managedTestSource(envelope.FrameEnvelope{
|
|
Protocol: envelope.ProtocolJT808,
|
|
VIN: fmt.Sprintf("LNBVIN%011d", index),
|
|
Phone: fmt.Sprintf("13%09d", index),
|
|
MessageID: "0x0200",
|
|
SourceEndpoint: fmt.Sprintf("10.0.0.%d:808", index+1),
|
|
EventTimeMS: time.Date(2026, 7, 13, 8, 0, index, 0, loc).UnixMilli(),
|
|
ReceivedAtMS: time.Date(2026, 7, 13, 8, 0, index, 0, loc).UnixMilli(),
|
|
Fields: map[string]any{
|
|
"jt808.location.total_mileage_km": 1000 + float64(index),
|
|
},
|
|
})
|
|
result, err := writer.AppendWithResult(context.Background(), env)
|
|
if err != nil {
|
|
errorsCh <- err
|
|
return
|
|
}
|
|
if result.SamplesWritten != 1 {
|
|
errorsCh <- fmt.Errorf("worker %d samples written = %d", index, result.SamplesWritten)
|
|
}
|
|
}(index)
|
|
}
|
|
wait.Wait()
|
|
close(errorsCh)
|
|
for err := range errorsCh {
|
|
t.Fatal(err)
|
|
}
|
|
if exec.CallCount() == 0 {
|
|
t.Fatal("concurrent writers did not execute any MySQL statements")
|
|
}
|
|
}
|
|
|
|
type execCall struct {
|
|
query string
|
|
args []any
|
|
}
|
|
|
|
type recordingExec struct {
|
|
calls []execCall
|
|
errs []error
|
|
}
|
|
|
|
type selectiveFailingExec struct {
|
|
failVIN string
|
|
}
|
|
|
|
func (e *selectiveFailingExec) ExecContext(_ context.Context, _ string, args ...any) (sql.Result, error) {
|
|
if len(args) > 0 && fmt.Sprint(args[0]) == e.failVIN {
|
|
return nil, errors.New("test projection failure")
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
type concurrentExec struct {
|
|
mu sync.Mutex
|
|
calls int
|
|
}
|
|
|
|
func (e *concurrentExec) ExecContext(_ context.Context, _ string, _ ...any) (sql.Result, error) {
|
|
e.mu.Lock()
|
|
e.calls++
|
|
e.mu.Unlock()
|
|
return nil, nil
|
|
}
|
|
|
|
func (e *concurrentExec) CallCount() int {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
return e.calls
|
|
}
|
|
|
|
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
|
|
}
|