package main import ( "context" "net/http" "net/http/httptest" "strings" "testing" "time" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/capacity" ) func TestParseEndpoints(t *testing.T) { endpoints := parseEndpoints("gateway=http://127.0.0.1:20211/metrics, fast=http://127.0.0.1:20215/metrics") if len(endpoints) != 2 { t.Fatalf("len = %d, want 2: %#v", len(endpoints), endpoints) } if endpoints[0].Name != "gateway" || endpoints[0].URL != "http://127.0.0.1:20211/metrics" { t.Fatalf("endpoint[0] = %#v", endpoints[0]) } if endpoints[1].Name != "fast" || endpoints[1].URL != "http://127.0.0.1:20215/metrics" { t.Fatalf("endpoint[1] = %#v", endpoints[1]) } } func TestParseEndpointsUsesDefaultMetricsURLWhenOnlyBaseURLIsProvided(t *testing.T) { endpoints := parseEndpoints("gateway=http://127.0.0.1:20211") if len(endpoints) != 1 { t.Fatalf("len = %d, want 1", len(endpoints)) } if !strings.HasSuffix(endpoints[0].URL, "/metrics") { t.Fatalf("url = %q, want /metrics suffix", endpoints[0].URL) } } func TestDefaultEndpointsSplitRealtimeAPIAndWriter(t *testing.T) { endpoints := parseEndpoints(defaultEndpoints) byName := map[string]string{} for _, endpoint := range endpoints { byName[endpoint.Name] = endpoint.URL } if got, want := byName["realtime"], "http://127.0.0.1:20216/metrics"; got != want { t.Fatalf("realtime endpoint = %q, want %q", got, want) } if got, want := byName["realtime-api"], "http://127.0.0.1:20200/metrics"; got != want { t.Fatalf("realtime-api endpoint = %q, want %q", got, want) } if got, want := byName["identity"], "http://127.0.0.1:20217/metrics"; got != want { t.Fatalf("identity endpoint = %q, want %q", got, want) } if got, want := byName["fields-projector"], "http://127.0.0.1:20218/metrics"; got != want { t.Fatalf("fields-projector endpoint = %q, want %q", got, want) } services := strings.Join(parseRequiredServices(defaultRequiredServices), ",") for _, want := range []string{"realtime", "identity", "realtime-api", "fields-projector"} { if !strings.Contains(services, want) { t.Fatalf("required services %q missing %q", services, want) } } } func TestParseRequiredConsumerTopics(t *testing.T) { parsed := parseRequiredConsumerTopics("history=a|b|a; stat = c | d ; ignored") if got := strings.Join(parsed["history"], ","); got != "a,b" { t.Fatalf("history topics = %q, want a,b", got) } if got := strings.Join(parsed["stat"], ","); got != "c,d" { t.Fatalf("stat topics = %q, want c,d", got) } if _, exists := parsed["ignored"]; exists { t.Fatalf("unexpected ignored service in %#v", parsed) } } func TestParseRequiredConsumerTopicsReturnsNilForEmptyValue(t *testing.T) { if parsed := parseRequiredConsumerTopics(" "); parsed != nil { t.Fatalf("parsed = %#v, want nil", parsed) } } func TestParseRequiredServicesDeduplicatesAndPreservesOrder(t *testing.T) { services := parseRequiredServices("gateway, history, gateway, stat") if got := strings.Join(services, ","); got != "gateway,history,stat" { t.Fatalf("services = %q, want gateway,history,stat", got) } } func TestParseRequiredServicesReturnsNilForEmptyValue(t *testing.T) { if services := parseRequiredServices(" "); services != nil { t.Fatalf("services = %#v, want nil", services) } } func TestRequiredServiceFindingsReportsMissingEndpoint(t *testing.T) { findings := requiredServiceFindings( []endpoint{{Name: "gateway", URL: "http://127.0.0.1:20211/metrics"}}, map[string]string{"gateway": "vehicle_service_info 1"}, []string{"gateway", "history"}, ) if len(findings) != 1 || findings[0] != "required service endpoint missing: history" { t.Fatalf("findings = %#v", findings) } } func TestRequiredServiceFindingsReportsConfiguredButUnscrapedService(t *testing.T) { findings := requiredServiceFindings( []endpoint{ {Name: "gateway", URL: "http://127.0.0.1:20211/metrics"}, {Name: "history", URL: "http://127.0.0.1:20212/metrics"}, }, map[string]string{"gateway": "vehicle_service_info 1"}, []string{"gateway", "history"}, ) if len(findings) != 1 || findings[0] != "required service metrics missing: history" { t.Fatalf("findings = %#v", findings) } } func TestRequiredServiceFindingsCanBeDisabled(t *testing.T) { findings := requiredServiceFindings(nil, nil, nil) if len(findings) != 0 { t.Fatalf("findings = %#v, want none", findings) } } func TestFormatRequiredConsumerTopicsIsStable(t *testing.T) { formatted := formatRequiredConsumerTopics(map[string][]string{ "stat": {"vehicle.fields.go.jt808.v1", "vehicle.fields.go.gb32960.v1"}, "history": {"vehicle.raw.go.jt808.v1"}, }) want := "history=vehicle.raw.go.jt808.v1;stat=vehicle.fields.go.gb32960.v1|vehicle.fields.go.jt808.v1" if formatted != want { t.Fatalf("formatted = %q, want %q", formatted, want) } } func TestDailyMileageDiagnosticsRequestURLUsesDefaultPathAndDate(t *testing.T) { requestURL, err := dailyMileageDiagnosticsRequestURL("http://127.0.0.1:20200", "2026-07-12") if err != nil { t.Fatalf("request URL error: %v", err) } if !strings.Contains(requestURL, defaultDailyMileageDiagnosticsPath) { t.Fatalf("url = %q, want default diagnostics path", requestURL) } if !strings.Contains(requestURL, "date=2026-07-12") { t.Fatalf("url = %q, want date query", requestURL) } } func TestDailyMileageFieldStatusRequestURLDerivesFromReasonsPath(t *testing.T) { requestURL, err := dailyMileageFieldStatusRequestURL("http://127.0.0.1:20200/api/stats/daily-metrics/diagnostics/reasons?severity=source_data", "2026-07-12") if err != nil { t.Fatalf("request URL error: %v", err) } for _, want := range []string{ defaultDailyMileageFieldStatusPath, "date=2026-07-12", "severity=source_data", } { if !strings.Contains(requestURL, want) { t.Fatalf("url = %q, want %q", requestURL, want) } } if strings.Contains(requestURL, "/reasons") { t.Fatalf("url = %q should not keep /reasons", requestURL) } } func TestCollectDailyMileageDiagnostics(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.URL.Query().Get("date"); got != "2026-07-12" { t.Fatalf("date = %q, want 2026-07-12", got) } w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case defaultDailyMileageDiagnosticsPath: _, _ = w.Write([]byte(`{ "items":[ {"stat_date":"2026-07-12","protocol":"YUTONG_MQTT","diagnosis":"NO_SOURCE_SAMPLE","reason":"realtime_location_has_total_mileage_but_no_stat_sample","count":7}, {"stat_date":"2026-07-12","protocol":"JT808","diagnosis":"NO_TOTAL_MILEAGE","reason":"realtime_total_mileage_non_positive","count":2} ], "vehicle_total":349, "actionable_issue_total":9 }`)) case defaultDailyMileageFieldStatusPath: _, _ = w.Write([]byte(`{ "items":[ {"stat_date":"2026-07-12","protocol":"JT808","diagnosis":"NO_TOTAL_MILEAGE","reason":"realtime_total_mileage_non_positive","realtime_mileage_field_status":"standard_field_non_positive","count":2,"severity":"source_data","field_status_severity":"source_data","field_status_action":"源头总里程小于等于 0"} ], "total":1, "vehicle_total":349, "actionable_issue_total":9, "pipeline_issue_total":7, "source_data_issue_total":2 }`)) default: t.Fatalf("unexpected path %q", r.URL.Path) } })) defer server.Close() diagnostics, findings := collectDailyMileageDiagnostics(context.Background(), server.URL, "2026-07-12", time.Second, 0) if len(findings) != 0 { t.Fatalf("findings = %#v, want none", findings) } if diagnostics == nil { t.Fatal("diagnostics = nil") } if diagnostics.VehicleTotal != 349 || diagnostics.ActionableIssueTotal != 9 { t.Fatalf("diagnostics = %#v", diagnostics) } if diagnostics.PipelineIssueTotal != 7 || diagnostics.SourceDataIssueTotal != 2 { t.Fatalf("issue split = pipeline:%d source:%d", diagnostics.PipelineIssueTotal, diagnostics.SourceDataIssueTotal) } if len(diagnostics.Items) != 2 || diagnostics.Items[0].Severity != "pipeline" || diagnostics.Items[1].Severity != "source_data" { t.Fatalf("items = %#v", diagnostics.Items) } if diagnostics.FieldStatus == nil || diagnostics.FieldStatus.SummaryTotal != 1 || diagnostics.FieldStatus.Items[0].RealtimeMileageFieldStatus != "standard_field_non_positive" { t.Fatalf("field status = %#v", diagnostics.FieldStatus) } } func TestCollectDailyMileageDiagnosticsIncludesIssueSamples(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case defaultDailyMileageDiagnosticsPath: _, _ = w.Write([]byte(`{ "items":[ {"stat_date":"2026-07-12","protocol":"JT808","diagnosis":"NO_TOTAL_MILEAGE","reason":"realtime_total_mileage_non_positive","count":2}, {"stat_date":"2026-07-12","protocol":"YUTONG_MQTT","diagnosis":"NO_TOTAL_MILEAGE","reason":"realtime_total_mileage_missing","count":1} ], "vehicle_total":3, "actionable_issue_total":3 }`)) case "/api/stats/daily-metrics/diagnostics": if got := r.URL.Query().Get("date"); got != "2026-07-12" { t.Fatalf("date = %q, want 2026-07-12", got) } if got := r.URL.Query().Get("limit"); got == "" { t.Fatal("sample detail request missing limit") } switch r.URL.Query().Get("protocol") { case "JT808": _, _ = w.Write([]byte(`{"items":[{"vin":"LKLG7C4E1NA774752","stat_date":"2026-07-12","protocol":"JT808","plate":"沪A03086F","peer":"222.66.200.68:12962","realtime_total_mileage_km":0,"realtime_field_count":23,"realtime_sample_fields":["jt808.location.latitude","jt808.location.speed_kmh"],"realtime_mileage_field_status":"standard_field_non_positive","realtime_mileage_evidence":"标准里程字段为 0","raw_frame_query_path":"/api/history/raw-frames?protocol=JT808&vin=LKLG7C4E1NA774752","source_sample_query_path":"/api/stats/daily-metrics/sources?protocol=JT808&vin=LKLG7C4E1NA774752","diagnosis":"NO_TOTAL_MILEAGE","reason":"realtime_total_mileage_non_positive"}]}`)) case "YUTONG_MQTT": _, _ = w.Write([]byte(`{"items":[{"vin":"LMRKH9AC9R1004121","stat_date":"2026-07-12","protocol":"YUTONG_MQTT","peer":"mqtt://yutong/ytforward/shln/3","realtime_field_count":27,"realtime_sample_fields":["yutong_mqtt.data.latitude","yutong_mqtt.data.meter_speed"],"realtime_mileage_field_status":"no_candidate_mileage_field","realtime_mileage_evidence":"未发现 mileage 字段","raw_frame_query_path":"/api/history/raw-frames?protocol=YUTONG_MQTT&vin=LMRKH9AC9R1004121","source_sample_query_path":"/api/stats/daily-metrics/sources?protocol=YUTONG_MQTT&vin=LMRKH9AC9R1004121","diagnosis":"NO_TOTAL_MILEAGE","reason":"realtime_total_mileage_missing"}]}`)) default: t.Fatalf("unexpected protocol %q", r.URL.Query().Get("protocol")) } case defaultDailyMileageFieldStatusPath: _, _ = w.Write([]byte(`{ "items":[ {"stat_date":"2026-07-12","protocol":"JT808","diagnosis":"NO_TOTAL_MILEAGE","reason":"realtime_total_mileage_non_positive","realtime_mileage_field_status":"standard_field_non_positive","count":2,"field_status_severity":"source_data"} ], "total":1, "vehicle_total":3, "actionable_issue_total":3, "pipeline_issue_total":0, "source_data_issue_total":3 }`)) default: t.Fatalf("unexpected path %q", r.URL.Path) } })) defer server.Close() diagnostics, findings := collectDailyMileageDiagnostics(context.Background(), server.URL, "2026-07-12", time.Second, 2) if len(findings) != 0 { t.Fatalf("findings = %#v, want none", findings) } if diagnostics == nil { t.Fatal("diagnostics = nil") } if len(diagnostics.Samples) != 2 { t.Fatalf("samples = %#v, want two", diagnostics.Samples) } if diagnostics.Samples[0].VIN != "LKLG7C4E1NA774752" || diagnostics.Samples[0].Severity != capacity.DailyMileageSeveritySourceData { t.Fatalf("first sample = %#v", diagnostics.Samples[0]) } if !strings.Contains(diagnostics.Samples[0].RecommendedOperatorAction, "总里程小于等于 0") { t.Fatalf("first sample recommended action = %q", diagnostics.Samples[0].RecommendedOperatorAction) } if diagnostics.Samples[0].RealtimeFieldCount != 23 || diagnostics.Samples[0].RealtimeMileageFieldStatus != "standard_field_non_positive" { t.Fatalf("first sample field diagnostics lost: %#v", diagnostics.Samples[0]) } if diagnostics.Samples[0].RawFrameQueryPath == "" || diagnostics.Samples[0].SourceSampleQueryPath == "" { t.Fatalf("first sample trace links lost: %#v", diagnostics.Samples[0]) } if diagnostics.Samples[1].VIN != "LMRKH9AC9R1004121" || diagnostics.Samples[1].Severity != capacity.DailyMileageSeveritySourceData { t.Fatalf("second sample = %#v", diagnostics.Samples[1]) } if !strings.Contains(diagnostics.Samples[1].RecommendedOperatorAction, "缺少总里程字段") { t.Fatalf("second sample recommended action = %q", diagnostics.Samples[1].RecommendedOperatorAction) } if len(diagnostics.Samples[1].RealtimeSampleFields) != 2 || diagnostics.Samples[1].RealtimeMileageEvidence == "" { t.Fatalf("second sample realtime evidence lost: %#v", diagnostics.Samples[1]) } if diagnostics.Samples[1].RawFrameQueryPath == "" || diagnostics.Samples[1].SourceSampleQueryPath == "" { t.Fatalf("second sample trace links lost: %#v", diagnostics.Samples[1]) } } func TestDailyMileageDiagnosticsSampleRequestURLDerivesDetailPath(t *testing.T) { requestURL, err := dailyMileageDiagnosticsSampleRequestURL( "http://127.0.0.1:20200/api/stats/daily-metrics/diagnostics/reasons?date=2026-07-11", "2026-07-12", capacity.DailyMileageDiagnosisReasonItem{Protocol: "jt808", Diagnosis: "no_total_mileage"}, 3, ) if err != nil { t.Fatalf("sample request URL error: %v", err) } for _, want := range []string{ "/api/stats/daily-metrics/diagnostics?", "date=2026-07-12", "protocol=JT808", "diagnosis=NO_TOTAL_MILEAGE", "limit=3", "offset=0", } { if !strings.Contains(requestURL, want) { t.Fatalf("url = %q, want %q", requestURL, want) } } } func TestDailyMileageDiagnosticFindingsCanGateOnlyPipelineIssues(t *testing.T) { diagnostics := &capacity.DailyMileageDiagnostics{ ActionableIssueTotal: 9, PipelineIssueTotal: 7, SourceDataIssueTotal: 2, } findings := dailyMileageDiagnosticFindings(diagnostics, -1, 0, -1) if len(findings) != 1 { t.Fatalf("findings = %#v, want one pipeline finding", findings) } if !strings.Contains(findings[0], "daily mileage pipeline issues 7 exceeds 0") { t.Fatalf("finding = %q, want pipeline threshold", findings[0]) } if strings.Contains(findings[0], "actionable issues") { t.Fatalf("pipeline-only gate should not use actionable threshold: %q", findings[0]) } } func TestDailyMileageDiagnosticFindingsReportsOnlyWhenPipelineIsCleanAndSourceDataExists(t *testing.T) { diagnostics := &capacity.DailyMileageDiagnostics{ ActionableIssueTotal: 3, PipelineIssueTotal: 0, SourceDataIssueTotal: 3, } findings := dailyMileageDiagnosticFindings(diagnostics, -1, 0, -1) if len(findings) != 0 { t.Fatalf("findings = %#v, want none because source-data issues should not block pipeline gate", findings) } } func TestSummarizeDailyMileageFieldStatusPrefersDiagnosisSeverity(t *testing.T) { actionable, pipeline, sourceData := summarizeDailyMileageFieldStatus([]capacity.DailyMileageFieldStatusSummaryItem{ { Diagnosis: "NO_TOTAL_MILEAGE", RealtimeMileageFieldStatus: "candidate_field_unmapped", FieldStatusSeverity: capacity.DailyMileageSeverityPipeline, Severity: capacity.DailyMileageSeveritySourceData, Count: 4, }, { Diagnosis: "NO_TOTAL_MILEAGE", RealtimeMileageFieldStatus: "no_candidate_mileage_field", FieldStatusSeverity: capacity.DailyMileageSeveritySourceData, Count: 3, }, { Diagnosis: "OK", RealtimeMileageFieldStatus: "daily_metric_exists", FieldStatusSeverity: capacity.DailyMileageSeverityOK, Count: 10, }, }) if actionable != 7 || pipeline != 0 || sourceData != 7 { t.Fatalf("summary actionable=%d pipeline=%d source=%d", actionable, pipeline, sourceData) } } func TestCollectDailyMileageDiagnosticsReportsHTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "mysql unavailable", http.StatusInternalServerError) })) defer server.Close() diagnostics, findings := collectDailyMileageDiagnostics(context.Background(), server.URL, "", time.Second, 0) if diagnostics != nil { t.Fatalf("diagnostics = %#v, want nil", diagnostics) } if len(findings) != 1 || !strings.Contains(findings[0], "status 500") { t.Fatalf("findings = %#v, want status 500", findings) } } func TestDailyMileageSourceQualityRequestURLUsesDefaultPathAndDateRange(t *testing.T) { requestURL, err := dailyMileageSourceQualityRequestURL("http://127.0.0.1:20200", "2026-07-12", 3) if err != nil { t.Fatalf("request URL error: %v", err) } for _, want := range []string{ defaultDailyMileageSourceQualityPath, "dateFrom=2026-07-12", "dateTo=2026-07-12", "includeTotal=true", "limit=3", "offset=0", } { if !strings.Contains(requestURL, want) { t.Fatalf("url = %q, want %q", requestURL, want) } } } func TestCollectDailyMileageSourceQuality(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != defaultDailyMileageSourceQualityPath { t.Fatalf("path = %q, want %q", r.URL.Path, defaultDailyMileageSourceQualityPath) } if got := r.URL.Query().Get("dateFrom"); got != "2026-07-12" { t.Fatalf("dateFrom = %q, want 2026-07-12", got) } if got := r.URL.Query().Get("dateTo"); got != "2026-07-12" { t.Fatalf("dateTo = %q, want 2026-07-12", got) } if got := r.URL.Query().Get("includeTotal"); got != "true" { t.Fatalf("includeTotal = %q, want true", got) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{ "items":[ {"stat_date":"2026-07-12","protocol":"JT808","quality_status":"OK","quality_reason":"historical_source_baseline","source_count":10,"vehicle_count":10,"selected_count":10,"sample_count":200,"daily_mileage_km":800.5}, {"stat_date":"2026-07-12","protocol":"GB32960","quality_status":"OK","quality_reason":"current_day_fallback_after_invalid_baseline","source_count":4,"vehicle_count":4,"selected_count":4,"sample_count":40,"daily_mileage_km":12.3}, {"stat_date":"2026-07-12","protocol":"JT808","quality_status":"INVALID_DELTA","quality_reason":"outside_daily_range","source_count":3,"vehicle_count":3,"selected_count":0,"sample_count":12,"daily_mileage_km":4000}, {"stat_date":"2026-07-12","protocol":"YUTONG_MQTT","quality_status":"NO_PREVIOUS_BASELINE","quality_reason":"current_day_first_sample","source_count":2,"vehicle_count":2,"selected_count":0,"sample_count":2,"daily_mileage_km":0}, {"stat_date":"2026-07-12","protocol":"YUTONG_MQTT","quality_status":"OK","quality_reason":"realtime_location_fallback_historical_baseline","source_count":5,"vehicle_count":5,"selected_count":5,"sample_count":5,"daily_mileage_km":0} ], "total":7, "limit":5, "offset":0 }`)) })) defer server.Close() diagnostics, findings := collectDailyMileageSourceQuality(context.Background(), server.URL, "2026-07-12", time.Second, 5) if len(findings) != 0 { t.Fatalf("findings = %#v, want none", findings) } if diagnostics == nil { t.Fatal("diagnostics = nil") } if diagnostics.SummaryTotal != 7 || !diagnostics.ItemsTruncated { t.Fatalf("summary total/truncated = %d/%v, want 7/true", diagnostics.SummaryTotal, diagnostics.ItemsTruncated) } if diagnostics.SourceCount != 24 || diagnostics.OKSourceCount != 19 || diagnostics.InvalidDeltaSourceCount != 3 || diagnostics.NoBaselineSourceCount != 2 || diagnostics.FallbackAfterInvalidBaselineSourceCount != 4 || diagnostics.RealtimeLocationFallbackSourceCount != 5 { t.Fatalf("source quality totals = %#v", diagnostics) } if diagnostics.VehicleCount != 24 || diagnostics.SampleCount != 259 { t.Fatalf("vehicle/sample totals = %d/%d", diagnostics.VehicleCount, diagnostics.SampleCount) } if len(diagnostics.Items) != 5 { t.Fatalf("items = %#v, want five", diagnostics.Items) } } func TestDailyMileageSourceQualityFindingsCanGateInvalidDelta(t *testing.T) { diagnostics := &capacity.DailyMileageSourceQualityDiagnostics{ InvalidDeltaSourceCount: 3, NoBaselineSourceCount: 2, } findings := dailyMileageSourceQualityFindings(diagnostics, 0, -1, -1, -1) if len(findings) != 1 || !strings.Contains(findings[0], "daily mileage source INVALID_DELTA count 3 exceeds 0") { t.Fatalf("findings = %#v", findings) } } func TestDailyMileageSourceQualityFindingsCanGateNoBaseline(t *testing.T) { diagnostics := &capacity.DailyMileageSourceQualityDiagnostics{ InvalidDeltaSourceCount: 3, NoBaselineSourceCount: 2, } findings := dailyMileageSourceQualityFindings(diagnostics, -1, 0, -1, -1) if len(findings) != 1 || !strings.Contains(findings[0], "daily mileage source NO_PREVIOUS_BASELINE count 2 exceeds 0") { t.Fatalf("findings = %#v", findings) } } func TestDailyMileageSourceQualityFindingsCanGateFallbackAfterInvalidBaseline(t *testing.T) { diagnostics := &capacity.DailyMileageSourceQualityDiagnostics{ FallbackAfterInvalidBaselineSourceCount: 4, } findings := dailyMileageSourceQualityFindings(diagnostics, -1, -1, 0, -1) if len(findings) != 1 || !strings.Contains(findings[0], "daily mileage source current-day fallback after invalid baseline count 4 exceeds 0") { t.Fatalf("findings = %#v", findings) } } func TestDailyMileageSourceQualityFindingsCanGateRealtimeLocationFallback(t *testing.T) { diagnostics := &capacity.DailyMileageSourceQualityDiagnostics{ RealtimeLocationFallbackSourceCount: 5, } findings := dailyMileageSourceQualityFindings(diagnostics, -1, -1, -1, 0) if len(findings) != 1 || !strings.Contains(findings[0], "daily mileage source realtime-location fallback count 5 exceeds 0") { t.Fatalf("findings = %#v", findings) } } func TestDailyMileageSourceSelectionRequestURLUsesDefaultPathAndDateRange(t *testing.T) { requestURL, err := dailyMileageSourceSelectionRequestURL("http://127.0.0.1:20200", "2026-07-12", 3) if err != nil { t.Fatalf("request URL error: %v", err) } for _, want := range []string{ defaultDailyMileageSourceSelectionPath, "dateFrom=2026-07-12", "dateTo=2026-07-12", "includeTotal=true", "limit=3", "offset=0", } { if !strings.Contains(requestURL, want) { t.Fatalf("url = %q, want %q", requestURL, want) } } } func TestDailyMileageSourceSelectionSampleRequestURLUsesSourceDetailPath(t *testing.T) { requestURL, err := dailyMileageSourceSelectionSampleRequestURL("http://127.0.0.1:20200/api/stats/daily-metrics/sources/selection", "2026-07-12", 3) if err != nil { t.Fatalf("request URL error: %v", err) } for _, want := range []string{ defaultDailyMileageSourcesPath, "dateFrom=2026-07-12", "dateTo=2026-07-12", "includeTotal=false", "selected=false", "orderBy=dailyMileageDesc", "limit=3", "offset=0", } { if !strings.Contains(requestURL, want) { t.Fatalf("url = %q, want %q", requestURL, want) } } if strings.Contains(requestURL, "/selection?") { t.Fatalf("url = %q, should use source detail path", requestURL) } } func TestCollectDailyMileageSourceSelection(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.URL.Query().Get("dateFrom"); got != "2026-07-12" { t.Fatalf("dateFrom = %q, want 2026-07-12", got) } if got := r.URL.Query().Get("dateTo"); got != "2026-07-12" { t.Fatalf("dateTo = %q, want 2026-07-12", got) } w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case defaultDailyMileageSourceSelectionPath: if got := r.URL.Query().Get("includeTotal"); got != "true" { t.Fatalf("includeTotal = %q, want true", got) } _, _ = w.Write([]byte(`{ "items":[ {"stat_date":"2026-07-12","protocol":"JT808","selection_status":"selected","selection_reason":"selected_current_projection","source_count":10,"vehicle_count":10,"selected_count":10,"sample_count":200,"daily_mileage_km":800.5}, {"stat_date":"2026-07-12","protocol":"JT808","selection_status":"not_selected","selection_reason":"unmanaged_source_lower_priority","source_count":3,"vehicle_count":3,"selected_count":0,"sample_count":12,"daily_mileage_km":40}, {"stat_date":"2026-07-12","protocol":"JT808","selection_status":"not_selected","selection_reason":"unknown_source_kind_lower_priority","source_count":2,"vehicle_count":2,"selected_count":0,"sample_count":8,"daily_mileage_km":20}, {"stat_date":"2026-07-12","protocol":"JT808","selection_status":"excluded","selection_reason":"invalid_delta","source_count":1,"vehicle_count":1,"selected_count":0,"sample_count":2,"daily_mileage_km":0} ], "total":5, "limit":4, "offset":0 }`)) case defaultDailyMileageSourcesPath: selected := r.URL.Query().Get("selected") if got := r.URL.Query().Get("includeTotal"); got != "false" { t.Fatalf("includeTotal = %q, want false", got) } if selected == "true" { if got := r.URL.Query().Get("vin"); got == "" { t.Fatalf("selected source query should include vin") } if got := r.URL.Query().Get("protocol"); got != "JT808" { t.Fatalf("selected source protocol = %q, want JT808", got) } _, _ = w.Write([]byte(`{ "items":[ {"vin":"` + r.URL.Query().Get("vin") + `","stat_date":"2026-07-12","protocol":"JT808","source_key":"JT808|source|selected","source_ip":"10.0.0.9","source_endpoint":"10.0.0.9:43625","phone":"13300000009","platform_name":"可信平台","source_code":"trusted","source_kind":"PLATFORM","daily_mileage_km":12,"sample_count":20,"quality_status":"OK","selection_status":"selected","selection_reason":"selected_current_projection","selection_action":"selected","updated_at":"2026-07-12T10:01:00+08:00"} ], "total":1, "limit":1, "offset":0 }`)) return } if selected != "false" { t.Fatalf("selected = %q, want false", selected) } if got := r.URL.Query().Get("orderBy"); got != "dailyMileageDesc" { t.Fatalf("orderBy = %q, want dailyMileageDesc", got) } _, _ = w.Write([]byte(`{ "items":[ {"vin":"VIN-UNKNOWN","stat_date":"2026-07-12","protocol":"JT808","source_key":"JT808|source|unknown","source_ip":"10.0.0.3","source_endpoint":"10.0.0.3:43625","phone":"13300000003","platform_name":"未知类型平台","source_code":"unknown","source_kind":"UNKNOWN","daily_mileage_km":30,"sample_count":4,"quality_status":"OK","selection_status":"not_selected","selection_reason":"unknown_source_kind_lower_priority","selection_action":"set_source_kind","updated_at":"2026-07-12T10:00:00+08:00"}, {"vin":"VIN-UNMANAGED","stat_date":"2026-07-12","protocol":"JT808","source_key":"JT808|source|unmanaged","source_ip":"10.0.0.2","source_endpoint":"10.0.0.2:43625","phone":"13300000002","platform_name":"未维护平台","source_kind":"PLATFORM","daily_mileage_km":20,"sample_count":3,"quality_status":"OK","selection_status":"not_selected","selection_reason":"unmanaged_source_lower_priority","selection_action":"create_vehicle_data_source","updated_at":"2026-07-12T10:00:00+08:00"}, {"vin":"VIN-LOW","stat_date":"2026-07-12","protocol":"JT808","source_key":"JT808|source|low","source_ip":"10.0.0.1","source_endpoint":"10.0.0.1:43625","phone":"13300000001","platform_name":"低优先级平台","source_code":"G7s","source_kind":"PLATFORM","daily_mileage_km":10,"sample_count":2,"quality_status":"OK","selection_status":"not_selected","selection_reason":"lower_trust_priority_or_sample_count","selection_action":"none","updated_at":"2026-07-12T10:00:00+08:00"} ], "total":3, "limit":40, "offset":0 }`)) default: t.Fatalf("path = %q, want source selection or source detail path", r.URL.Path) } })) defer server.Close() diagnostics, findings := collectDailyMileageSourceSelection(context.Background(), server.URL, "2026-07-12", time.Second, 4, 2) if len(findings) != 0 { t.Fatalf("findings = %#v, want none", findings) } if diagnostics == nil { t.Fatal("diagnostics = nil") } if diagnostics.SummaryTotal != 5 || !diagnostics.ItemsTruncated { t.Fatalf("summary total/truncated = %d/%v, want 5/true", diagnostics.SummaryTotal, diagnostics.ItemsTruncated) } if diagnostics.SourceCount != 16 || diagnostics.SelectedSourceCount != 10 || diagnostics.NotSelectedSourceCount != 5 || diagnostics.ExcludedSourceCount != 1 { t.Fatalf("selection totals = %#v", diagnostics) } if diagnostics.NotSelectedMileageKM != 60 { t.Fatalf("not selected mileage = %v, want 60", diagnostics.NotSelectedMileageKM) } if diagnostics.UnmanagedSourceCount != 3 || diagnostics.UnknownKindSourceCount != 2 { t.Fatalf("source management totals unmanaged=%d unknown=%d", diagnostics.UnmanagedSourceCount, diagnostics.UnknownKindSourceCount) } if diagnostics.VehicleCount != 16 || diagnostics.SampleCount != 222 { t.Fatalf("vehicle/sample totals = %d/%d", diagnostics.VehicleCount, diagnostics.SampleCount) } if len(diagnostics.Protocols) != 1 { t.Fatalf("protocols = %#v, want one JT808 summary", diagnostics.Protocols) } if got := diagnostics.Protocols[0]; got.Protocol != "JT808" || got.SourceCount != 16 || got.SelectedSourceCount != 10 || got.NotSelectedSourceCount != 5 || got.NotSelectedMileageKM != 60 || got.ExcludedSourceCount != 1 || got.UnmanagedSourceCount != 3 || got.UnknownKindSourceCount != 2 { t.Fatalf("JT808 protocol summary = %#v", got) } if len(diagnostics.Items) != 4 { t.Fatalf("items = %#v, want four", diagnostics.Items) } if len(diagnostics.Samples) != 2 { t.Fatalf("samples = %#v, want two source management samples", diagnostics.Samples) } if diagnostics.Samples[0].VIN != "VIN-UNKNOWN" || diagnostics.Samples[0].SelectionAction != "set_source_kind" { t.Fatalf("first sample = %#v", diagnostics.Samples[0]) } if diagnostics.Samples[0].SelectedSource == nil || diagnostics.Samples[0].SelectedSource.PlatformName != "可信平台" || diagnostics.Samples[0].SelectedSource.DailyMileageKM != 12 { t.Fatalf("first selected source = %#v", diagnostics.Samples[0].SelectedSource) } if !strings.Contains(diagnostics.Samples[0].SelectedSourceQueryPath, "selected=true") || !strings.Contains(diagnostics.Samples[0].SelectedSourceQueryPath, "vin=VIN-UNKNOWN") { t.Fatalf("first selected source query path = %q", diagnostics.Samples[0].SelectedSourceQueryPath) } if diagnostics.Samples[1].VIN != "VIN-UNMANAGED" || diagnostics.Samples[1].SelectionAction != "create_vehicle_data_source" { t.Fatalf("second sample = %#v", diagnostics.Samples[1]) } if diagnostics.Samples[1].SelectedSource == nil || diagnostics.Samples[1].SelectedSource.SourceIP != "10.0.0.9" { t.Fatalf("second selected source = %#v", diagnostics.Samples[1].SelectedSource) } } func TestDailyMileageSourceSelectionFindingsCanGateSelectedSourceFloor(t *testing.T) { diagnostics := &capacity.DailyMileageSourceSelectionDiagnostics{SelectedSourceCount: 0} findings := dailyMileageSourceSelectionFindings(diagnostics, 1, nil, -1, -1, -1, -1) if len(findings) != 1 || !strings.Contains(findings[0], "selected source count 0 is below 1") { t.Fatalf("findings = %#v", findings) } } func TestDailyMileageSourceSelectionFindingsCanGateSelectedSourceByProtocol(t *testing.T) { diagnostics := &capacity.DailyMileageSourceSelectionDiagnostics{ Protocols: []capacity.DailyMileageSourceSelectionProtocol{ {Protocol: "GB32960", SelectedSourceCount: 12}, {Protocol: "JT808", SelectedSourceCount: 0}, }, } findings := dailyMileageSourceSelectionFindings(diagnostics, -1, []string{"GB32960", "JT808", "YUTONG_MQTT"}, 1, -1, -1, -1) if len(findings) != 2 { t.Fatalf("findings = %#v, want JT808 and YUTONG_MQTT", findings) } if !strings.Contains(findings[0], "protocol JT808 is 0 below 1") { t.Fatalf("first finding = %q", findings[0]) } if !strings.Contains(findings[1], "protocol YUTONG_MQTT is 0 below 1") { t.Fatalf("second finding = %q", findings[1]) } } func TestDailyMileageSourceSelectionFindingsCanGateUnmanagedAndUnknownKind(t *testing.T) { diagnostics := &capacity.DailyMileageSourceSelectionDiagnostics{ UnmanagedSourceCount: 3, UnknownKindSourceCount: 2, } findings := dailyMileageSourceSelectionFindings(diagnostics, -1, nil, -1, 0, 0, -1) if len(findings) != 2 { t.Fatalf("findings = %#v, want two", findings) } if !strings.Contains(findings[0], "unmanaged source count 3 exceeds 0") { t.Fatalf("first finding = %q", findings[0]) } if !strings.Contains(findings[1], "UNKNOWN source_kind count 2 exceeds 0") { t.Fatalf("second finding = %q", findings[1]) } } func TestDailyMileageSourceSelectionFindingsCanGateNotSelectedMileage(t *testing.T) { diagnostics := &capacity.DailyMileageSourceSelectionDiagnostics{ NotSelectedMileageKM: 16736.6, } findings := dailyMileageSourceSelectionFindings(diagnostics, -1, nil, -1, -1, -1, 1000) if len(findings) != 1 || !strings.Contains(findings[0], "not-selected source mileage 16736.6km exceeds 1000.0km") { t.Fatalf("findings = %#v", findings) } } func TestDailyMileageSourceSelectionSampleIncludesHighMileageNotSelectedSource(t *testing.T) { sample := capacity.DailyMileageSourceSelectionSample{ SelectionStatus: "not_selected", SelectionReason: "lower_trust_priority_or_sample_count", DailyMileageKM: 1234.5, } if !isProblematicDailyMileageSourceSelectionSample(sample) { t.Fatalf("sample should be collected for not-selected mileage evidence") } } func TestDataSourceDiagnosticsRequestURLUsesDefaultPathAndTotal(t *testing.T) { requestURL, err := dataSourceDiagnosticsRequestURL("http://127.0.0.1:20200", 3) if err != nil { t.Fatalf("request URL error: %v", err) } for _, want := range []string{ defaultDataSourceDiagnosticsPath, "sourceCodeMissing=true", "includeTotal=true", "limit=3", "offset=0", } { if !strings.Contains(requestURL, want) { t.Fatalf("url = %q, want %q", requestURL, want) } } } func TestDataSourceMappingIssuesRequestURLUsesDiagnosticsPath(t *testing.T) { requestURL, err := dataSourceMappingIssuesRequestURL("http://127.0.0.1:20200", 3) if err != nil { t.Fatalf("request URL error: %v", err) } for _, want := range []string{ defaultDataSourceDiagnosticsPath, "sourceCodeMissing=false", "mappingIssueOnly=true", "includeTotal=true", "limit=3", "offset=0", } { if !strings.Contains(requestURL, want) { t.Fatalf("url = %q, want %q", requestURL, want) } } } func TestCollectDataSourceDiagnostics(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != defaultDataSourceDiagnosticsPath { t.Fatalf("path = %q, want %q", r.URL.Path, defaultDataSourceDiagnosticsPath) } if got := r.URL.Query().Get("includeTotal"); got != "true" { t.Fatalf("includeTotal = %q, want true", got) } if got := r.URL.Query().Get("limit"); got != "3" { t.Fatalf("limit = %q, want 3", got) } if got := r.URL.Query().Get("mappingIssueOnly"); got == "true" { if missing := r.URL.Query().Get("sourceCodeMissing"); missing != "false" { t.Fatalf("mapping issue sourceCodeMissing = %q, want false", missing) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{ "items":[ {"id":7,"protocol":"JT808","source_ip":"115.159.85.149","platform_name":"G7易流","source_code":"dongfang_beidou","source_kind":"PLATFORM","latest_seen_age_seconds":60,"registration_rows":388,"phone_count":388,"identifier_matched_phones":8,"unmapped_phone_count":380,"identifier_match_ratio":0.0206,"configured_source_code_matched_phones":8,"matched_source_code_count":1,"reason":"low_identifier_coverage","recommended_operator_action":"补充该来源手机号映射"} ], "total":1, "limit":3, "offset":0 }`)) return } if got := r.URL.Query().Get("sourceCodeMissing"); got != "true" { t.Fatalf("sourceCodeMissing = %q, want true", got) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{ "items":[ {"id":253218,"protocol":"JT808","source_ip":"117.132.196.43","latest_source_endpoint":"117.132.196.43:38256","source_kind":"UNKNOWN","latest_seen_age_seconds":120,"registration_rows":1,"phone_count":1,"candidate_source_code":"guangan_beidou","candidate_platform_name":"广安北斗","reason":"candidate_available","recommended_operator_action":"确认来源平台","suggested_source_kind":"PLATFORM","suggestion_confidence":"HIGH","suggestion_reason":"single_source_code_with_many_phones_or_long_activity"}, {"id":253219,"protocol":"JT808","source_ip":"117.132.196.44","source_kind":"UNKNOWN","latest_seen_age_seconds":600,"registration_rows":1,"phone_count":1,"candidate_source_code":"guangan_beidou","candidate_platform_name":"广安北斗","reason":"candidate_available","recommended_operator_action":"确认来源平台","suggested_source_kind":"PLATFORM","suggestion_confidence":"HIGH","suggestion_reason":"single_source_code_with_many_phones_or_long_activity"}, {"id":253220,"protocol":"JT808","source_ip":"117.132.196.45","source_kind":"UNKNOWN","latest_seen_age_seconds":20,"reason":"no_registration","recommended_operator_action":"等待注册证据","suggested_source_kind":"UNKNOWN","suggestion_confidence":"LOW","suggestion_reason":"insufficient_evidence"} ], "total":400, "limit":3, "offset":0 }`)) })) defer server.Close() diagnostics, findings := collectDataSourceDiagnostics(context.Background(), server.URL, time.Second, 1, 3, 300) if len(findings) != 0 { t.Fatalf("findings = %#v, want none", findings) } if diagnostics == nil { t.Fatal("diagnostics = nil") } if diagnostics.MissingSourceCodeTotal != 400 { t.Fatalf("missing source code total = %d, want 400", diagnostics.MissingSourceCodeTotal) } if diagnostics.ActionableCandidateTotal != 2 { t.Fatalf("actionable candidates = %d, want 2", diagnostics.ActionableCandidateTotal) } if diagnostics.RecentMissingSourceCodeTotal != 2 || diagnostics.RecentAgeThresholdSeconds != 300 { t.Fatalf("recent summary = %d threshold=%d, want 2/300", diagnostics.RecentMissingSourceCodeTotal, diagnostics.RecentAgeThresholdSeconds) } if diagnostics.ReasonCounts["candidate_available"] != 2 || diagnostics.ReasonCounts["no_registration"] != 1 { t.Fatalf("reason counts = %#v", diagnostics.ReasonCounts) } if !diagnostics.ReasonCountsTruncated { t.Fatalf("reason counts should be marked truncated when total exceeds fetched rows") } if len(diagnostics.Samples) != 1 || diagnostics.Samples[0].CandidateSourceCode != "guangan_beidou" { t.Fatalf("samples = %#v", diagnostics.Samples) } if diagnostics.MappingIssueTotal != 1 || diagnostics.RecentMappingIssueTotal != 1 { t.Fatalf("mapping issue summary = total %d recent %d", diagnostics.MappingIssueTotal, diagnostics.RecentMappingIssueTotal) } if diagnostics.MappingIssueReasonCounts["low_identifier_coverage"] != 1 { t.Fatalf("mapping reason counts = %#v", diagnostics.MappingIssueReasonCounts) } if len(diagnostics.MappingIssueSamples) != 1 || diagnostics.MappingIssueSamples[0].UnmappedPhoneCount != 380 { t.Fatalf("mapping samples = %#v", diagnostics.MappingIssueSamples) } } func TestDataSourceKindSuggestionsRequestURLUsesDefaultPathAndUnknownKind(t *testing.T) { requestURL, err := dataSourceKindSuggestionsRequestURL("http://127.0.0.1:20200", "gb32960", 3) if err != nil { t.Fatalf("request URL error: %v", err) } for _, want := range []string{ defaultDataSourceKindSuggestionsPath, "protocol=GB32960", "sourceKind=UNKNOWN", "includeTotal=true", "limit=3", "offset=0", } { if !strings.Contains(requestURL, want) { t.Fatalf("url = %q, want %q", requestURL, want) } } } func TestCollectDataSourceKindSuggestionsAggregatesPlatformCandidates(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != defaultDataSourceKindSuggestionsPath { t.Fatalf("path = %q, want %q", r.URL.Path, defaultDataSourceKindSuggestionsPath) } if got := r.URL.Query().Get("sourceKind"); got != "UNKNOWN" { t.Fatalf("sourceKind = %q, want UNKNOWN", got) } if got := r.URL.Query().Get("includeTotal"); got != "true" { t.Fatalf("includeTotal = %q, want true", got) } if got := r.URL.Query().Get("limit"); got != "3" { t.Fatalf("limit = %q, want 3", got) } w.Header().Set("Content-Type", "application/json") switch r.URL.Query().Get("protocol") { case "GB32960": _, _ = w.Write([]byte(`{ "items":[ {"id":7,"protocol":"GB32960","source_ip":"8.134.95.166","latest_source_endpoint":"8.134.95.166:32960","platform_name":"现代 HTWO","source_code":"Hyundai","source_kind":"UNKNOWN","latest_seen_age_seconds":90,"reason":"source_configured","suggested_source_kind":"PLATFORM","suggestion_confidence":"MEDIUM","suggestion_reason":"non_jt808_configured_source"} ], "total":1, "limit":3, "offset":0 }`)) case "JT808": _, _ = w.Write([]byte(`{ "items":[ {"id":253218,"protocol":"JT808","source_ip":"117.132.196.43","source_kind":"UNKNOWN","latest_seen_age_seconds":600,"registration_rows":1,"phone_count":12,"candidate_source_code":"guangan_beidou","candidate_platform_name":"广安北斗","reason":"candidate_available","suggested_source_kind":"PLATFORM","suggestion_confidence":"HIGH","suggestion_reason":"single_source_code_with_many_phones_or_long_activity"}, {"id":253219,"protocol":"JT808","source_ip":"117.132.196.44","source_kind":"UNKNOWN","latest_seen_age_seconds":60,"reason":"no_registration","suggested_source_kind":"UNKNOWN","suggestion_confidence":"LOW","suggestion_reason":"insufficient_evidence"} ], "total":2, "limit":3, "offset":0 }`)) case "YUTONG_MQTT": _, _ = w.Write([]byte(`{"items":[],"total":0,"limit":3,"offset":0}`)) default: t.Fatalf("unexpected protocol query %q", r.URL.Query().Get("protocol")) } })) defer server.Close() diagnostics, findings := collectDataSourceKindSuggestions( context.Background(), server.URL, time.Second, []string{"GB32960", "JT808", "YUTONG_MQTT"}, 1, 3, 300, ) if len(findings) != 0 { t.Fatalf("findings = %#v, want none", findings) } if diagnostics == nil { t.Fatal("diagnostics = nil") } if diagnostics.UnknownSourceKindTotal != 3 { t.Fatalf("unknown source kind total = %d, want 3", diagnostics.UnknownSourceKindTotal) } if diagnostics.RecentUnknownSourceKindTotal != 2 || diagnostics.RecentAgeThresholdSeconds != 300 { t.Fatalf("recent summary = %d threshold=%d, want 2/300", diagnostics.RecentUnknownSourceKindTotal, diagnostics.RecentAgeThresholdSeconds) } if diagnostics.PlatformKindCandidateTotal != 2 { t.Fatalf("platform kind candidates = %d, want 2", diagnostics.PlatformKindCandidateTotal) } if diagnostics.RecentPlatformKindCandidateTotal != 1 { t.Fatalf("recent platform kind candidates = %d, want 1", diagnostics.RecentPlatformKindCandidateTotal) } if diagnostics.ReasonCounts["non_jt808_configured_source"] != 1 || diagnostics.ReasonCounts["single_source_code_with_many_phones_or_long_activity"] != 1 { t.Fatalf("reason counts = %#v", diagnostics.ReasonCounts) } if len(diagnostics.Samples) != 1 || diagnostics.Samples[0].Protocol != "GB32960" { t.Fatalf("samples = %#v", diagnostics.Samples) } } func TestJT808IdentityGapsRequestURLUsesDefaultPathAndRecentSeconds(t *testing.T) { requestURL, err := jt808IdentityGapsRequestURL("http://127.0.0.1:20200", 86400, 3) if err != nil { t.Fatalf("request URL error: %v", err) } for _, want := range []string{ defaultJT808IdentityGapsPath, "includeTotal=true", "recentSeconds=86400", "limit=3", "offset=0", } { if !strings.Contains(requestURL, want) { t.Fatalf("url = %q, want %q", requestURL, want) } } } func TestCollectJT808IdentityGaps(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != defaultJT808IdentityGapsPath { t.Fatalf("path = %q, want %q", r.URL.Path, defaultJT808IdentityGapsPath) } if got := r.URL.Query().Get("includeTotal"); got != "true" { t.Fatalf("includeTotal = %q, want true", got) } if got := r.URL.Query().Get("recentSeconds"); got != "86400" { t.Fatalf("recentSeconds = %q, want 86400", got) } if got := r.URL.Query().Get("limit"); got != "2" { t.Fatalf("limit = %q, want 2", got) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{ "items":[ {"phone":"41025503543","vin":"unknown","source_ip":"122.152.221.156","source_endpoint":"122.152.221.156:23185","source_code":"dongfang_beidou","platform_name":"广安车联","source_kind":"PLATFORM","latest_seen_at":"2026-07-12 14:01:02","latest_seen_age_seconds":30,"reason":"missing_phone_binding","recommended_operator_action":"将该 phone 维护到 vehicle_identifier","raw_frame_query_path":"/api/history/raw-frames?protocol=JT808&phone=41025503543","data_source_query_path":"/api/stats/data-sources?protocol=JT808&sourceIP=122.152.221.156"} ], "total":1, "limit":2, "offset":0, "recentSeconds":86400 }`)) })) defer server.Close() diagnostics, findings := collectJT808IdentityGaps(context.Background(), server.URL, time.Second, 86400, 2) if len(findings) != 0 { t.Fatalf("findings = %#v, want none", findings) } if diagnostics == nil { t.Fatal("diagnostics = nil") } if diagnostics.Total != 1 || diagnostics.RecentSeconds != 86400 { t.Fatalf("diagnostics summary = total %d recent %d, want 1/86400", diagnostics.Total, diagnostics.RecentSeconds) } if len(diagnostics.Samples) != 1 || diagnostics.Samples[0].Phone != "41025503543" { t.Fatalf("samples = %#v", diagnostics.Samples) } if diagnostics.Samples[0].RawFrameQueryPath == "" || diagnostics.Samples[0].DataSourceQueryPath == "" { t.Fatalf("diagnostic links should be preserved: %#v", diagnostics.Samples[0]) } } func TestDataSourceDiagnosticFindingsCanGateMissingSourceCode(t *testing.T) { diagnostics := &capacity.DataSourceDiagnostics{MissingSourceCodeTotal: 400} findings := dataSourceDiagnosticFindings(diagnostics, 100, -1, -1, -1, -1) if len(findings) != 1 || !strings.Contains(findings[0], "data source missing source_code 400 exceeds 100") { t.Fatalf("findings = %#v", findings) } } func TestDataSourceKindDiagnosticFindingsCanGateRecentPlatformCandidates(t *testing.T) { diagnostics := &capacity.DataSourceDiagnostics{ PlatformKindCandidateTotal: 3, RecentPlatformKindCandidateTotal: 1, RecentAgeThresholdSeconds: 300, } findings := dataSourceKindDiagnosticFindings(diagnostics, -1, 0) if len(findings) != 1 || !strings.Contains(findings[0], "recent UNKNOWN source_kind platform candidates 1 exceeds 0") { t.Fatalf("findings = %#v", findings) } if !strings.Contains(findings[0], "recent_age_seconds=300") { t.Fatalf("finding should include recent age threshold: %q", findings[0]) } } func TestDataSourceKindDiagnosticFindingsCanGatePlatformCandidates(t *testing.T) { diagnostics := &capacity.DataSourceDiagnostics{ PlatformKindCandidateTotal: 3, } findings := dataSourceKindDiagnosticFindings(diagnostics, 0, -1) if len(findings) != 1 || !strings.Contains(findings[0], "UNKNOWN source_kind platform candidates 3 exceeds 0") { t.Fatalf("findings = %#v", findings) } } func TestJT808IdentityGapFindingsCanGate(t *testing.T) { diagnostics := &capacity.JT808IdentityGapDiagnostics{ Total: 2, RecentSeconds: 86400, } findings := jt808IdentityGapFindings(diagnostics, 1) if len(findings) != 1 || !strings.Contains(findings[0], "JT808 unresolved identity gaps 2 exceeds 1") { t.Fatalf("findings = %#v", findings) } if !strings.Contains(findings[0], "recent_seconds=86400") { t.Fatalf("finding should include recent seconds: %q", findings[0]) } } func TestDataSourceDiagnosticFindingsCanGateActionableCandidates(t *testing.T) { diagnostics := &capacity.DataSourceDiagnostics{ MissingSourceCodeTotal: 400, ActionableCandidateTotal: 3, } findings := dataSourceDiagnosticFindings(diagnostics, -1, -1, 0, -1, -1) if len(findings) != 1 || !strings.Contains(findings[0], "data source actionable candidates 3 exceeds 0") { t.Fatalf("findings = %#v", findings) } } func TestDataSourceDiagnosticFindingsCanGateRecentMissingSourceCode(t *testing.T) { diagnostics := &capacity.DataSourceDiagnostics{ MissingSourceCodeTotal: 400, RecentMissingSourceCodeTotal: 2, RecentAgeThresholdSeconds: 300, } findings := dataSourceDiagnosticFindings(diagnostics, -1, 0, -1, -1, -1) if len(findings) != 1 || !strings.Contains(findings[0], "data source recent missing source_code 2 exceeds 0") { t.Fatalf("findings = %#v", findings) } if !strings.Contains(findings[0], "recent_age_seconds=300") { t.Fatalf("finding should include recent age threshold: %q", findings[0]) } } func TestDataSourceDiagnosticFindingsCanGateMappingIssues(t *testing.T) { diagnostics := &capacity.DataSourceDiagnostics{ MappingIssueTotal: 3, RecentMappingIssueTotal: 2, RecentAgeThresholdSeconds: 300, } findings := dataSourceDiagnosticFindings(diagnostics, -1, -1, -1, 0, 1) if len(findings) != 2 { t.Fatalf("findings = %#v, want 2", findings) } if !strings.Contains(findings[0], "data source mapping issues 3 exceeds 0") { t.Fatalf("mapping issue finding missing: %#v", findings) } if !strings.Contains(findings[1], "data source recent mapping issues 2 exceeds 1") { t.Fatalf("recent mapping issue finding missing: %#v", findings) } }