package platform import ( "context" "fmt" "net/url" "os" "path/filepath" "strconv" "strings" "sync" "testing" "time" ) func TestHistoryExportIndexSurvivesServiceRestart(t *testing.T) { dir := t.TempDir() first := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir}) completedAt := time.Now().UTC().Format(time.RFC3339) filePath := filepath.Join(dir, "exp_completed.csv") if err := os.WriteFile(filePath, []byte("vin\nVIN001\n"), 0o640); err != nil { t.Fatal(err) } first.exportsMu.Lock() first.exports["exp_completed"] = &HistoryExportJob{ID: "exp_completed", Name: "完成任务", Status: "completed", Progress: 100, Format: "csv", Category: "location", CreatedAt: completedAt, UpdatedAt: completedAt, DownloadURL: "/api/v2/exports/exp_completed/download", filePath: filePath} first.exports["exp_running"] = &HistoryExportJob{ID: "exp_running", Name: "中断任务", Status: "running", Progress: 50, Format: "csv", Category: "location", CreatedAt: completedAt, UpdatedAt: completedAt} if err := first.persistHistoryExportsLocked(); err != nil { first.exportsMu.Unlock() t.Fatal(err) } first.exportsMu.Unlock() restarted := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir}) jobs := restarted.ListHistoryExports() if len(jobs) != 2 { t.Fatalf("jobs=%+v", jobs) } path, _, err := restarted.HistoryExportFile("exp_completed") if err != nil || path != filePath { t.Fatalf("completed export not restored: path=%q err=%v", path, err) } for _, job := range jobs { if job.ID == "exp_running" && (job.Status != "failed" || !strings.Contains(job.Error, "服务重启")) { t.Fatalf("interrupted export should be failed: %+v", job) } } } type countingStore struct { *MockStore vehiclesCalls int vehicleRealtimeCalls int overviewBatchCalls int } func newCountingStore() *countingStore { return &countingStore{MockStore: NewMockStore()} } func (s *countingStore) Vehicles(ctx context.Context, query url.Values) (Page[VehicleRow], error) { s.vehiclesCalls++ return s.MockStore.Vehicles(ctx, query) } func (s *countingStore) VehicleRealtime(ctx context.Context, query url.Values) (Page[VehicleRealtimeRow], error) { s.vehicleRealtimeCalls++ return s.MockStore.VehicleRealtime(ctx, query) } func (s *countingStore) VehicleServiceOverviews(ctx context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) { s.overviewBatchCalls++ return s.MockStore.VehicleServiceOverviews(ctx, query) } func TestVehicleServiceOverviewUsesBatchDataPath(t *testing.T) { store := newCountingStore() service := NewService(store) overview, err := service.VehicleServiceOverview(context.Background(), "粤AG18312", "") if err != nil { t.Fatalf("VehicleServiceOverview returned error: %v", err) } if overview.VIN != "LB9A32A24R0LS1426" { t.Fatalf("expected canonical VIN from overview, got %+v", overview) } if store.overviewBatchCalls != 1 || store.vehiclesCalls != 0 || store.vehicleRealtimeCalls != 0 { t.Fatalf("single overview should use one batch store call, batch=%d vehicles=%d realtime=%d", store.overviewBatchCalls, store.vehiclesCalls, store.vehicleRealtimeCalls) } } func TestVehicleServiceOverviewsUsesBatchDataPath(t *testing.T) { store := newCountingStore() service := NewService(store) page, err := service.VehicleServiceOverviews(context.Background(), VehicleOverviewBatchQuery{ Keywords: []string{"粤AG18312", "LMRKH9AC2R1004087"}, Limit: 200, }) if err != nil { t.Fatalf("VehicleServiceOverviews returned error: %v", err) } if page.Total != 2 || len(page.Items) != 2 { t.Fatalf("expected two overview rows, got %+v", page) } if store.vehiclesCalls > 1 || store.vehicleRealtimeCalls > 1 { t.Fatalf("batch overview should avoid per-keyword store calls, vehicles=%d realtime=%d", store.vehiclesCalls, store.vehicleRealtimeCalls) } } func TestVehicleServiceSummaryCountsProtocolOnlineByProtocolSlot(t *testing.T) { service := NewService(NewMockStore()) summary, err := service.VehicleServiceSummary(context.Background()) if err != nil { t.Fatalf("VehicleServiceSummary returned error: %v", err) } byProtocol := map[string]ProtocolStat{} for _, protocol := range summary.Protocols { byProtocol[protocol.Protocol] = protocol } gb32960 := byProtocol["GB32960"] if gb32960.Total != 2 { t.Fatalf("expected two GB32960 source slots, got %+v", gb32960) } if gb32960.Online != 1 { t.Fatalf("GB32960 online count must use GB32960 source status only, got %+v", gb32960) } } func TestCompleteProtocolStatsIncludesCanonicalSlots(t *testing.T) { stats := completeProtocolStats([]ProtocolStat{{Protocol: "JT808", Online: 2, Total: 5}}) byProtocol := map[string]ProtocolStat{} for _, stat := range stats { byProtocol[stat.Protocol] = stat } for _, protocol := range canonicalVehicleProtocols { if _, ok := byProtocol[protocol]; !ok { t.Fatalf("canonical protocol %s should be present, got %+v", protocol, stats) } } if byProtocol["JT808"].Online != 2 || byProtocol["JT808"].Total != 5 { t.Fatalf("existing protocol stats should be preserved, got %+v", byProtocol["JT808"]) } if byProtocol["GB32960"].Online != 0 || byProtocol["GB32960"].Total != 0 { t.Fatalf("missing canonical protocol should be exposed as zero slot, got %+v", byProtocol["GB32960"]) } } func TestSummarizeTrackUsesMileageAndChronology(t *testing.T) { points := []HistoryLocationRow{ {DeviceTime: "2026-07-03 10:00:00", TotalMileageKm: 100, SpeedKmh: 0, Longitude: 113.1, Latitude: 23.1}, {DeviceTime: "2026-07-03 10:30:00", TotalMileageKm: 112.5, SpeedKmh: 50, Longitude: 113.2, Latitude: 23.2}, } summary := summarizeTrack(points) if summary.DistanceKm != 12.5 || summary.DurationSeconds != 1800 || summary.MaximumSpeedKmh != 50 { t.Fatalf("unexpected track summary: %+v", summary) } } func TestTrackEventKeepsSynchronizedTelemetryEvidence(t *testing.T) { direction := int64(88) alarm := int64(2) event := trackEvent(7, "alarm", "报警点", HistoryLocationRow{DeviceTime: "2026-07-03 10:00:00", SpeedKmh: 36, SOCPercent: 78.5, SOCAvailable: true, DirectionDeg: &direction, AlarmFlag: &alarm, Longitude: 113.2, Latitude: 23.1}) if event.Index != 7 || !event.SOCAvailable || event.SOCPercent != 78.5 || event.DirectionDeg == nil || *event.DirectionDeg != 88 || event.AlarmFlag == nil || *event.AlarmFlag != 2 { t.Fatalf("track event lost synchronized telemetry evidence: %+v", event) } } func TestSampleTrackPointsPreservesEndpoints(t *testing.T) { points := make([]HistoryLocationRow, 10) for index := range points { points[index].DeviceTime = strconv.Itoa(index) } sampled := sampleTrackPoints(points, 4) if len(sampled) != 4 || sampled[0].DeviceTime != "0" || sampled[len(sampled)-1].DeviceTime != "9" { t.Fatalf("sampling should preserve endpoints: %+v", sampled) } } func TestTrackAnalysisFiltersInvalidDuplicateAndDriftPoints(t *testing.T) { points := []HistoryLocationRow{ {DeviceTime: "2026-07-03 10:00:00", Protocol: "JT808", Longitude: 113.1, Latitude: 23.1}, {DeviceTime: "2026-07-03 10:00:00", Protocol: "JT808", Longitude: 113.1, Latitude: 23.1}, {DeviceTime: "2026-07-03 10:01:00", Protocol: "JT808", Longitude: 114.1, Latitude: 24.1}, {DeviceTime: "2026-07-03 10:02:00", Protocol: "JT808", Longitude: 0, Latitude: 0}, {DeviceTime: "2026-07-03 10:03:00", Protocol: "JT808", Longitude: 113.101, Latitude: 23.101}, } clean, quality := analyzeTrackPoints(points) if len(clean) != 2 || quality.ValidPoints != 2 || quality.DuplicatePoints != 1 || quality.DriftPoints != 1 || quality.InvalidCoordinatePoints != 1 { t.Fatalf("unexpected track quality projection: clean=%+v quality=%+v", clean, quality) } if quality.Status != "warning" { t.Fatalf("filtered evidence must surface as warning: %+v", quality) } } func TestTrackPrimarySourcePreventsParallelProtocolsFromBecomingOneRoute(t *testing.T) { points := []HistoryLocationRow{ {DeviceTime: "2026-07-03 10:00:00", Protocol: "GB32960", Longitude: 113.1, Latitude: 23.1}, {DeviceTime: "2026-07-03 10:00:00", Protocol: "JT808", Longitude: 103.1, Latitude: 30.1}, {DeviceTime: "2026-07-03 10:01:00", Protocol: "GB32960", Longitude: 113.101, Latitude: 23.101}, {DeviceTime: "2026-07-03 10:01:00", Protocol: "JT808", Longitude: 103.101, Latitude: 30.101}, {DeviceTime: "2026-07-03 10:02:00", Protocol: "JT808", Longitude: 103.102, Latitude: 30.102}, } clean, quality := analyzeTrackPoints(points) if len(clean) != 5 || quality.DriftPoints != 0 { t.Fatalf("parallel protocols must be checked within source: clean=%+v quality=%+v", clean, quality) } selected, protocol, alternate := selectTrackPrimarySource(clean, "") if protocol != "JT808" || len(selected) != 3 || alternate != 2 { t.Fatalf("unexpected primary source projection: protocol=%s selected=%+v alternate=%d", protocol, selected, alternate) } explicit, protocol, alternate := selectTrackPrimarySource(clean, "GB32960") if protocol != "GB32960" || len(explicit) != 2 || alternate != 3 { t.Fatalf("explicit source must win: protocol=%s selected=%+v alternate=%d", protocol, explicit, alternate) } } func TestTrackSegmentsExposeStopsAndDataGapsWithoutClaimingIgnition(t *testing.T) { points := []HistoryLocationRow{ {DeviceTime: "2026-07-03 10:00:00", SpeedKmh: 0, Longitude: 113.1, Latitude: 23.1}, {DeviceTime: "2026-07-03 10:02:00", SpeedKmh: 0, Longitude: 113.10001, Latitude: 23.10001}, {DeviceTime: "2026-07-03 10:04:00", SpeedKmh: 0, Longitude: 113.10002, Latitude: 23.10002}, {DeviceTime: "2026-07-03 10:05:00", SpeedKmh: 30, Longitude: 113.11, Latitude: 23.11}, {DeviceTime: "2026-07-03 10:20:00", SpeedKmh: 30, Longitude: 113.12, Latitude: 23.12}, } segments := buildTrackSegments(points) if len(segments) != 3 || segments[0].Type != "stopped" || segments[1].Type != "moving" || segments[2].Type != "gap" { t.Fatalf("unexpected inferred segments: %+v", segments) } stops := buildTrackStops(points, segments) if len(stops) != 1 || stops[0].DurationSeconds != 240 || !strings.Contains(stops[0].Evidence, "不代表点火状态") { t.Fatalf("unexpected inferred stops: %+v", stops) } } func TestTrackSegmentsDoNotTreatSubsecondSamplesAsDataGaps(t *testing.T) { points := []HistoryLocationRow{ {DeviceTime: "2026-07-03T10:00:00.100+08:00", SpeedKmh: 30, Longitude: 113.1, Latitude: 23.1}, {DeviceTime: "2026-07-03T10:00:00.900+08:00", SpeedKmh: 31, Longitude: 113.1001, Latitude: 23.1001}, {DeviceTime: "2026-07-03T10:00:01.700+08:00", SpeedKmh: 32, Longitude: 113.1002, Latitude: 23.1002}, } segments := buildTrackSegments(points) if len(segments) != 1 || segments[0].Type != "moving" || segments[0].PointCount != 3 { t.Fatalf("subsecond telemetry must remain a continuous moving segment: %+v", segments) } } func TestMonitorBoundsAreValidatedAndApplied(t *testing.T) { bounds, ok, err := parseMonitorBounds("103,29,105,31") if err != nil || !ok || !bounds.contains(104, 30) || bounds.contains(106, 30) { t.Fatalf("unexpected monitor bounds: bounds=%+v ok=%t err=%v", bounds, ok, err) } for _, invalid := range []string{"103,29,105", "east,29,105,31", "105,31,103,29", "-181,0,1,1"} { if _, _, invalidErr := parseMonitorBounds(invalid); invalidErr == nil { t.Fatalf("invalid monitor bounds must be rejected: %q", invalid) } } } func TestMonitorQueryKeepsServerOwnedKeywordAndMotionStatus(t *testing.T) { query := normalizeMonitorQuery(url.Values{"keyword": {"沪A"}, "status": {"driving"}}) if query.Get("vin") != "沪A" || query.Get("limit") != "10000" { t.Fatalf("monitor query did not normalize keyword and bound: %v", query) } if !matchesMonitorStatus(VehicleRealtimeRow{Online: true, SpeedKmh: 20, LastSeen: "2026-07-14T08:00:00+08:00"}, "driving") { t.Fatal("driving row must match driving filter") } if matchesMonitorStatus(VehicleRealtimeRow{Online: true, SpeedKmh: 0, LastSeen: "2026-07-14T08:00:00+08:00"}, "driving") { t.Fatal("idle row must not match driving filter") } } func TestMonitorMapTenThousandVehiclesNeverReturnsTenThousandPoints(t *testing.T) { vehicles := syntheticMonitorVehicles(10_000) result, err := buildMonitorMapResponse(vehicles, url.Values{"zoom": {"13"}}) if err != nil { t.Fatal(err) } if (result.Mode != "clusters" && result.Mode != "mixed") || len(result.Clusters) == 0 || len(result.Points)+len(result.Clusters) > monitorPointLimit { t.Fatalf("10k unbounded map must stay aggregated: mode=%s points=%d clusters=%d", result.Mode, len(result.Points), len(result.Clusters)) } for _, cluster := range result.Clusters { if cluster.Count < 2 { t.Fatalf("singleton must be released as a point instead of a fake cluster: %+v", cluster) } } viewport, err := buildMonitorMapResponse(vehicles, url.Values{"zoom": {"11"}, "bounds": {"113,22,114,24"}}) if err != nil { t.Fatal(err) } if viewport.Mode != "points" || len(viewport.Points) >= monitorPointLimit { t.Fatalf("bounded high zoom should return a controlled point set: mode=%s points=%d", viewport.Mode, len(viewport.Points)) } } func TestMonitorWorkspaceSharesOneRealtimeSnapshot(t *testing.T) { store := newCountingStore() service := NewService(store) workspace, err := service.MonitorWorkspace(context.Background(), url.Values{ "zoom": {"13"}, "railLimit": {"2"}, }) if err != nil { t.Fatalf("MonitorWorkspace returned error: %v", err) } if store.vehicleRealtimeCalls != 1 { t.Fatalf("workspace should read one realtime snapshot, calls=%d", store.vehicleRealtimeCalls) } if workspace.Summary.TotalVehicles == 0 || workspace.Map.Total == 0 { t.Fatalf("workspace should derive summary and map from the shared snapshot: %+v", workspace) } if len(workspace.Vehicles.Items) != 2 || workspace.Vehicles.Limit != 2 || workspace.Vehicles.Total < 2 { t.Fatalf("workspace rail page should honor its bounded limit: %+v", workspace.Vehicles) } } func TestMonitorMapUsesMeaningfulClustersAndReleasesPointsAtDetailZoom(t *testing.T) { vehicles := Page[VehicleRealtimeRow]{Items: []VehicleRealtimeRow{ {VIN: "NEAR-1", Plate: "粤A00001", Online: true, Longitude: 113.260, Latitude: 23.130}, {VIN: "NEAR-2", Plate: "粤A00002", Online: true, Longitude: 113.265, Latitude: 23.135}, {VIN: "SINGLE", Plate: "粤A00003", Online: true, Longitude: 113.600, Latitude: 23.500}, }, Total: 3, Limit: 3} mixed, err := buildMonitorMapResponse(vehicles, url.Values{"zoom": {"10"}}) if err != nil { t.Fatal(err) } if mixed.Mode != "mixed" || len(mixed.Clusters) != 1 || mixed.Clusters[0].Count != 2 || len(mixed.Points) != 1 || mixed.Points[0].VIN != "SINGLE" { t.Fatalf("zoom below the detail threshold must keep only a meaningful cluster and release its singleton: %+v", mixed) } detail, err := buildMonitorMapResponse(vehicles, url.Values{"zoom": {"11"}}) if err != nil { t.Fatal(err) } if detail.Mode != "points" || len(detail.Points) != 3 || len(detail.Clusters) != 0 { t.Fatalf("zoom 11 must release controlled data as exact points: %+v", detail) } } func BenchmarkMonitorMapTenThousandVehicles(b *testing.B) { vehicles := syntheticMonitorVehicles(10_000) query := url.Values{"zoom": {"5"}} b.ReportAllocs() b.ResetTimer() for index := 0; index < b.N; index++ { if _, err := buildMonitorMapResponse(vehicles, query); err != nil { b.Fatal(err) } } } func syntheticMonitorVehicles(count int) Page[VehicleRealtimeRow] { items := make([]VehicleRealtimeRow, count) for index := range items { items[index] = VehicleRealtimeRow{ VIN: "SYNTH" + strconv.Itoa(index), Online: true, SpeedKmh: float64(index % 90), Longitude: 73 + float64((index/100)%200)*0.3, Latitude: 18 + float64(index%100)*0.3, LastSeen: "2026-07-14T08:00:00+08:00", } } return Page[VehicleRealtimeRow]{Items: items, Total: count, Limit: count} } func TestKeyPointSamplingPreservesEventsAndMapsToReturnedIndexes(t *testing.T) { points := make([]HistoryLocationRow, 20) for index := range points { points[index].DeviceTime = strconv.Itoa(index) } sampled, indexes := sampleTrackPointsWithKeys(points, 6, []int{5, 11}) if len(sampled) != 6 || !containsInt(indexes, 0) || !containsInt(indexes, 5) || !containsInt(indexes, 11) || !containsInt(indexes, 19) { t.Fatalf("key-point sampling lost evidence: indexes=%+v", indexes) } mapped := nearestSampledIndex(11, indexes) if mapped < 0 || mapped >= len(indexes) || indexes[mapped] != 11 { t.Fatalf("event should map to its preserved returned point: mapped=%d indexes=%+v", mapped, indexes) } } func TestTrackCoverageDoesNotPresentLatestSliceAsCompleteWindow(t *testing.T) { coverage := trackCoverage(url.Values{"dateFrom": {"2026-07-01T00:00:00"}}, 71000, 5000, 4990, 1200, true, true) if coverage.Complete || coverage.TotalPoints != 71000 || coverage.FetchedPoints != 5000 || len(coverage.LimitReasons) != 3 { t.Fatalf("unexpected bounded coverage: %+v", coverage) } if !strings.Contains(coverage.Evidence, "不代表完整时间窗") { t.Fatalf("coverage boundary must be explicit: %+v", coverage) } } func TestTrackWindowRequiresBoundedOrderedPair(t *testing.T) { if err := validateTrackWindow("2026-07-01T00:00", ""); err == nil { t.Fatal("one-sided track window must be rejected") } if err := validateTrackWindow("2026-07-08T00:00", "2026-07-01T00:00"); err == nil { t.Fatal("reversed track window must be rejected") } if err := validateTrackWindow("2026-07-01T00:00", "2026-07-09T00:00"); err == nil { t.Fatal("track window over seven days must be rejected") } if err := validateTrackWindow("2026-07-01T00:00", "2026-07-08T00:00"); err != nil { t.Fatalf("seven-day track window should be accepted: %v", err) } if err := validateTrackWindow("", ""); err != nil { t.Fatalf("unbounded latest-slice compatibility mode should remain available: %v", err) } } func containsInt(values []int, wanted int) bool { for _, value := range values { if value == wanted { return true } } return false } func TestHistoryExportRunsAsControlledAsyncJob(t *testing.T) { service := NewService(NewMockStore()) job, err := service.CreateHistoryExport(HistoryExportRequest{Keywords: []string{"川AHTWO1"}, Category: "location", Metrics: []string{"speedKmh"}, Format: "csv"}) if err != nil { t.Fatalf("create export: %v", err) } deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { jobs := service.ListHistoryExports() if len(jobs) > 0 && jobs[0].ID == job.ID && jobs[0].Status == "completed" { path, _, fileErr := service.HistoryExportFile(job.ID) if fileErr != nil { t.Fatalf("export file: %v", fileErr) } body, readErr := os.ReadFile(path) if readErr != nil { t.Fatalf("read export: %v", readErr) } if len(body) < 3 || string(body[:3]) != "\xEF\xBB\xBF" { t.Fatalf("CSV should include UTF-8 BOM") } return } time.Sleep(10 * time.Millisecond) } t.Fatalf("export job did not complete: %+v", service.ListHistoryExports()) } type largeHistoryExportStore struct { *MockStore total int64 } func (s *largeHistoryExportStore) HistoryExportCount(context.Context, HistoryExportStoreQuery) (int64, error) { return s.total, nil } func (s *largeHistoryExportStore) HistoryExportBatch(_ context.Context, query HistoryExportStoreQuery, cursor HistoryExportCursor, limit int) ([]HistoryDataRow, HistoryExportCursor, error) { remaining := int(s.total) - cursor.Offset if remaining <= 0 { return []HistoryDataRow{}, cursor, nil } if remaining < limit { limit = remaining } rows := make([]HistoryDataRow, limit) for index := range rows { number := cursor.Offset + index rows[index] = HistoryDataRow{ID: "row-" + strconv.Itoa(number), VIN: query.VIN, Plate: "粤A测试", Protocol: "JT808", DeviceTime: "2026-07-14T00:00:00+08:00", ServerTime: "2026-07-14T00:00:01+08:00", Quality: "normal", Values: map[string]any{"speedKmh": float64(number % 120)}} } cursor.Offset += len(rows) return rows, cursor, nil } func TestHistoryExportStreamsBeyondLegacyLimit(t *testing.T) { assertLargeHistoryExport(t, 12_345, 10*time.Second) } func TestHistoryExportMillionRows(t *testing.T) { if os.Getenv("EXPORT_MILLION_TEST") != "1" { t.Skip("set EXPORT_MILLION_TEST=1 for the release capacity gate") } assertLargeHistoryExport(t, 1_000_000, 2*time.Minute) } func assertLargeHistoryExport(t *testing.T, total int64, timeout time.Duration) { t.Helper() dir := t.TempDir() store := &largeHistoryExportStore{MockStore: NewMockStore(), total: total} service := NewServiceWithRuntime(store, RuntimeInfo{ExportDir: dir}) job, err := service.CreateHistoryExport(HistoryExportRequest{Keywords: []string{"LNXNEGRR7SR318212"}, Category: "location", DateFrom: "2026-07-14T00:00", DateTo: "2026-07-14T06:00", Metrics: []string{"speedKmh"}, Format: "csv"}) if err != nil { t.Fatal(err) } deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { current := service.ListHistoryExports()[0] if current.Status == "failed" { t.Fatalf("export failed: %+v", current) } if current.Status == "completed" { if current.RowCount != int(total) || current.ProcessedRows != total || current.TotalRows != total || current.FileSizeBytes == 0 || current.CompletedAt == "" { t.Fatalf("incomplete evidence: %+v", current) } path, _, fileErr := service.HistoryExportFile(job.ID) if fileErr != nil { t.Fatal(fileErr) } if _, statErr := os.Stat(path + ".part"); !os.IsNotExist(statErr) { t.Fatalf("part file should be atomically removed: %v", statErr) } body, readErr := os.ReadFile(path) if readErr != nil { t.Fatal(readErr) } if !strings.Contains(string(body[:min(len(body), 512)]), "查询开始") || !strings.Contains(string(body[:min(len(body), 512)]), "速度(km/h)") { t.Fatalf("CSV metadata missing: %q", body[:min(len(body), 512)]) } return } time.Sleep(10 * time.Millisecond) } t.Fatalf("export timed out: %+v", service.ListHistoryExports()) } func TestRawMetricMetadataKeepsProtocolAndUnit(t *testing.T) { label, unit := rawMetricMetadata("jt808.location.additional.total_mileage_km") if label != "总里程 · JT808" || unit != "km" { t.Fatalf("unexpected RAW metric metadata: %q %q", label, unit) } } func TestBuildLatestTelemetryResponseUsesCatalogAndNewestSourceEvidence(t *testing.T) { now := time.Date(2026, 7, 14, 9, 30, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) definitions := []MetricDefinition{ {Key: "speed_kmh", Label: "速度", Description: "车辆最新行驶速度", Unit: "km/h", Category: "driving", ValueType: "numeric", SourceFields: map[string]string{"GB32960": "gb32960.vehicle.speed_kmh"}}, {Key: "alarm_active", Label: "协议告警位", Unit: "", Category: "safety", ValueType: "boolean", SourceFields: map[string]string{"GB32960": "gb32960.alarm.general_alarm_flag"}}, } frames := []RawFrameRow{ {ID: "new", VIN: "VIN001", Protocol: "GB32960", DeviceTime: "2026-07-14 09:29:58", ServerTime: "2026-07-14 09:29:59", ParseStatus: "ok", SourceEndpoint: "gateway-a", ParsedFields: map[string]any{"gb32960.vehicle.speed_kmh": 42.5, "gb32960.alarm.general_alarm_flag": float64(1), "vendor.custom_temperature_c": 31.2}}, {ID: "old", VIN: "VIN001", Protocol: "GB32960", DeviceTime: "2026-07-14 09:20:00", ServerTime: "2026-07-14 09:20:01", ParseStatus: "ok", ParsedFields: map[string]any{"gb32960.vehicle.speed_kmh": 10.0}}, } response := buildLatestTelemetryResponse("VIN001", frames, definitions, now) if response.VIN != "VIN001" || response.ScannedFrames != 2 || len(response.Values) != 3 || len(response.Categories) != 3 { t.Fatalf("unexpected response: %+v", response) } byKey := map[string]LatestTelemetryValue{} for _, value := range response.Values { byKey[value.Key] = value } if speed := byKey["speed_kmh"]; speed.Value != 42.5 || speed.SourceField != "gb32960.vehicle.speed_kmh" || speed.FrameID != "new" || speed.Quality != "good" || speed.FreshnessSeconds != 1 { t.Fatalf("unified speed should use newest frame and evidence: %+v", speed) } if alarm := byKey["alarm_active"]; alarm.Value != true || alarm.Category != "alarm" { t.Fatalf("catalog boolean should be normalized: %+v", alarm) } if extension := byKey["vendor.custom_temperature_c"]; extension.Category != "extension" || extension.Unit != "℃" || extension.Protocol != "GB32960" { t.Fatalf("manufacturer extension should retain source semantics: %+v", extension) } } func TestLatestTelemetryQualityDistinguishesStaleAndWarnings(t *testing.T) { now := time.Date(2026, 7, 14, 9, 30, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) stale, reason, freshness, delay := latestTelemetryQuality(RawFrameRow{DeviceTime: "2026-07-14 09:19:59", ServerTime: "2026-07-14 09:20:00", ParseStatus: "ok"}, now) if stale != "stale" || freshness != 600 || delay == nil || *delay != 1 || !strings.Contains(reason, "超过 5 分钟") { t.Fatalf("unexpected stale evidence: %s %s %d %v", stale, reason, freshness, delay) } warning, reason, _, _ := latestTelemetryQuality(RawFrameRow{ServerTime: "2026-07-14 09:29:59", ParseStatus: "failed", ParseError: "checksum"}, now) if warning != "warning" || !strings.Contains(reason, "checksum") { t.Fatalf("parse warning should retain reason: %s %s", warning, reason) } } type latestTelemetryQueryStore struct { *MockStore mu sync.Mutex queries []RawFrameQuery } func (s *latestTelemetryQueryStore) RawFrames(ctx context.Context, query RawFrameQuery) (Page[RawFrameRow], error) { s.mu.Lock() s.queries = append(s.queries, query) s.mu.Unlock() return s.MockStore.RawFrames(ctx, query) } func TestLatestTelemetryBoundsProtocolReads(t *testing.T) { store := &latestTelemetryQueryStore{MockStore: NewMockStore()} response, err := NewService(store).LatestTelemetry(t.Context(), "LNXNEGRR7SR318212") if err != nil { t.Fatal(err) } if len(response.Values) == 0 || response.ScannedFrames != 1 { t.Fatalf("expected one mock GB frame: %+v", response) } store.mu.Lock() defer store.mu.Unlock() if len(store.queries) != len(canonicalVehicleProtocols) { t.Fatalf("queries = %+v", store.queries) } protocols := map[string]bool{} for _, query := range store.queries { protocols[query.Protocol] = true if query.VIN != "LNXNEGRR7SR318212" || query.Limit != 5 || !query.IncludeFields || !query.SkipCount { t.Fatalf("unbounded latest query: %+v", query) } } for _, protocol := range canonicalVehicleProtocols { if !protocols[protocol] { t.Fatalf("missing protocol %s: %+v", protocol, store.queries) } } } func BenchmarkLatestTelemetryHundredFrames(b *testing.B) { now := time.Date(2026, 7, 14, 9, 30, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) definitions := metricDefinitions() frames := make([]RawFrameRow, 100) for frameIndex := range frames { fields := make(map[string]any, 50) for fieldIndex := 0; fieldIndex < 50; fieldIndex++ { fields[fmt.Sprintf("vendor.section_%02d.metric_%02d_voltage_v", fieldIndex/10, fieldIndex)] = float64(frameIndex + fieldIndex) } fields["gb32960.vehicle.speed_kmh"] = float64(frameIndex) frames[frameIndex] = RawFrameRow{ID: fmt.Sprintf("frame-%03d", frameIndex), VIN: "VIN001", Protocol: "GB32960", DeviceTime: "2026-07-14 09:29:58", ServerTime: "2026-07-14 09:29:59", ParseStatus: "ok", ParsedFields: fields} } b.ReportAllocs() b.ResetTimer() for range b.N { response := buildLatestTelemetryResponse("VIN001", frames, definitions, now) if len(response.Values) != 51 { b.Fatalf("values = %d", len(response.Values)) } } }