package stats import ( "context" "database/sql" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/DATA-DOG/go-sqlmock" ) const dailyMetricSelectPattern = "SELECT m.vin, m.stat_date, m.protocol, m.source_id" const dailyMetricSourceSelectPattern = "SELECT s.vin, s.stat_date, s.protocol, s.source_key, s.source_ip" const dailyMetricSourceQualitySummarySelectPattern = "SELECT s.stat_date, s.protocol, s.quality_status, s.quality_reason" const dailyMetricSourceSelectionSummarySelectPattern = "SELECT stat_date, protocol, selection_status, selection_reason" const dailyMetricDiagnosisSelectPattern = "SELECT rt.vin, \\? AS stat_date, rt.protocol" const dailyMetricDiagnosisSummarySelectPattern = "SELECT \\? AS stat_date,\\s+rt.protocol" const dailyMetricDiagnosisReasonSummarySelectPattern = "SELECT stat_date, protocol, diagnosis, reason, COUNT\\(\\*\\) AS count" const dailyMetricDiagnosisFieldStatusSummarySelectPattern = "SELECT stat_date, protocol, diagnosis, reason, realtime_mileage_field_status, COUNT\\(\\*\\) AS count" func TestMetricRepositoryQueriesDailyMetricsWithFilters(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() mock.ExpectQuery(dailyMetricSelectPattern). WithArgs("LKLG7C4E3NA774736", "JT808", "2026-07-01", "2026-07-01", 20, 0). WillReturnRows(sqlmock.NewRows([]string{ "vin", "stat_date", "protocol", "source_id", "source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind", "daily_mileage_km", "latest_total_mileage_km", "updated_at", }).AddRow( "LKLG7C4E3NA774736", time.Date(2026, 7, 1, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)), "JT808", 3, "115.231.168.135", "115.231.168.135:41561", "G7 平台", "G7S", "PLATFORM", 12.3, 12357.9, time.Date(2026, 7, 1, 23, 9, 36, 0, time.FixedZone("Asia/Shanghai", 8*3600)), )) repository := NewMetricRepository(db) rows, err := repository.Query(context.Background(), MetricQuery{ VIN: "LKLG7C4E3NA774736", Protocol: "JT808", DateFrom: "2026-07-01", DateTo: "2026-07-01", Limit: 20, }) if err != nil { t.Fatalf("Query() error = %v", err) } if len(rows) != 1 { t.Fatalf("row count = %d", len(rows)) } if rows[0].DailyMileageKM != 12.3 { t.Fatalf("unexpected row: %#v", rows[0]) } if rows[0].SourceID == nil || *rows[0].SourceID != 3 { t.Fatalf("source id = %#v", rows[0].SourceID) } if rows[0].SourceIP != "115.231.168.135" || rows[0].PlatformName != "G7 平台" || rows[0].SourceCode != "G7S" || rows[0].SourceKind != "PLATFORM" { t.Fatalf("unexpected source evidence: %#v", rows[0]) } if rows[0].StatDate != "2026-07-01" || rows[0].UpdatedAt != "2026-07-01 23:09:36" { t.Fatalf("unexpected time formatting: %#v", rows[0]) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestMetricQueryReturnsSelectedSourceID(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() mock.ExpectQuery(dailyMetricSelectPattern). WillReturnRows(sqlmock.NewRows([]string{ "vin", "stat_date", "protocol", "source_id", "source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind", "daily_mileage_km", "latest_total_mileage_km", "updated_at", }).AddRow( "LA9GG64L7PBAF4001", "2026-07-08", "JT808", 2, "117.132.194.31", "117.132.194.31:20471", "广安北斗", "guangan_beidou", "PLATFORM", 23.1, 4123.9, "2026-07-08 13:30:57", )) repo := NewMetricRepository(db) got, err := repo.Query(context.Background(), MetricQuery{ VIN: "LA9GG64L7PBAF4001", Protocol: "JT808", Limit: 1, }) if err != nil { t.Fatalf("Query() error = %v", err) } if got[0].SourceID == nil || *got[0].SourceID != 2 { t.Fatalf("source id = %#v", got[0].SourceID) } if got[0].SourceKind != "PLATFORM" || got[0].PlatformName != "广安北斗" { t.Fatalf("source evidence = %#v", got[0]) } } func TestMetricRepositoryQueriesDailyMetricSourcesWithEvidence(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() mock.ExpectQuery(dailyMetricSourceSelectPattern). WithArgs("LA9GG64L7PBAF4001", "JT808", "2026-07-08", "2026-07-08", "115.231.168.135", "OK", "historical_source_baseline", 1, 20, 0). WillReturnRows(sqlmock.NewRows([]string{ "vin", "stat_date", "protocol", "source_key", "source_ip", "source_endpoint", "phone", "device_id", "platform_name", "source_id", "source_code", "source_kind", "enabled", "trust_priority", "first_total_mileage_km", "latest_total_mileage_km", "daily_mileage_km", "sample_count", "first_event_time", "latest_event_time", "quality_status", "quality_reason", "is_selected", "updated_at", }).AddRow( "LA9GG64L7PBAF4001", "2026-07-08", "JT808", "JT808:13307795425@115.231.168.135", "115.231.168.135", "115.231.168.135:41561", "13307795425", nil, "信达", 5, "xinda", "PLATFORM", 1, 10, 4100.8, 4123.9, 23.1, 128, "2026-07-08 00:01:00", "2026-07-08 23:59:00", "OK", "historical_source_baseline", 1, "2026-07-08 23:59:10", )) selected := true repository := NewMetricRepository(db) rows, err := repository.QuerySources(context.Background(), MetricSourceQuery{ VIN: "LA9GG64L7PBAF4001", Protocol: "jt808", DateFrom: "2026-07-08", DateTo: "2026-07-08", SourceIP: "115.231.168.135", QualityStatus: "ok", QualityReason: "Historical_Source_Baseline", Selected: &selected, Limit: 20, }) if err != nil { t.Fatalf("QuerySources() error = %v", err) } if len(rows) != 1 { t.Fatalf("row count = %d", len(rows)) } row := rows[0] if row.PlatformName != "信达" || row.SourceCode != "xinda" || row.SourceKind != "PLATFORM" || !row.IsSelected { t.Fatalf("unexpected source row: %#v", row) } if row.SourceEnabled == nil || !*row.SourceEnabled { t.Fatalf("source enabled = %#v", row.SourceEnabled) } if row.TrustPriority == nil || *row.TrustPriority != 10 { t.Fatalf("trust priority = %#v", row.TrustPriority) } if row.FirstTotalMileageKM == nil || *row.FirstTotalMileageKM != 4100.8 { t.Fatalf("first total mileage = %#v", row.FirstTotalMileageKM) } if row.LatestEventTime != "2026-07-08 23:59:00" || row.QualityReason != "historical_source_baseline" { t.Fatalf("unexpected event/quality fields: %#v", row) } if row.SelectionStatus != "selected" || row.SelectionReason != "selected_current_projection" { t.Fatalf("selection evidence = %#v", row) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestMetricSourceSelectionEvidence(t *testing.T) { disabled := false sourceID := int64(3) tests := []struct { name string row MetricSourceRow wantStatus string wantReason string }{ { name: "selected", row: MetricSourceRow{ IsSelected: true, QualityStatus: QualityOK, }, wantStatus: "selected", wantReason: "selected_current_projection", }, { name: "invalid quality", row: MetricSourceRow{ QualityStatus: QualityInvalidDelta, QualityReason: "outside_daily_range", }, wantStatus: "excluded", wantReason: "quality_outside_daily_range", }, { name: "disabled source", row: MetricSourceRow{ QualityStatus: QualityOK, SourceEnabled: &disabled, SourceID: &sourceID, }, wantStatus: "excluded", wantReason: "source_disabled", }, { name: "unmanaged source", row: MetricSourceRow{ QualityStatus: QualityOK, }, wantStatus: "not_selected", wantReason: "unmanaged_source_lower_priority", }, { name: "direct source without source table row", row: MetricSourceRow{ QualityStatus: QualityOK, SourceKind: "DIRECT", }, wantStatus: "not_selected", wantReason: "lower_trust_priority_or_sample_count", }, { name: "unknown source kind", row: MetricSourceRow{ QualityStatus: QualityOK, SourceID: &sourceID, SourceKind: "UNKNOWN", }, wantStatus: "not_selected", wantReason: "unknown_source_kind_lower_priority", }, { name: "lower priority", row: MetricSourceRow{ QualityStatus: QualityOK, SourceID: &sourceID, SourceKind: "PLATFORM", }, wantStatus: "not_selected", wantReason: "lower_trust_priority_or_sample_count", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { status, reason, action := metricSourceSelectionEvidence(tt.row) if status != tt.wantStatus || reason != tt.wantReason || action == "" { t.Fatalf("metricSourceSelectionEvidence() = %q, %q, %q", status, reason, action) } }) } } func TestMetricRepositoryQueriesDailyMetricSourceQualitySummary(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() mock.ExpectQuery(dailyMetricSourceQualitySummarySelectPattern). WithArgs("JT808", "2026-07-12", "2026-07-12", "INVALID_DELTA", "outside_daily_range", 10, 0). WillReturnRows(sqlmock.NewRows([]string{ "stat_date", "protocol", "quality_status", "quality_reason", "source_count", "vehicle_count", "selected_count", "sample_count", "daily_mileage_km", }).AddRow( "2026-07-12", "JT808", "INVALID_DELTA", "outside_daily_range", int64(3), int64(3), int64(0), int64(128), 12435.2, )) repository := NewMetricRepository(db) rows, err := repository.QuerySourceQualitySummary(context.Background(), MetricSourceQuery{ Protocol: "jt808", DateFrom: "2026-07-12", DateTo: "2026-07-12", QualityStatus: "invalid_delta", QualityReason: "Outside_Daily_Range", Limit: 10, }) if err != nil { t.Fatalf("QuerySourceQualitySummary() error = %v", err) } if len(rows) != 1 { t.Fatalf("row count = %d", len(rows)) } row := rows[0] if row.StatDate != "2026-07-12" || row.Protocol != "JT808" || row.QualityStatus != "INVALID_DELTA" || row.QualityReason != "outside_daily_range" { t.Fatalf("unexpected summary key: %#v", row) } if row.SourceCount != 3 || row.VehicleCount != 3 || row.SelectedCount != 0 || row.SampleCount != 128 || row.DailyMileageKM != 12435.2 { t.Fatalf("unexpected summary values: %#v", row) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestMetricRepositoryQueriesDailyMetricSourceSelectionSummary(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() mock.ExpectQuery(dailyMetricSourceSelectionSummarySelectPattern). WithArgs("JT808", "2026-07-12", "2026-07-12", 10, 0). WillReturnRows(sqlmock.NewRows([]string{ "stat_date", "protocol", "selection_status", "selection_reason", "source_count", "vehicle_count", "selected_count", "sample_count", "daily_mileage_km", }).AddRow( "2026-07-12", "JT808", "not_selected", "lower_trust_priority_or_sample_count", int64(36), int64(31), int64(0), int64(3200), 812.5, )) repository := NewMetricRepository(db) rows, err := repository.QuerySourceSelectionSummary(context.Background(), MetricSourceQuery{ Protocol: "jt808", DateFrom: "2026-07-12", DateTo: "2026-07-12", Limit: 10, }) if err != nil { t.Fatalf("QuerySourceSelectionSummary() error = %v", err) } if len(rows) != 1 { t.Fatalf("row count = %d", len(rows)) } row := rows[0] if row.StatDate != "2026-07-12" || row.Protocol != "JT808" || row.SelectionStatus != "not_selected" || row.SelectionReason != "lower_trust_priority_or_sample_count" { t.Fatalf("unexpected summary key: %#v", row) } if row.SourceCount != 36 || row.VehicleCount != 31 || row.SelectedCount != 0 || row.SampleCount != 3200 || row.DailyMileageKM != 812.5 { t.Fatalf("unexpected summary values: %#v", row) } if row.SelectionAction == "" || !strings.Contains(row.SelectionAction, "更高可信优先级") { t.Fatalf("selection action = %q", row.SelectionAction) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestBuildMetricSourceSelectionSummarySQLGroupsByComputedSelectionEvidence(t *testing.T) { sqlText, args := buildMetricSourceSelectionSummarySQL(normalizeMetricSourceQuery(MetricSourceQuery{ Protocol: "jt808", DateFrom: "2026-07-12", DateTo: "2026-07-12", Limit: 20, })) for _, want := range []string{ "selection_status", "selection_reason", "selected_current_projection", "source_disabled", "source_key LIKE '%@PLATFORM:%'", "source_key LIKE '%@DIRECT'", "ds.id IS NULL AND COALESCE(NULLIF(TRIM(ds.source_kind), ''), CASE WHEN s.source_key LIKE '%@PLATFORM:%' THEN 'PLATFORM' WHEN s.source_key LIKE '%@DIRECT' THEN 'DIRECT' ELSE 'UNKNOWN' END) <> 'DIRECT'", "unmanaged_source_lower_priority", "unknown_source_kind_lower_priority", "lower_trust_priority_or_sample_count", "GROUP BY stat_date, protocol, selection_status, selection_reason", } { if !strings.Contains(sqlText, want) { t.Fatalf("selection summary SQL missing %q:\n%s", want, sqlText) } } if len(args) != 5 || args[0] != "JT808" || args[1] != "2026-07-12" || args[2] != "2026-07-12" || args[3] != 20 || args[4] != 0 { t.Fatalf("args = %#v", args) } } func TestBuildMetricSourceSQLCanOrderByDailyMileageDesc(t *testing.T) { selected := false sqlText, args := buildMetricSourceSQL(normalizeMetricSourceQuery(MetricSourceQuery{ Protocol: "jt808", DateFrom: "2026-07-12", DateTo: "2026-07-12", Selected: &selected, OrderBy: "daily_mileage_desc", Limit: 20, })) for _, want := range []string{ "ORDER BY s.daily_mileage_km DESC", "s.sample_count DESC", "s.latest_event_time DESC", } { if !strings.Contains(sqlText, want) { t.Fatalf("sql missing %q:\n%s", want, sqlText) } } if strings.Contains(sqlText, "ORDER BY s.stat_date DESC, s.vin ASC") { t.Fatalf("sql should use mileage ordering:\n%s", sqlText) } if len(args) != 6 || args[0] != "JT808" || args[1] != "2026-07-12" || args[2] != "2026-07-12" || args[3] != 0 || args[4] != 20 || args[5] != 0 { t.Fatalf("args = %#v", args) } } func TestMetricRepositoryQueriesDailyMetricDiagnostics(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() mock.ExpectQuery(dailyMetricDiagnosisSelectPattern). WillReturnRows(sqlmock.NewRows([]string{ "vin", "stat_date", "protocol", "plate", "platform_name", "peer", "snapshot_event_time", "location_event_time", "snapshot_updated_at", "location_updated_at", "realtime_total_mileage_km", "realtime_total_mileage_event_time", "daily_mileage_km", "daily_latest_total_mileage_km", "source_sample_count", "ok_source_count", "selectable_source_count", "latest_stat_event_time", "quality_statuses", "snapshot_parsed_json", }).AddRow( "LA9HE60A1PBAF4008", "2026-07-12", "JT808", "粤A12345", "信达", "115.231.168.135:47849", "2026-07-12 02:23:27", "2026-07-12 02:23:27", "2026-07-12 02:23:29", "2026-07-12 02:23:29", 25246.6, "2026-07-12 02:23:27", nil, nil, 0, 0, 0, nil, "", `{"jt808.location.latitude":"30.1","jt808.location.total_mileage_km":"25246.6"}`, )) repository := NewMetricRepository(db) rows, err := repository.QueryDiagnostics(context.Background(), MetricDiagnosisQuery{ Protocol: "jt808", Date: "2026-07-12", Diagnosis: "NO_SOURCE_SAMPLE", Limit: 20, }) if err != nil { t.Fatalf("QueryDiagnostics() error = %v", err) } if len(rows) != 1 { t.Fatalf("row count = %d", len(rows)) } row := rows[0] if row.Diagnosis != "NO_SOURCE_SAMPLE" || row.Reason != "realtime_location_has_total_mileage_but_no_stat_sample" { t.Fatalf("unexpected diagnosis: %#v", row) } if row.Severity != "pipeline" { t.Fatalf("severity = %q, want pipeline", row.Severity) } if !strings.Contains(row.RecommendedOperatorAction, "字段流 topic") { t.Fatalf("recommended action = %q", row.RecommendedOperatorAction) } if row.RealtimeTotalMileageKM == nil || *row.RealtimeTotalMileageKM != 25246.6 { t.Fatalf("realtime total mileage = %#v", row.RealtimeTotalMileageKM) } if row.DailyMileageKM != nil { t.Fatalf("daily mileage should be nil: %#v", row.DailyMileageKM) } if row.PlatformName != "信达" || row.Peer != "115.231.168.135:47849" { t.Fatalf("unexpected realtime evidence: %#v", row) } if row.RealtimeFieldCount != 2 || len(row.RealtimeSampleFields) != 2 { t.Fatalf("realtime field evidence = count:%d fields:%#v", row.RealtimeFieldCount, row.RealtimeSampleFields) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestMetricDiagnosisWhereSupportsReasonAndSeverityFilters(t *testing.T) { query := normalizeMetricDiagnosisQuery(MetricDiagnosisQuery{ Date: "2026-07-12", Reason: "REALTIME_TOTAL_MILEAGE_MISSING", Severity: "SOURCE_DATA", }) where, args := buildMetricDiagnosisWhere(query) joined := strings.Join(where, "\n") for _, want := range []string{ "CASE", "l.total_mileage_km IS NULL", "source_data", } { if !strings.Contains(joined, want) { t.Fatalf("where missing %q:\n%s", want, joined) } } if len(args) < 4 { t.Fatalf("args too short: %#v", args) } var hasReason, hasSeverity bool for _, arg := range args { switch arg { case "realtime_total_mileage_missing": hasReason = true case "source_data": hasSeverity = true } } if !hasReason || !hasSeverity { t.Fatalf("args = %#v, want reason/severity filters", args) } } func TestMetricRepositoryQueriesDailyMetricDiagnosisSummary(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() mock.ExpectQuery(dailyMetricDiagnosisSummarySelectPattern). WillReturnRows(sqlmock.NewRows([]string{ "stat_date", "protocol", "active_count", "ok_count", "missing_daily_count", "no_source_sample_count", "no_total_mileage_count", }).AddRow( "2026-07-12", "JT808", int64(244), int64(244), int64(0), int64(0), int64(0), )) repository := NewMetricRepository(db) rows, err := repository.QueryDiagnosisSummary(context.Background(), MetricDiagnosisQuery{ Protocol: "jt808", Date: "2026-07-12", }) if err != nil { t.Fatalf("QueryDiagnosisSummary() error = %v", err) } if len(rows) != 1 { t.Fatalf("row count = %d", len(rows)) } row := rows[0] if row.StatDate != "2026-07-12" || row.Protocol != "JT808" || row.ActiveCount != 244 || row.OKCount != 244 || row.ActionableIssueCount != 0 { t.Fatalf("unexpected summary row: %#v", row) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestMetricRepositoryQueriesDailyMetricDiagnosisReasonSummary(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() mock.ExpectQuery(dailyMetricDiagnosisReasonSummarySelectPattern). WillReturnRows(sqlmock.NewRows([]string{ "stat_date", "protocol", "diagnosis", "reason", "count", }).AddRow( "2026-07-12", "YUTONG_MQTT", "NO_TOTAL_MILEAGE", "realtime_total_mileage_not_reported_on_stat_date", int64(7), )) repository := NewMetricRepository(db) rows, err := repository.QueryDiagnosisReasonSummary(context.Background(), MetricDiagnosisQuery{ Protocol: "yutong_mqtt", Date: "2026-07-12", Diagnosis: "no_total_mileage", }) if err != nil { t.Fatalf("QueryDiagnosisReasonSummary() error = %v", err) } if len(rows) != 1 { t.Fatalf("row count = %d", len(rows)) } row := rows[0] if row.StatDate != "2026-07-12" || row.Protocol != "YUTONG_MQTT" || row.Diagnosis != "NO_TOTAL_MILEAGE" || row.Reason != "realtime_total_mileage_not_reported_on_stat_date" || row.Count != 7 || row.Severity != metricDiagnosisSeveritySourceData { t.Fatalf("unexpected reason summary row: %#v", row) } if !strings.Contains(row.RecommendedOperatorAction, "不是统计日期内上报") { t.Fatalf("recommended action = %q", row.RecommendedOperatorAction) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestMetricRepositoryQueriesDailyMetricDiagnosisFieldStatusSummary(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() mock.ExpectQuery(dailyMetricDiagnosisFieldStatusSummarySelectPattern). WillReturnRows(sqlmock.NewRows([]string{ "stat_date", "protocol", "diagnosis", "reason", "realtime_mileage_field_status", "count", }).AddRow( "2026-07-12", "YUTONG_MQTT", "NO_TOTAL_MILEAGE", "realtime_total_mileage_missing", "no_candidate_mileage_field", int64(9), )) repository := NewMetricRepository(db) rows, err := repository.QueryDiagnosisFieldStatusSummary(context.Background(), MetricDiagnosisQuery{ Protocol: "yutong_mqtt", Date: "2026-07-12", Diagnosis: "no_total_mileage", Reason: "realtime_total_mileage_missing", }) if err != nil { t.Fatalf("QueryDiagnosisFieldStatusSummary() error = %v", err) } if len(rows) != 1 { t.Fatalf("row count = %d", len(rows)) } row := rows[0] if row.StatDate != "2026-07-12" || row.Protocol != "YUTONG_MQTT" || row.Diagnosis != "NO_TOTAL_MILEAGE" || row.RealtimeMileageFieldStatus != "no_candidate_mileage_field" || row.Count != 9 { t.Fatalf("unexpected field status summary row: %#v", row) } if row.Severity != metricDiagnosisSeveritySourceData { t.Fatalf("severity = %q", row.Severity) } if row.FieldStatusSeverity != metricDiagnosisSeveritySourceData { t.Fatalf("field status severity = %q", row.FieldStatusSeverity) } if !strings.Contains(row.FieldStatusAction, "源平台") { t.Fatalf("field status action = %q", row.FieldStatusAction) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestMetricDiagnosisFieldStatusSeverityDistinguishesFreshProjectionEvidence(t *testing.T) { tests := []struct { status string want string }{ {status: "daily_metric_exists", want: metricDiagnosisSeverityOK}, {status: "candidate_field_unmapped", want: metricDiagnosisSeverityPipeline}, {status: "mapped_protocol_field_without_fresh_evidence", want: metricDiagnosisSeveritySourceData}, {status: "standard_field_not_consumed", want: metricDiagnosisSeverityPipeline}, {status: "standard_field_time_missing", want: metricDiagnosisSeverityPipeline}, {status: "no_realtime_fields", want: metricDiagnosisSeverityPipeline}, {status: "standard_field_non_positive", want: metricDiagnosisSeveritySourceData}, {status: "standard_field_stale", want: metricDiagnosisSeveritySourceData}, {status: "no_candidate_mileage_field", want: metricDiagnosisSeveritySourceData}, } for _, tt := range tests { t.Run(tt.status, func(t *testing.T) { if got := metricDiagnosisMileageFieldStatusSeverity(tt.status); got != tt.want { t.Fatalf("metricDiagnosisMileageFieldStatusSeverity(%q) = %q, want %q", tt.status, got, tt.want) } }) } } func TestMetricDiagnosisFieldStatusSummaryTotalsPreferDiagnosisSeverity(t *testing.T) { _, actionable, pipeline, sourceData := metricDiagnosisFieldStatusSummaryIssueTotals([]MetricDiagnosisFieldStatusSummaryRow{ { Diagnosis: "MISSING_DAILY", Reason: "source_samples_all_invalid", Severity: metricDiagnosisSeveritySourceData, RealtimeMileageFieldStatus: "source_sample_exists", FieldStatusSeverity: metricDiagnosisSeverityPipeline, Count: 2, }, }) if actionable != 2 || pipeline != 0 || sourceData != 2 { t.Fatalf("totals actionable=%d pipeline=%d source=%d", actionable, pipeline, sourceData) } } func TestMetricDiagnosisIssueSeverity(t *testing.T) { tests := []struct { name string diagnosis string reason string want string }{ {name: "ok", diagnosis: "OK", want: metricDiagnosisSeverityOK}, {name: "missing daily", diagnosis: "MISSING_DAILY", want: metricDiagnosisSeverityPipeline}, {name: "missing daily all invalid samples", diagnosis: "MISSING_DAILY", reason: "source_samples_all_invalid", want: metricDiagnosisSeveritySourceData}, {name: "missing daily all excluded samples", diagnosis: "MISSING_DAILY", reason: "source_samples_all_excluded", want: metricDiagnosisSeveritySourceData}, {name: "no source sample", diagnosis: "NO_SOURCE_SAMPLE", want: metricDiagnosisSeverityPipeline}, {name: "time missing", diagnosis: "NO_TOTAL_MILEAGE", reason: "realtime_total_mileage_time_missing", want: metricDiagnosisSeverityPipeline}, {name: "source no mileage", diagnosis: "NO_TOTAL_MILEAGE", reason: "realtime_total_mileage_non_positive", want: metricDiagnosisSeveritySourceData}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := metricDiagnosisIssueSeverity(tt.diagnosis, tt.reason); got != tt.want { t.Fatalf("metricDiagnosisIssueSeverity() = %q, want %q", got, tt.want) } }) } } func TestBuildActiveRealtimeMetricSQLUsesBusinessRealtimeTimestamp(t *testing.T) { sqlText, args := buildActiveRealtimeMetricSQL(normalizeMetricDiagnosisQuery(MetricDiagnosisQuery{ Protocol: "jt808", Date: "2026-07-12", })) for _, want := range []string{ "vehicle_realtime_snapshot", "vehicle_realtime_location", "event_time >= ?", "received_at >= ?", "protocol = ?", } { if !strings.Contains(sqlText, want) { t.Fatalf("active realtime SQL missing %q:\n%s", want, sqlText) } } if strings.Contains(sqlText, "updated_at >= ?") { t.Fatalf("active realtime SQL should not use row mutation time as vehicle activity evidence:\n%s", sqlText) } if strings.Contains(sqlText, "COALESCE(event_time") { t.Fatalf("active realtime SQL should not let stale event_time hide received_at:\n%s", sqlText) } if strings.Count(sqlText, "protocol = ?") != 2 { t.Fatalf("protocol filter should be pushed into both realtime tables:\n%s", sqlText) } if len(args) != 10 { t.Fatalf("args = %d, want 10: %#v", len(args), args) } } func TestBuildMetricDiagnosisSQLRequiresPositiveRealtimeMileage(t *testing.T) { summarySQL, _ := buildMetricDiagnosisSummarySQL(normalizeMetricDiagnosisQuery(MetricDiagnosisQuery{ Protocol: "jt808", Date: "2026-07-12", })) detailSQL, _ := buildMetricDiagnosisSQL(normalizeMetricDiagnosisQuery(MetricDiagnosisQuery{ Protocol: "jt808", Date: "2026-07-12", Diagnosis: "NO_SOURCE_SAMPLE", })) noTotalSQL, _ := buildMetricDiagnosisSQL(normalizeMetricDiagnosisQuery(MetricDiagnosisQuery{ Protocol: "jt808", Date: "2026-07-12", Diagnosis: "NO_TOTAL_MILEAGE", })) for _, want := range []string{ "l.total_mileage_km > 0", "l.total_mileage_km IS NULL OR l.total_mileage_km <= 0", } { combinedSQL := summarySQL + "\n" + detailSQL + "\n" + noTotalSQL if !strings.Contains(combinedSQL, want) { t.Fatalf("diagnosis SQL missing %q:\n%s", want, combinedSQL) } } } func TestBuildMetricDiagnosisFieldStatusSummarySQLClassifiesMileageEvidence(t *testing.T) { sqlText, args := buildMetricDiagnosisFieldStatusSummarySQL(normalizeMetricDiagnosisQuery(MetricDiagnosisQuery{ Protocol: "yutong_mqtt", Date: "2026-07-12", Diagnosis: "NO_TOTAL_MILEAGE", Reason: "realtime_total_mileage_missing", Severity: "source_data", })) for _, want := range []string{ "realtime_mileage_field_status", "standard_field_non_positive", "standard_field_stale", "mapped_protocol_field_without_fresh_evidence", "candidate_field_unmapped", "no_candidate_mileage_field", "LOWER(s.parsed_json) LIKE '%mileage%'", "GROUP BY stat_date, protocol, diagnosis, reason, realtime_mileage_field_status", } { if !strings.Contains(sqlText, want) { t.Fatalf("field status summary SQL missing %q:\n%s", want, sqlText) } } for _, want := range []any{"NO_TOTAL_MILEAGE", "realtime_total_mileage_missing", "source_data"} { var found bool for _, arg := range args { if arg == want { found = true break } } if !found { t.Fatalf("args missing %v: %#v", want, args) } } } func TestMetricDiagnosisTreatsZeroMileageAsNoTotalMileage(t *testing.T) { diagnosis, reason := classifyMetricDiagnosis(false, 0, 0, 0, sql.NullFloat64{Float64: 0, Valid: true}, "2026-07-12 02:51:04", "2026-07-12", ) if diagnosis != "NO_TOTAL_MILEAGE" || reason != "realtime_total_mileage_non_positive" { t.Fatalf("diagnosis=%s reason=%s", diagnosis, reason) } } func TestMetricDiagnosisTreatsAllInvalidSourceSamplesAsSourceData(t *testing.T) { diagnosis, reason := classifyMetricDiagnosis(false, 2, 0, 0, sql.NullFloat64{Float64: 47724.2, Valid: true}, "2026-07-13 00:07:45", "2026-07-13", ) if diagnosis != "MISSING_DAILY" || reason != "source_samples_all_invalid" { t.Fatalf("diagnosis=%s reason=%s", diagnosis, reason) } if got := metricDiagnosisIssueSeverity(diagnosis, reason); got != metricDiagnosisSeveritySourceData { t.Fatalf("severity=%s, want source_data", got) } if action := metricDiagnosisRecommendedOperatorAction(diagnosis, reason); !strings.Contains(action, "质量规则排除") { t.Fatalf("action=%q", action) } } func TestMetricDiagnosisTreatsAllExcludedSourceSamplesAsSourceData(t *testing.T) { diagnosis, reason := classifyMetricDiagnosis(false, 1, 1, 0, sql.NullFloat64{Float64: 33574.6, Valid: true}, "2026-07-13 00:11:56", "2026-07-13", ) if diagnosis != "MISSING_DAILY" || reason != "source_samples_all_excluded" { t.Fatalf("diagnosis=%s reason=%s", diagnosis, reason) } if got := metricDiagnosisIssueSeverity(diagnosis, reason); got != metricDiagnosisSeveritySourceData { t.Fatalf("severity=%s, want source_data", got) } if action := metricDiagnosisRecommendedOperatorAction(diagnosis, reason); !strings.Contains(action, "没有可参与选举的来源") { t.Fatalf("action=%q", action) } } func TestMetricDiagnosisExplainsStaleRealtimeMileage(t *testing.T) { diagnosis, reason := classifyMetricDiagnosis(false, 0, 0, 0, sql.NullFloat64{Float64: 85344, Valid: true}, "2026-07-11 23:59:59", "2026-07-12", ) if diagnosis != "NO_TOTAL_MILEAGE" || reason != "realtime_total_mileage_not_reported_on_stat_date" { t.Fatalf("diagnosis=%s reason=%s", diagnosis, reason) } } func TestMetricDiagnosisExplainsMissingRealtimeMileageTime(t *testing.T) { diagnosis, reason := classifyMetricDiagnosis(false, 0, 0, 0, sql.NullFloat64{Float64: 85344, Valid: true}, "", "2026-07-12", ) if diagnosis != "NO_TOTAL_MILEAGE" || reason != "realtime_total_mileage_time_missing" { t.Fatalf("diagnosis=%s reason=%s", diagnosis, reason) } } func TestMetricDiagnosisExplainsMissingRealtimeMileage(t *testing.T) { diagnosis, reason := classifyMetricDiagnosis(false, 0, 0, 0, sql.NullFloat64{}, "", "2026-07-12", ) if diagnosis != "NO_TOTAL_MILEAGE" || reason != "realtime_total_mileage_missing" { t.Fatalf("diagnosis=%s reason=%s", diagnosis, reason) } } func TestMetricHandlerReturnsDailyMetrics(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM vehicle_daily_mileage m"). WithArgs("LB9A32A21R0LS1707", "GB32960", "2020-07-01", "2020-07-01"). WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(42)) mock.ExpectQuery(dailyMetricSelectPattern). WithArgs("LB9A32A21R0LS1707", "GB32960", "2020-07-01", "2020-07-01", 50, 0). WillReturnRows(sqlmock.NewRows([]string{ "vin", "stat_date", "protocol", "source_id", "source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind", "daily_mileage_km", "latest_total_mileage_km", "updated_at", }).AddRow( "LB9A32A21R0LS1707", "2020-07-01", "GB32960", 1, "8.134.95.166", "8.134.95.166:32960", "现代 HTWO", "HYUNDAI", "PLATFORM", 0.0, 53490.9, "2026-07-01 22:28:25", )) handler := NewMetricHandler(NewMetricRepository(db)) request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics?vin=LB9A32A21R0LS1707&protocol=GB32960&dateFrom=2020-07-01&dateTo=2020-07-01&includeTotal=true", nil) response := httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) } body := response.Body.String() for _, want := range []string{`"vin":"LB9A32A21R0LS1707"`, `"platform_name":"现代 HTWO"`, `"source_kind":"PLATFORM"`, `"daily_mileage_km":0`, `"total":42`} { if !strings.Contains(body, want) { t.Fatalf("response missing %s: %s", want, body) } } if strings.Contains(body, "created_at") { t.Fatalf("daily mileage response should not expose created_at: %s", body) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestMetricHandlerReturnsDailyMetricSources(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM vehicle_daily_mileage_source s"). WithArgs("LA9GG64L7PBAF4001", "JT808", "2026-07-08", "2026-07-08", "historical_source_baseline", 1). WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(2)) mock.ExpectQuery(dailyMetricSourceSelectPattern). WithArgs("LA9GG64L7PBAF4001", "JT808", "2026-07-08", "2026-07-08", "historical_source_baseline", 1, 50, 0). WillReturnRows(sqlmock.NewRows([]string{ "vin", "stat_date", "protocol", "source_key", "source_ip", "source_endpoint", "phone", "device_id", "platform_name", "source_id", "source_code", "source_kind", "enabled", "trust_priority", "first_total_mileage_km", "latest_total_mileage_km", "daily_mileage_km", "sample_count", "first_event_time", "latest_event_time", "quality_status", "quality_reason", "is_selected", "updated_at", }).AddRow( "LA9GG64L7PBAF4001", "2026-07-08", "JT808", "JT808:13307795425@115.231.168.135", "115.231.168.135", "115.231.168.135:41561", "13307795425", nil, "信达", 5, "xinda", "PLATFORM", 1, 10, 4100.8, 4123.9, 23.1, 128, "2026-07-08 00:01:00", "2026-07-08 23:59:00", "OK", "historical_source_baseline", 1, "2026-07-08 23:59:10", )) handler := NewMetricHandler(NewMetricRepository(db)) request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics/sources?vin=LA9GG64L7PBAF4001&protocol=JT808&dateFrom=2026-07-08&dateTo=2026-07-08&qualityReason=Historical_Source_Baseline&selected=true&includeTotal=true", nil) response := httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) } body := response.Body.String() for _, want := range []string{`"total":2`, `"source_key":"JT808:13307795425@115.231.168.135"`, `"platform_name":"信达"`, `"source_kind":"PLATFORM"`, `"is_selected":true`, `"quality_status":"OK"`, `"selection_status":"selected"`, `"selection_reason":"selected_current_projection"`} { if !strings.Contains(body, want) { t.Fatalf("response missing %s: %s", want, body) } } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestMetricHandlerReturnsDailyMetricSourceQualitySummary(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM \\("). WithArgs("JT808", "2026-07-12", "2026-07-12", "INVALID_DELTA", "outside_daily_range"). WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(1)) mock.ExpectQuery(dailyMetricSourceQualitySummarySelectPattern). WithArgs("JT808", "2026-07-12", "2026-07-12", "INVALID_DELTA", "outside_daily_range", 5, 0). WillReturnRows(sqlmock.NewRows([]string{ "stat_date", "protocol", "quality_status", "quality_reason", "source_count", "vehicle_count", "selected_count", "sample_count", "daily_mileage_km", }).AddRow( "2026-07-12", "JT808", "INVALID_DELTA", "outside_daily_range", int64(3), int64(3), int64(0), int64(128), 12435.2, )) handler := NewMetricHandler(NewMetricRepository(db)) request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics/sources/quality?protocol=JT808&dateFrom=2026-07-12&dateTo=2026-07-12&qualityStatus=invalid_delta&qualityReason=Outside_Daily_Range&includeTotal=true&limit=5", nil) response := httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) } body := response.Body.String() for _, want := range []string{`"total":1`, `"protocol":"JT808"`, `"quality_status":"INVALID_DELTA"`, `"quality_reason":"outside_daily_range"`, `"source_count":3`, `"vehicle_count":3`, `"selected_count":0`, `"sample_count":128`} { if !strings.Contains(body, want) { t.Fatalf("response missing %s: %s", want, body) } } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestMetricHandlerReturnsDailyMetricSourceSelectionSummary(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM \\("). WithArgs("JT808", "2026-07-12", "2026-07-12"). WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(2)) mock.ExpectQuery(dailyMetricSourceSelectionSummarySelectPattern). WithArgs("JT808", "2026-07-12", "2026-07-12", 10, 0). WillReturnRows(sqlmock.NewRows([]string{ "stat_date", "protocol", "selection_status", "selection_reason", "source_count", "vehicle_count", "selected_count", "sample_count", "daily_mileage_km", }).AddRow( "2026-07-12", "JT808", "excluded", "quality_outside_daily_range", int64(3), int64(3), int64(0), int64(128), 12435.2, )) handler := NewMetricHandler(NewMetricRepository(db)) request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics/sources/selection?protocol=JT808&dateFrom=2026-07-12&dateTo=2026-07-12&includeTotal=true&limit=10", nil) response := httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) } body := response.Body.String() for _, want := range []string{`"total":2`, `"protocol":"JT808"`, `"selection_status":"excluded"`, `"selection_reason":"quality_outside_daily_range"`, `"selection_action":"来源质量未通过`, `"source_count":3`, `"vehicle_count":3`, `"sample_count":128`} { if !strings.Contains(body, want) { t.Fatalf("response missing %s: %s", want, body) } } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestMetricHandlerReturnsDailyMetricDiagnostics(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() mock.ExpectQuery(dailyMetricDiagnosisSelectPattern). WillReturnRows(sqlmock.NewRows([]string{ "vin", "stat_date", "protocol", "plate", "platform_name", "peer", "snapshot_event_time", "location_event_time", "snapshot_updated_at", "location_updated_at", "realtime_total_mileage_km", "realtime_total_mileage_event_time", "daily_mileage_km", "daily_latest_total_mileage_km", "source_sample_count", "ok_source_count", "selectable_source_count", "latest_stat_event_time", "quality_statuses", "snapshot_parsed_json", }).AddRow( "LMRKH9AC2R1004087", "2026-07-12", "YUTONG_MQTT", "粤A99999", "宇通 MQTT", "yutong", "2026-07-12 10:01:00", nil, "2026-07-12 10:01:01", nil, nil, nil, nil, nil, 0, 0, 0, nil, "", `{"yutong_mqtt.data.latitude":"30.593478","yutong_mqtt.data.longitude":"121.073451","yutong_mqtt.data.meter_speed":"0"}`, )) handler := NewMetricHandler(NewMetricRepository(db)) request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics/diagnostics?protocol=YUTONG_MQTT&date=2026-07-12&diagnosis=NO_TOTAL_MILEAGE", nil) response := httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) } body := response.Body.String() for _, want := range []string{`"vin":"LMRKH9AC2R1004087"`, `"diagnosis":"NO_TOTAL_MILEAGE"`, `"reason":"realtime_total_mileage_missing"`, `"severity":"source_data"`, `"recommended_operator_action":"当日实时数据缺少总里程字段`, `"realtime_field_count":3`, `"realtime_mileage_field_status":"no_candidate_mileage_field"`, `"realtime_mileage_evidence":"实时快照已有字段`, `"raw_frame_query_path":"/api/history/raw-frames?`, `"source_sample_query_path":"/api/stats/daily-metrics/sources?`, `dateFrom=2026-07-12+00%3A00%3A00`, `protocol=YUTONG_MQTT`, `vin=LMRKH9AC2R1004087`, `"yutong_mqtt.data.latitude"`, `"yutong_mqtt.data.meter_speed"`, `"total":1`} { if !strings.Contains(body, want) { t.Fatalf("response missing %s: %s", want, body) } } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestMetricDiagnosisTraceQueryPaths(t *testing.T) { row := MetricDiagnosisRow{ VIN: "LMRKH9AC2R1004087", StatDate: "2026-07-12", Protocol: "YUTONG_MQTT", } rawPath := metricDiagnosisRawFrameQueryPath(row) for _, want := range []string{ "/api/history/raw-frames?", "dateFrom=2026-07-12+00%3A00%3A00", "dateTo=2026-07-13+00%3A00%3A00", "includeFields=true", "limit=20", "orderBy=eventTime", "protocol=YUTONG_MQTT", "vin=LMRKH9AC2R1004087", } { if !strings.Contains(rawPath, want) { t.Fatalf("raw path missing %q: %s", want, rawPath) } } sourcePath := metricDiagnosisSourceSampleQueryPath(row) for _, want := range []string{ "/api/stats/daily-metrics/sources?", "dateFrom=2026-07-12", "dateTo=2026-07-12", "includeTotal=true", "limit=50", "protocol=YUTONG_MQTT", "vin=LMRKH9AC2R1004087", } { if !strings.Contains(sourcePath, want) { t.Fatalf("source path missing %q: %s", want, sourcePath) } } } func TestMetricRepositoryFlagsCandidateMileageFieldsWhenStandardMileageMissing(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() mock.ExpectQuery(dailyMetricDiagnosisSelectPattern). WillReturnRows(sqlmock.NewRows([]string{ "vin", "stat_date", "protocol", "plate", "platform_name", "peer", "snapshot_event_time", "location_event_time", "snapshot_updated_at", "location_updated_at", "realtime_total_mileage_km", "realtime_total_mileage_event_time", "daily_mileage_km", "daily_latest_total_mileage_km", "source_sample_count", "ok_source_count", "selectable_source_count", "latest_stat_event_time", "quality_statuses", "snapshot_parsed_json", }).AddRow( "VIN001", "2026-07-12", "YUTONG_MQTT", "", "宇通 MQTT", "mqtt://yutong/ytforward/shln/3", "2026-07-12 10:01:00", nil, "2026-07-12 10:01:01", nil, nil, nil, nil, nil, 0, 0, 0, nil, "", `{"yutong_mqtt.data.latitude":"30.593478","yutong_mqtt.data.odometer":"65423000","yutong_mqtt.data.meter_speed":"12"}`, )) repository := NewMetricRepository(db) rows, err := repository.QueryDiagnostics(context.Background(), MetricDiagnosisQuery{ Protocol: "YUTONG_MQTT", Date: "2026-07-12", Diagnosis: "NO_TOTAL_MILEAGE", Limit: 20, }) if err != nil { t.Fatalf("QueryDiagnostics() error = %v", err) } if len(rows) != 1 { t.Fatalf("row count = %d", len(rows)) } row := rows[0] if row.RealtimeMileageFieldStatus != "candidate_field_unmapped" { t.Fatalf("mileage field status = %q, row=%#v", row.RealtimeMileageFieldStatus, row) } if len(row.RealtimeMileageCandidateFields) != 1 || row.RealtimeMileageCandidateFields[0] != "yutong_mqtt.data.odometer" { t.Fatalf("candidate fields = %#v", row.RealtimeMileageCandidateFields) } if !strings.Contains(row.RealtimeMileageEvidence, "协议字段映射") { t.Fatalf("evidence = %q", row.RealtimeMileageEvidence) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestMetricRepositoryTreatsMergedMappedMileageFieldAsStaleEvidence(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() mock.ExpectQuery(dailyMetricDiagnosisSelectPattern). WillReturnRows(sqlmock.NewRows([]string{ "vin", "stat_date", "protocol", "plate", "platform_name", "peer", "snapshot_event_time", "location_event_time", "snapshot_updated_at", "location_updated_at", "realtime_total_mileage_km", "realtime_total_mileage_event_time", "daily_mileage_km", "daily_latest_total_mileage_km", "source_sample_count", "ok_source_count", "selectable_source_count", "latest_stat_event_time", "quality_statuses", "snapshot_parsed_json", }).AddRow( "VIN001", "2026-07-12", "YUTONG_MQTT", "", "宇通 MQTT", "mqtt://yutong/ytforward/shln/3", "2026-07-12 10:01:00", nil, "2026-07-12 10:01:01", nil, nil, nil, nil, nil, 0, 0, 0, nil, "", `{"yutong_mqtt.data.latitude":"30.593478","yutong_mqtt.data.total_mileage":"65423000","yutong_mqtt.data.meter_speed":"12"}`, )) repository := NewMetricRepository(db) rows, err := repository.QueryDiagnostics(context.Background(), MetricDiagnosisQuery{ Protocol: "YUTONG_MQTT", Date: "2026-07-12", Diagnosis: "NO_TOTAL_MILEAGE", Limit: 20, }) if err != nil { t.Fatalf("QueryDiagnostics() error = %v", err) } if len(rows) != 1 { t.Fatalf("row count = %d", len(rows)) } row := rows[0] if row.RealtimeMileageFieldStatus != "mapped_protocol_field_without_fresh_evidence" { t.Fatalf("mileage field status = %q, row=%#v", row.RealtimeMileageFieldStatus, row) } if len(row.RealtimeMileageCandidateFields) != 1 || row.RealtimeMileageCandidateFields[0] != "yutong_mqtt.data.total_mileage" { t.Fatalf("candidate fields = %#v", row.RealtimeMileageCandidateFields) } if !strings.Contains(row.RealtimeMileageEvidence, "合并快照") { t.Fatalf("evidence = %q", row.RealtimeMileageEvidence) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestMetricRepositoryReportsStaleStandardMileageBeforeCandidateFields(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() mock.ExpectQuery(dailyMetricDiagnosisSelectPattern). WillReturnRows(sqlmock.NewRows([]string{ "vin", "stat_date", "protocol", "plate", "platform_name", "peer", "snapshot_event_time", "location_event_time", "snapshot_updated_at", "location_updated_at", "realtime_total_mileage_km", "realtime_total_mileage_event_time", "daily_mileage_km", "daily_latest_total_mileage_km", "source_sample_count", "ok_source_count", "selectable_source_count", "latest_stat_event_time", "quality_statuses", "snapshot_parsed_json", }).AddRow( "VIN001", "2026-07-13", "YUTONG_MQTT", "", "宇通 MQTT", "mqtt://yutong/ytforward/shln/3", "2026-07-13 10:01:00", "2026-07-13 10:01:00", "2026-07-13 10:01:01", "2026-07-13 10:01:01", 65423.0, "2026-07-12 23:59:59", nil, nil, 0, 0, 0, nil, "", `{"yutong_mqtt.data.total_mileage":"65423000"}`, )) repository := NewMetricRepository(db) rows, err := repository.QueryDiagnostics(context.Background(), MetricDiagnosisQuery{ Protocol: "YUTONG_MQTT", Date: "2026-07-13", Diagnosis: "NO_TOTAL_MILEAGE", Limit: 20, }) if err != nil { t.Fatalf("QueryDiagnostics() error = %v", err) } if len(rows) != 1 { t.Fatalf("row count = %d", len(rows)) } row := rows[0] if row.Reason != "realtime_total_mileage_not_reported_on_stat_date" { t.Fatalf("reason = %q", row.Reason) } if row.RealtimeMileageFieldStatus != "standard_field_stale" { t.Fatalf("mileage field status = %q, row=%#v", row.RealtimeMileageFieldStatus, row) } if len(row.RealtimeMileageCandidateFields) != 0 { t.Fatalf("candidate fields should be hidden for standard stale field: %#v", row.RealtimeMileageCandidateFields) } if !strings.Contains(row.RealtimeMileageEvidence, "不在统计日") { t.Fatalf("evidence = %q", row.RealtimeMileageEvidence) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestMetricHandlerReturnsDailyMetricDiagnosisSummary(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() mock.ExpectQuery(dailyMetricDiagnosisSummarySelectPattern). WillReturnRows(sqlmock.NewRows([]string{ "stat_date", "protocol", "active_count", "ok_count", "missing_daily_count", "no_source_sample_count", "no_total_mileage_count", }).AddRow( "2026-07-12", "YUTONG_MQTT", int64(13), int64(7), int64(0), int64(0), int64(6), )) handler := NewMetricHandler(NewMetricRepository(db)) request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics/diagnostics/summary?protocol=YUTONG_MQTT&date=2026-07-12", nil) response := httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) } body := response.Body.String() for _, want := range []string{`"protocol":"YUTONG_MQTT"`, `"active_count":13`, `"ok_count":7`, `"no_total_mileage_count":6`, `"active_total":13`, `"actionable_issue_total":6`} { if !strings.Contains(body, want) { t.Fatalf("response missing %s: %s", want, body) } } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestMetricHandlerReturnsDailyMetricDiagnosisReasonSummary(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() mock.ExpectQuery(dailyMetricDiagnosisReasonSummarySelectPattern). WillReturnRows(sqlmock.NewRows([]string{ "stat_date", "protocol", "diagnosis", "reason", "count", }).AddRow( "2026-07-12", "JT808", "NO_TOTAL_MILEAGE", "realtime_total_mileage_non_positive", int64(2), )) handler := NewMetricHandler(NewMetricRepository(db)) request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics/diagnostics/reasons?protocol=JT808&date=2026-07-12&diagnosis=NO_TOTAL_MILEAGE", nil) response := httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) } body := response.Body.String() for _, want := range []string{`"protocol":"JT808"`, `"diagnosis":"NO_TOTAL_MILEAGE"`, `"reason":"realtime_total_mileage_non_positive"`, `"count":2`, `"severity":"source_data"`, `"recommended_operator_action":"终端上报总里程小于等于 0`, `"vehicle_total":2`, `"actionable_issue_total":2`, `"pipeline_issue_total":0`, `"source_data_issue_total":2`} { if !strings.Contains(body, want) { t.Fatalf("response missing %s: %s", want, body) } } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestMetricHandlerReturnsDailyMetricDiagnosisFieldStatusSummary(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() mock.ExpectQuery(dailyMetricDiagnosisFieldStatusSummarySelectPattern). WillReturnRows(sqlmock.NewRows([]string{ "stat_date", "protocol", "diagnosis", "reason", "realtime_mileage_field_status", "count", }).AddRow( "2026-07-12", "JT808", "NO_TOTAL_MILEAGE", "realtime_total_mileage_non_positive", "standard_field_non_positive", int64(2), )) handler := NewMetricHandler(NewMetricRepository(db)) request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics/diagnostics/field-status?protocol=JT808&date=2026-07-12&diagnosis=NO_TOTAL_MILEAGE", nil) response := httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) } body := response.Body.String() for _, want := range []string{`"protocol":"JT808"`, `"diagnosis":"NO_TOTAL_MILEAGE"`, `"reason":"realtime_total_mileage_non_positive"`, `"realtime_mileage_field_status":"standard_field_non_positive"`, `"field_status_severity":"source_data"`, `"field_status_action":"源头总里程小于等于 0`, `"vehicle_total":2`, `"source_data_issue_total":2`} { if !strings.Contains(body, want) { t.Fatalf("response missing %s: %s", want, body) } } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestMetricHandlerReturnsEmptyItemsArrayWhenNoRows(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() mock.ExpectQuery(dailyMetricSelectPattern). WithArgs("YUTONG_MQTT", 50, 0). WillReturnRows(sqlmock.NewRows([]string{ "vin", "stat_date", "protocol", "source_id", "source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind", "daily_mileage_km", "latest_total_mileage_km", "updated_at", })) handler := NewMetricHandler(NewMetricRepository(db)) request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics?protocol=YUTONG_MQTT", nil) response := httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) } body := response.Body.String() if !strings.Contains(body, `"items":[]`) || strings.Contains(body, `"items":null`) { t.Fatalf("expected empty items array, got: %s", body) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestMetricHandlerSkipsTotalCountByDefault(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() mock.ExpectQuery(dailyMetricSelectPattern). WithArgs("JT808", 1, 0). WillReturnRows(sqlmock.NewRows([]string{ "vin", "stat_date", "protocol", "source_id", "source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind", "daily_mileage_km", "latest_total_mileage_km", "updated_at", }).AddRow( "LKLG7C4E3NA774736", "2026-07-02", "JT808", 3, "115.231.168.135", "115.231.168.135:41561", "G7 平台", "G7S", "PLATFORM", 12.3, 8805.1, "2026-07-02 23:59:59", )) handler := NewMetricHandler(NewMetricRepository(db)) request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics?protocol=JT808&limit=1", nil) response := httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) } body := response.Body.String() if !strings.Contains(body, `"total":1`) { t.Fatalf("response should use page size as total when total count is not requested: %s", body) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestMetricHandlerRejectsInvalidPagination(t *testing.T) { handler := NewMetricHandler(NewMetricRepository(&sql.DB{})) request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics?limit=2001", nil) response := httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) } } func TestMetricHandlerRejectsInvalidDiagnosis(t *testing.T) { handler := NewMetricHandler(NewMetricRepository(&sql.DB{})) request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics/diagnostics?diagnosis=BAD", nil) response := httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) } } func TestMetricHandlerRejectsInvalidDiagnosisReason(t *testing.T) { handler := NewMetricHandler(NewMetricRepository(&sql.DB{})) request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics/diagnostics?reason=unknown_reason", nil) response := httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) } } func TestMetricHandlerRejectsInvalidDiagnosisSeverity(t *testing.T) { handler := NewMetricHandler(NewMetricRepository(&sql.DB{})) request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics/diagnostics?severity=critical", nil) response := httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) } }