Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/api/internal/platform/service_test.go
2026-07-19 12:32:01 +08:00

1123 lines
50 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package platform
import (
"context"
"fmt"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
)
func exportAdminContext() context.Context {
return WithPrincipal(context.Background(), Principal{SubjectID: "1", Name: "平台管理员", Username: "admin", Role: "admin", UserType: "admin", AuthProvider: "local"})
}
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})
ctx := exportAdminContext()
jobs := restarted.ListHistoryExports(ctx)
if len(jobs) != 2 {
t.Fatalf("jobs=%+v", jobs)
}
path, _, err := restarted.HistoryExportFile(ctx, "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
lastVehicleQuery url.Values
lastRealtimeQuery url.Values
lastHistoryQuery url.Values
lastDailyMileageQuery url.Values
lastMileageStatisticsQuery url.Values
}
func newCountingStore() *countingStore {
return &countingStore{MockStore: NewMockStore()}
}
func (s *countingStore) Vehicles(ctx context.Context, query url.Values) (Page[VehicleRow], error) {
s.vehiclesCalls++
s.lastVehicleQuery = cloneValues(query)
return s.MockStore.Vehicles(ctx, query)
}
func (s *countingStore) VehicleRealtime(ctx context.Context, query url.Values) (Page[VehicleRealtimeRow], error) {
s.vehicleRealtimeCalls++
s.lastRealtimeQuery = cloneValues(query)
return s.MockStore.VehicleRealtime(ctx, query)
}
func (s *countingStore) HistoryLocationsFromTDengine(ctx context.Context, query url.Values) (Page[HistoryLocationRow], error) {
s.lastHistoryQuery = cloneValues(query)
return s.MockStore.HistoryLocationsFromTDengine(ctx, query)
}
func (s *countingStore) DailyMileage(ctx context.Context, query url.Values) (Page[DailyMileageRow], error) {
s.lastDailyMileageQuery = cloneValues(query)
return s.MockStore.DailyMileage(ctx, query)
}
func (s *countingStore) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) {
s.lastMileageStatisticsQuery = cloneValues(query)
return s.MockStore.MileageStatistics(ctx, query)
}
func TestCustomerVehicleScopeIsInjectedAndExplicitBypassIsDenied(t *testing.T) {
store := newCountingStore()
service := NewService(store)
principal := Principal{Name: "客户甲", Role: "customer", UserType: "customer", VehicleVINs: []string{"LB9A32A24R0LS1426"}}
ctx := WithPrincipal(context.Background(), principal)
if _, err := service.Vehicles(ctx, url.Values{"limit": {"20"}}); err != nil {
t.Fatalf("scoped vehicle list failed: %v", err)
}
if got := store.lastVehicleQuery.Get("scopeVins"); got != "LB9A32A24R0LS1426" {
t.Fatalf("scopeVins=%q", got)
}
_, err := service.VehicleRealtime(ctx, url.Values{"vin": {"LMRKH9AC2R1004087"}})
if clientErr, ok := asClientError(err); !ok || clientErr.Code != "VEHICLE_PERMISSION_DENIED" {
t.Fatalf("cross-vehicle query should be forbidden, err=%v", err)
}
}
func TestCustomerHistoryIsClampedToVehicleGrantStart(t *testing.T) {
store := newCountingStore()
service := NewService(store)
validFrom := time.Date(2026, 7, 10, 12, 34, 56, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
principal := Principal{
Name: "客户甲", Role: "customer", UserType: "customer",
VehicleVINs: []string{"LB9A32A24R0LS1426"},
VehicleGrants: []VehicleGrant{{VIN: "LB9A32A24R0LS1426", ValidFrom: validFrom}},
}
ctx := WithPrincipal(context.Background(), principal)
_, err := service.HistoryLocations(ctx, url.Values{
"vin": {"LB9A32A24R0LS1426"},
"dateFrom": {"2026-07-01T00:00:00+08:00"},
"dateTo": {"2026-07-11T00:00:00+08:00"},
})
if err != nil {
t.Fatalf("history query failed: %v", err)
}
if got := store.lastHistoryQuery.Get("dateFrom"); got != validFrom.Format(time.RFC3339) {
t.Fatalf("dateFrom=%q want=%q", got, validFrom.Format(time.RFC3339))
}
}
func TestCustomerHistoryBeforeGrantIsForbidden(t *testing.T) {
service := NewService(newCountingStore())
validFrom := time.Date(2026, 7, 10, 12, 34, 56, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
ctx := WithPrincipal(context.Background(), Principal{
Name: "客户甲", Role: "customer", UserType: "customer",
VehicleVINs: []string{"LB9A32A24R0LS1426"},
VehicleGrants: []VehicleGrant{{VIN: "LB9A32A24R0LS1426", ValidFrom: validFrom}},
})
_, err := service.HistoryLocations(ctx, url.Values{
"vin": {"LB9A32A24R0LS1426"},
"dateFrom": {"2026-07-01T00:00:00+08:00"},
"dateTo": {"2026-07-10T12:34:56+08:00"},
})
clientErr, ok := asClientError(err)
if !ok || clientErr.Code != "HISTORY_BEFORE_AUTHORIZATION" {
t.Fatalf("expected HISTORY_BEFORE_AUTHORIZATION, err=%v", err)
}
}
func TestCustomerMileageStartsAtFirstCompleteAuthorizedDay(t *testing.T) {
store := newCountingStore()
service := NewService(store)
validFrom := time.Date(2026, 7, 10, 12, 34, 56, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
ctx := WithPrincipal(context.Background(), Principal{
Name: "客户甲", Role: "customer", UserType: "customer",
VehicleVINs: []string{"LB9A32A24R0LS1426"},
VehicleGrants: []VehicleGrant{{VIN: "LB9A32A24R0LS1426", ValidFrom: validFrom}},
})
if _, err := service.DailyMileage(ctx, url.Values{
"vins": {"LB9A32A24R0LS1426"},
"dateFrom": {"2026-07-01"},
"dateTo": {"2026-07-15"},
}); err != nil {
t.Fatalf("daily mileage failed: %v", err)
}
if got := store.lastDailyMileageQuery.Get("scopeVehicleFrom"); got != `{"LB9A32A24R0LS1426":"2026-07-11"}` {
t.Fatalf("scopeVehicleFrom=%q", got)
}
}
func TestCustomerMileageFailsClosedWithoutGrantTime(t *testing.T) {
service := NewService(newCountingStore())
ctx := WithPrincipal(context.Background(), Principal{
Name: "客户甲", Role: "customer", UserType: "customer",
VehicleVINs: []string{"LB9A32A24R0LS1426"},
})
_, err := service.MileageStatistics(ctx, url.Values{
"vins": {"LB9A32A24R0LS1426"},
"dateFrom": {"2026-07-01"},
"dateTo": {"2026-07-15"},
})
clientErr, ok := asClientError(err)
if !ok || clientErr.Code != "HISTORY_SCOPE_UNAVAILABLE" {
t.Fatalf("expected HISTORY_SCOPE_UNAVAILABLE, err=%v", err)
}
}
func TestCustomerSourceEvidenceUsesOnlyCompleteAuthorizedDays(t *testing.T) {
service := NewService(newCountingStore())
shanghai := time.FixedZone("Asia/Shanghai", 8*60*60)
validFrom := time.Date(2026, 7, 10, 12, 34, 56, 0, shanghai)
validTo := time.Date(2026, 7, 15, 10, 0, 0, 0, shanghai)
ctx := WithPrincipal(context.Background(), Principal{
Name: "客户甲", Role: "customer", UserType: "customer",
VehicleVINs: []string{"LB9A32A24R0LS1426"},
VehicleGrants: []VehicleGrant{{VIN: "LB9A32A24R0LS1426", ValidFrom: validFrom, ValidTo: &validTo}},
})
if _, err := service.VehicleSourceEvidence(ctx, "LB9A32A24R0LS1426", "2026-07-10"); err == nil {
t.Fatal("partial authorization start day should not expose daily source evidence")
} else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "HISTORY_BEFORE_AUTHORIZATION" {
t.Fatalf("expected HISTORY_BEFORE_AUTHORIZATION, err=%v", err)
}
if _, err := service.VehicleSourceEvidence(ctx, "LB9A32A24R0LS1426", "2026-07-11"); err != nil {
t.Fatalf("first complete authorization day should be allowed: %v", err)
}
if _, err := service.VehicleSourceEvidence(ctx, "LB9A32A24R0LS1426", "2026-07-15"); err == nil {
t.Fatal("partial authorization end day should not expose daily source evidence")
} else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "HISTORY_AFTER_AUTHORIZATION" {
t.Fatalf("expected HISTORY_AFTER_AUTHORIZATION, err=%v", err)
}
}
func TestVehicleSourceDiagnosticExposesOpaqueReferencesAndElectionReasons(t *testing.T) {
service := NewService(NewMockStore())
ctx := WithPrincipal(context.Background(), Principal{Name: "运维员", Role: "operator", UserType: "operator"})
diagnostic, err := service.VehicleSourceDiagnostic(ctx, "LB9A32A24R0LS1426")
if err != nil {
t.Fatalf("VehicleSourceDiagnostic returned error: %v", err)
}
if diagnostic.Policy.Version != 1 || diagnostic.Access == nil || diagnostic.Evidence.RecommendedLocationProtocol != "JT808" {
t.Fatalf("diagnostic lost access or policy evidence: %+v", diagnostic)
}
if diagnostic.RecommendationReason == "" || diagnostic.RefreshHint == "" {
t.Fatalf("diagnostic must explain recommendation and refresh boundary: %+v", diagnostic)
}
for _, source := range diagnostic.Evidence.LocationSources {
if len(source.SourceRef) != 64 || source.SelectionReason == "" || source.FirstSeenAt == "" {
t.Fatalf("source diagnostic is incomplete: %+v", source)
}
if strings.Contains(source.SourceRef, "jt808") || strings.Contains(source.SourceRef, "g7") {
t.Fatalf("source reference must stay opaque: %+v", source)
}
}
}
func TestVehicleSourcePolicyUpdateRequiresAdminAndUsesOptimisticVersion(t *testing.T) {
service := NewService(NewMockStore())
operator := WithPrincipal(context.Background(), Principal{Name: "运维员", Role: "operator", UserType: "operator"})
diagnostic, err := service.VehicleSourceDiagnostic(operator, "LB9A32A24R0LS1426")
if err != nil {
t.Fatal(err)
}
source := diagnostic.Evidence.LocationSources[1]
if _, err := service.UpdateVehicleSourcePolicy(operator, diagnostic.Evidence.VIN, VehicleSourcePolicyUpdate{
Version: diagnostic.Policy.Version, SourceRef: source.SourceRef, Enabled: false, Priority: source.Priority, Remark: "人工核验",
}); err == nil {
t.Fatal("operator must not modify source policy")
}
admin := WithPrincipal(context.Background(), Principal{Name: "平台管理员", Role: "admin", UserType: "admin"})
updated, err := service.UpdateVehicleSourcePolicy(admin, diagnostic.Evidence.VIN, VehicleSourcePolicyUpdate{
Version: diagnostic.Policy.Version, SourceRef: source.SourceRef, Enabled: false, Priority: source.Priority, Remark: "人工核验",
})
if err != nil {
t.Fatalf("admin source policy update failed: %v", err)
}
if updated.Policy.Version != 2 || len(updated.Policy.Audit) != 1 || updated.Policy.Audit[0].Actor != "平台管理员" {
t.Fatalf("source policy update must version and audit: %+v", updated.Policy)
}
if _, err := service.UpdateVehicleSourcePolicy(admin, diagnostic.Evidence.VIN, VehicleSourcePolicyUpdate{
Version: 2, SourceRef: source.SourceRef, ProviderName: "G7s",
Enabled: false, Priority: source.Priority,
}); err == nil {
t.Fatal("provider maintenance must require an authoritative evidence note")
}
updated, err = service.UpdateVehicleSourcePolicy(admin, diagnostic.Evidence.VIN, VehicleSourcePolicyUpdate{
Version: 2, SourceRef: source.SourceRef, ProviderName: "G7s",
ProviderEvidence: "GPS 运维终端清单 2026-07-16",
Enabled: false, Priority: source.Priority, Remark: "人工核验",
})
if err != nil {
t.Fatalf("admin provider update failed: %v", err)
}
if updated.Policy.Version != 3 || updated.Policy.Audit[0].ChangeType != "provider" {
t.Fatalf("provider update must share optimistic versioning and audit: %+v", updated.Policy)
}
if !strings.Contains(updated.Policy.Audit[0].Summary, "核验依据GPS 运维终端清单 2026-07-16") ||
strings.Contains(updated.Policy.Audit[0].Summary, "人工核验") {
t.Fatalf("provider evidence and policy remark must remain separate: %+v", updated.Policy.Audit[0])
}
providerFound := false
for _, item := range updated.Evidence.LocationSources {
if item.SourceRef == source.SourceRef && item.ProviderOverride == "G7s" && item.SourceLabel == "G7s" {
providerFound = true
}
}
if !providerFound {
t.Fatalf("provider update must immediately affect source evidence: %+v", updated.Evidence.LocationSources)
}
if _, err := service.UpdateVehicleSourcePolicy(admin, diagnostic.Evidence.VIN, VehicleSourcePolicyUpdate{
Version: 1, SourceRef: source.SourceRef, Enabled: true, Priority: source.Priority,
}); err == nil {
t.Fatal("stale source policy update must conflict")
}
}
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, MileageAvailable: true, SpeedKmh: 0, Longitude: 113.1, Latitude: 23.1},
{DeviceTime: "2026-07-03 10:30:00", TotalMileageKm: 112.5, MileageAvailable: true, SpeedKmh: 50, Longitude: 113.2, Latitude: 23.2},
}
summary := summarizeTrack(points)
if summary.DistanceKm != 12.5 || summary.DistanceMethod != "odometer" || summary.DurationSeconds != 1800 || summary.MaximumSpeedKmh != 50 {
t.Fatalf("unexpected track summary: %+v", summary)
}
}
func TestSummarizeTrackUsesCoordinatesWhenProtocolMileageIsMissing(t *testing.T) {
points := []HistoryLocationRow{
{Protocol: "JT808", DeviceTime: "2026-07-03 10:00:00", Longitude: 113.1, Latitude: 23.1},
{Protocol: "JT808", DeviceTime: "2026-07-03 10:01:00", Longitude: 113.101, Latitude: 23.101},
}
summary := summarizeTrack(points)
if summary.DistanceMethod != "coordinates" || summary.DistanceKm <= 0 {
t.Fatalf("missing protocol mileage must use an explicitly labelled coordinate distance: %+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 TestTrackPrimarySourcePrefersUsefulMovementOverHighFrequencyStaticHeartbeat(t *testing.T) {
points := make([]HistoryLocationRow, 0, 120)
for index := 0; index < 100; index++ {
points = append(points, HistoryLocationRow{
DeviceTime: time.Date(2026, 7, 16, 8, 0, index, 0, time.UTC).Format(time.RFC3339),
Protocol: "YUTONG_MQTT",
Longitude: 121.400000,
Latitude: 31.200000,
})
}
for index := 0; index < 20; index++ {
points = append(points, HistoryLocationRow{
DeviceTime: time.Date(2026, 7, 16, 8, index, 0, 0, time.UTC).Format(time.RFC3339),
Protocol: "JT808",
Longitude: 121.400000 + float64(index)*0.002,
Latitude: 31.200000 + float64(index)*0.001,
SpeedKmh: 36,
})
}
selected, protocol, alternate := selectTrackPrimarySource(points, "")
if protocol != "JT808" || len(selected) != 20 || alternate != 100 {
t.Fatalf("moving source must beat static heartbeat volume: protocol=%s selected=%d alternate=%d", protocol, len(selected), 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 TestMonitorMapIgnoresInvalidOptionalBounds(t *testing.T) {
vehicles := Page[VehicleRealtimeRow]{
Items: []VehicleRealtimeRow{{
VIN: "LTEST000000000001", Plate: "粤A00001", Online: true, LocationAvailable: true,
Longitude: 113.2, Latitude: 23.1, LastSeen: "2026-07-17T15:41:00+08:00",
}},
Total: 1,
}
result, err := buildMonitorMapResponse(vehicles, url.Values{"zoom": {"12"}, "bounds": {"105,31,103,29"}})
if err != nil {
t.Fatalf("optional invalid bounds must not fail the map: %v", err)
}
if result.Total != 1 || len(result.Points) != 1 {
t.Fatalf("invalid bounds should fall back to the bounded unfiltered map: %+v", result)
}
}
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) {
reportIntervalMs := int64(30000)
vehicles := Page[VehicleRealtimeRow]{Items: []VehicleRealtimeRow{
{VIN: "NEAR-1", Plate: "粤A00001", Online: true, LocationAvailable: true, Longitude: 113.260, Latitude: 23.130, ReportIntervalMs: &reportIntervalMs},
{VIN: "NEAR-2", Plate: "粤A00002", Online: true, LocationAvailable: true, Longitude: 113.265, Latitude: 23.135},
{VIN: "SINGLE", Plate: "粤A00003", Online: true, LocationAvailable: 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)
}
if detail.Points[0].ReportIntervalMs == nil || *detail.Points[0].ReportIntervalMs != reportIntervalMs {
t.Fatalf("map point must preserve the primary protocol report interval: %+v", detail.Points[0])
}
}
func TestMonitorMapNationwideReturnsScopedProvinceSeedsAndGridFallback(t *testing.T) {
vehicles := Page[VehicleRealtimeRow]{Items: []VehicleRealtimeRow{
{VIN: "GUANGDONG-1", Online: true, LocationAvailable: true, Longitude: 113.260, Latitude: 23.130},
{VIN: "GUANGDONG-2", Online: false, LocationAvailable: true, Longitude: 113.265, Latitude: 23.135, LastSeen: "2026-07-15T08:00:00+08:00"},
{VIN: "OUTSIDE-STATUS", Online: true, LocationAvailable: true, Longitude: 121.47, Latitude: 31.23, SpeedKmh: 20},
{VIN: "NO-LOCATION", Online: true},
}, Total: 4, Limit: 4}
result, err := buildMonitorMapResponse(vehicles, url.Values{
"zoom": {"5"},
"status": {"offline"},
"bounds": {"113,22,114,24"},
})
if err != nil {
t.Fatal(err)
}
if result.Mode != "provinces" || result.Total != 1 || len(result.ProvincePoints) != 1 {
t.Fatalf("nationwide response must expose only filtered, located province seeds: %+v", result)
}
if result.ProvincePoints[0].Status != "offline" || result.ProvincePoints[0].Longitude != 113.265 {
t.Fatalf("unexpected province seed: %+v", result.ProvincePoints[0])
}
if len(result.Points)+len(result.Clusters) == 0 {
t.Fatal("nationwide response must retain a grid fallback when AMap district data is unavailable")
}
}
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),
LocationAvailable: true, 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())
ctx := exportAdminContext()
job, err := service.CreateHistoryExport(ctx, 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(ctx)
if len(jobs) > 0 && jobs[0].ID == job.ID && jobs[0].Status == "completed" {
path, _, fileErr := service.HistoryExportFile(ctx, 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")
}
if !strings.Contains(string(body), "数据质量,质量原因,证据ID") {
t.Fatalf("CSV should explain platform quality: %q", body[:min(len(body), 512)])
}
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("export job did not complete: %+v", service.ListHistoryExports(ctx))
}
func exportCustomerContext(subject, username, vin string) context.Context {
return WithPrincipal(context.Background(), Principal{
SubjectID: subject, Name: username, Username: username, Role: "customer", UserType: "customer", AuthProvider: "local",
CustomerRef: "customer-" + subject, VehicleVINs: []string{vin},
VehicleGrants: []VehicleGrant{{VIN: vin, ValidFrom: time.Date(2026, 7, 1, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))}},
})
}
func TestHistoryExportsAreOwnerScopedAndPersistAuditMetadata(t *testing.T) {
dir := t.TempDir()
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
vin := "LNXNEGRR7SR318212"
customerA := exportCustomerContext("101", "customer-a", vin)
customerB := exportCustomerContext("102", "customer-b", vin)
job, err := service.CreateHistoryExport(customerA, HistoryExportRequest{
Keywords: []string{vin}, Category: "location", DateFrom: "2026-07-14T00:00", DateTo: "2026-07-14T06:00", Metrics: []string{"speedKmh"}, Format: "csv",
})
if err != nil {
t.Fatal(err)
}
if job.OwnerID != "local:subject:101" || job.OwnerUsername != "customer-a" || job.CustomerRef != "customer-101" || len(job.VehicleScopes) != 1 || job.VehicleScopes[0].VIN != vin {
t.Fatalf("owner/scope audit metadata missing: %+v", job)
}
if jobs := service.ListHistoryExports(customerB); len(jobs) != 0 {
t.Fatalf("customer B saw customer A export: %+v", jobs)
}
if _, _, err := service.HistoryExportFile(customerB, job.ID); err == nil {
t.Fatal("customer B should not download customer A export")
}
if jobs := service.ListHistoryExports(exportAdminContext()); len(jobs) != 1 || jobs[0].ID != job.ID {
t.Fatalf("admin should audit all jobs: %+v", jobs)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
jobs := service.ListHistoryExports(customerA)
if len(jobs) == 1 && jobs[0].Status == "completed" {
path, _, fileErr := service.HistoryExportFile(customerA, job.ID)
if fileErr != nil {
t.Fatal(fileErr)
}
body, readErr := os.ReadFile(path)
if readErr != nil {
t.Fatal(readErr)
}
for _, evidence := range []string{"导出审计", "customer-a", "customer-101", "车辆 Scope", vin} {
if !strings.Contains(string(body), evidence) {
t.Fatalf("CSV missing audit evidence %q: %q", evidence, body[:min(len(body), 800)])
}
}
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("customer export did not complete: %+v", service.ListHistoryExports(customerA))
}
type revocableHistoryExportStore struct {
*MockStore
active bool
}
func (s *revocableHistoryExportStore) HistoryExportScopeActive(context.Context, HistoryExportJob) (bool, error) {
return s.active, nil
}
func TestCustomerHistoryExportStopsWhenAuthorizationIsRevoked(t *testing.T) {
store := &revocableHistoryExportStore{MockStore: NewMockStore(), active: false}
service := NewServiceWithRuntime(store, RuntimeInfo{ExportDir: t.TempDir()})
ctx := exportCustomerContext("101", "customer-a", "LNXNEGRR7SR318212")
job, err := service.CreateHistoryExport(ctx, 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(2 * time.Second)
for time.Now().Before(deadline) {
jobs := service.ListHistoryExports(ctx)
if len(jobs) == 1 && jobs[0].Status == "failed" {
if !strings.Contains(jobs[0].Error, "授权已变化") {
t.Fatalf("unexpected revoke evidence: %+v", jobs[0])
}
if _, _, fileErr := service.HistoryExportFile(ctx, job.ID); fileErr == nil {
t.Fatal("revoked customer should not download export")
}
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("revoked export did not stop: %+v", service.ListHistoryExports(ctx))
}
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})
ctx := exportAdminContext()
job, err := service.CreateHistoryExport(ctx, 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(ctx)[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(ctx, 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(ctx))
}
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 TestHistoryQualityReasonsExplainLocationRawAndMileageWarnings(t *testing.T) {
quality, reason := historyLocationQuality(HistoryLocationRow{Longitude: 0, Latitude: 0, SpeedKmh: 30, DeviceTime: "2026-07-14 09:29:58", ServerTime: "2026-07-14 09:29:59"})
if quality != "warning" || !strings.Contains(reason, "坐标") {
t.Fatalf("invalid coordinate should be explained: %s %s", quality, reason)
}
quality, reason = historyRawQuality(RawFrameRow{DeviceTime: "2026-07-14 09:29:58", ServerTime: "2026-07-14 09:29:59", ParseStatus: "failed", ParseError: "checksum"})
if quality != "warning" || !strings.Contains(reason, "checksum") {
t.Fatalf("parse failure should retain detail: %s %s", quality, reason)
}
quality, reason = historyMileageQuality(DailyMileageRow{StartMileageKm: 100, EndMileageKm: 130, DailyMileageKm: 12})
if quality != "warning" || !strings.Contains(reason, "不一致") {
t.Fatalf("mileage mismatch should be explained: %s %s", quality, reason)
}
}
func TestMergeDiscoveredRawMetricsScansFetchedScope(t *testing.T) {
rows := []HistoryDataRow{
{Values: map[string]any{"frameType": "realtime"}},
{Values: map[string]any{"gb32960.vehicle.soc_percent": 88}},
}
columns := mergeDiscoveredRawMetrics(historyRawMetrics(), rows)
found := false
for _, column := range columns {
if column.Key == "gb32960.vehicle.soc_percent" {
found = true
break
}
}
if !found {
t.Fatalf("discovered field outside the visible page should remain selectable: %+v", columns)
}
}
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"}},
{Key: "hydrogen_concentration_percent", Label: "最高氢浓度", Unit: "%", Category: "fuel-cell", ValueType: "numeric", SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.max_hydrogen_concentration_percent"}},
}
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), "gb32960.fuel_cell.max_hydrogen_concentration_percent": 0.032, "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) != 4 || len(response.Categories) != 4 {
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 hydrogen := byKey["hydrogen_concentration_percent"]; hydrogen.Value != 0.032 || hydrogen.Category != "fuel-cell" || hydrogen.Unit != "%" {
t.Fatalf("fuel-cell catalog metric should retain its business category: %+v", hydrogen)
}
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 TestGB32960ReferenceFormatsStatusAndHistoryMetadata(t *testing.T) {
if display := protocolDisplayValue("gb32960.vehicle.vehicle_status", float64(1)); display != "启动1" {
t.Fatalf("vehicle status display = %q", display)
}
if display := protocolDisplayValue("gb32960.position.position_status", 6); display != "有效定位 · 南纬 · 西经6" {
t.Fatalf("position status display = %q", display)
}
columns := mergeDiscoveredRawMetrics(historyRawMetrics(), []HistoryDataRow{{Values: map[string]any{
"gb32960.drive_motor.motors.motor_1.state": 2,
}}})
for _, column := range columns {
if column.Key == "gb32960.drive_motor.motors.motor_1.state" {
if column.Label != "驱动电机状态 · GB32960" || len(column.ValueMappings) == 0 || column.ValueMappings[1].Label != "发电" {
t.Fatalf("unexpected protocol reference column: %+v", column)
}
return
}
}
t.Fatalf("GB32960 reference column missing: %+v", columns)
}
func TestBuildLatestTelemetryResponseRetainsSameMetricAcrossProtocols(t *testing.T) {
now := time.Date(2026, 7, 14, 9, 30, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
definitions := []MetricDefinition{{
Key: "total_mileage_km", Label: "总里程", Description: "车辆累计行驶里程", Unit: "km", Category: "driving", ValueType: "numeric",
SourceFields: map[string]string{"GB32960": "gb32960.vehicle.total_mileage_km", "JT808": "jt808.location.total_mileage_km"},
}}
frames := []RawFrameRow{
{ID: "gb", VIN: "VIN001", Protocol: "GB32960", ServerTime: "2026-07-14 09:29:59", ParseStatus: "ok", ParsedFields: map[string]any{"gb32960.vehicle.total_mileage_km": 1000.0}},
{ID: "jt", VIN: "VIN001", Protocol: "JT808", ServerTime: "2026-07-14 09:29:59", ParseStatus: "ok", ParsedFields: map[string]any{"jt808.location.total_mileage_km": 980.0}},
}
response := buildLatestTelemetryResponse("VIN001", frames, definitions, now)
if len(response.Values) != 2 {
t.Fatalf("same unified metric from two protocols must be retained independently: %+v", response.Values)
}
byProtocol := map[string]LatestTelemetryValue{}
for _, value := range response.Values {
byProtocol[value.Protocol] = value
}
if byProtocol["GB32960"].Label != "仪表盘总里程" || byProtocol["JT808"].Label != "GPS 总里程" {
t.Fatalf("mileage semantics were not preserved: %+v", byProtocol)
}
}
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))
}
}
}