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 }