1151 lines
49 KiB
Go
1151 lines
49 KiB
Go
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)
|
|
}
|
|
}
|