package realtime import ( "encoding/json" "strings" "testing" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/protocol/yutongmqtt" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/telemetry" ) func TestRealtimeKVFieldsFromGB32960ParsedDomains(t *testing.T) { rows := realtimeKVFields(envelope.FrameEnvelope{ Protocol: envelope.ProtocolGB32960, VIN: "VIN001", EventTimeMS: 1000, ReceivedAtMS: 1100, EventID: "event-1", }, map[string]any{ "header": map[string]any{"vin": "VIN001", "command": "0x02"}, "data_units": []any{ map[string]any{"type": "0x01", "name": "vehicle", "value": map[string]any{"soc_percent": 88.0}}, map[string]any{"type": "0x30", "name": "gd_fc_stack", "value": map[string]any{ "stack_count": 1, "summaries": []any{ map[string]any{"stack_water_outlet_temp_c": 63, "hydrogen_inlet_pressure_kpa": 130}, }, }}, }, }) values := kvMap(rows) if values["gb32960.header/vin"] != "VIN001" || values["gb32960.header/command"] != "0x02" { t.Fatalf("gb32960 header kv missing: %#v", values) } if values["gb32960.vehicle/type"] != "0x01" || values["gb32960.vehicle/soc_percent"] != "88" { t.Fatalf("vehicle soc kv missing: %#v", values) } if values["gb32960.gd_fc_stack/stack_water_outlet_temp_c"] != "63" || values["gb32960.gd_fc_stack/hydrogen_inlet_pressure_kpa"] != "130" { t.Fatalf("stack pressure kv missing: %#v", values) } } func TestBuildFieldsEnvelopeUsesOnlyProtocolMappedFieldNames(t *testing.T) { env := envelope.FrameEnvelope{ Protocol: envelope.ProtocolGB32960, VIN: "VIN001", MessageID: "0x02", EventTimeMS: 1000, ReceivedAtMS: 1100, EventID: "event-1", Fields: map[string]any{ "charge_status": 1, envelope.FieldSOCPercent: 88.0, envelope.FieldTotalMileageKM: 100.1, }, Parsed: map[string]any{ "data_units": []any{ map[string]any{"type": "0x01", "name": "vehicle", "value": map[string]any{ "charge_status": 1, "soc_percent": 88.0, "total_mileage_km": 100.1, }}, }, }, } if !EnsureParsedFields(&env) { t.Fatal("EnsureParsedFields() should compute ingress fields") } fieldsEnv, ok := BuildFieldsEnvelope(env) if !ok { t.Fatal("BuildFieldsEnvelope() should emit mapped fields") } if len(fieldsEnv.ParsedFields) != 0 || len(fieldsEnv.ParsedFieldTypes) != 0 || len(fieldsEnv.Parsed) != 0 { t.Fatalf("fields envelope should keep only slim fields payload, parsed=%#v parsed_fields=%#v parsed_field_types=%#v", fieldsEnv.Parsed, fieldsEnv.ParsedFields, fieldsEnv.ParsedFieldTypes) } for _, bareKey := range []string{"charge_status", "soc_percent", "total_mileage_km"} { if _, exists := fieldsEnv.Fields[bareKey]; exists { t.Fatalf("fields envelope should not expose non-protocol bare key %q: %#v", bareKey, fieldsEnv.Fields) } } for _, mappedKey := range []string{ "gb32960.vehicle.charge_status", "gb32960.vehicle.soc_percent", "gb32960.vehicle.total_mileage_km", } { if _, exists := fieldsEnv.Fields[mappedKey]; !exists { t.Fatalf("fields envelope missing mapped key %q: %#v", mappedKey, fieldsEnv.Fields) } } } func TestEnsureParsedFieldsKeepsUnknownVINProtocolFieldsAndExcludesAnnotations(t *testing.T) { env := envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, Phone: "13307795425", Parsed: map[string]any{ "registration": map[string]any{ "manufacturer": "YUTNG", }, "identity": map[string]any{ "resolved": false, "reason": "no_binding", }, }, ParsedFields: map[string]any{ "jt808.location.speed_kmh": "12.3", }, } if !EnsureParsedFields(&env) { t.Fatal("unknown VIN raw frame should still retain parsed fields") } if got := env.ParsedFields["jt808.location.speed_kmh"]; got != "12.3" { t.Fatalf("precomputed field = %#v", got) } if got := env.ParsedFields["jt808.registration.manufacturer"]; got != "YUTNG" { t.Fatalf("merged registration field = %#v", got) } if _, exists := env.ParsedFields["jt808.identity.resolved"]; exists { t.Fatalf("derived identity annotations must not enter protocol fields: %#v", env.ParsedFields) } for _, field := range []string{"jt808.location.speed_kmh", "jt808.registration.manufacturer"} { if env.ParsedFieldTypes[field] == "" { t.Fatalf("field type missing for %s: %#v", field, env.ParsedFieldTypes) } } } func TestBuildFieldsEnvelopeCopiesSourceMetadata(t *testing.T) { env := envelope.FrameEnvelope{ Protocol: envelope.ProtocolYutongMQTT, VIN: "LMRKH9AC3R1004101", MessageID: "MQTT", SourceEndpoint: "mqtt://yutong/ytforward/shln/1", SourceCode: "yutong", PlatformName: "宇通", SourceKind: "PLATFORM", EventTimeMS: 1000, ReceivedAtMS: 1100, EventID: "event-yutong", Fields: map[string]any{ envelope.FieldTotalMileageKM: 56905, }, Parsed: map[string]any{ "data": map[string]any{"TOTAL_MILEAGE": 56905000}, }, } if !EnsureParsedFields(&env) { t.Fatal("EnsureParsedFields() should compute ingress fields") } fieldsEnv, ok := BuildFieldsEnvelope(env) if !ok { t.Fatal("BuildFieldsEnvelope() should emit mapped fields") } if fieldsEnv.SourceCode != "yutong" || fieldsEnv.PlatformName != "宇通" || fieldsEnv.SourceKind != "PLATFORM" { t.Fatalf("source metadata = code:%q platform:%q kind:%q", fieldsEnv.SourceCode, fieldsEnv.PlatformName, fieldsEnv.SourceKind) } } func TestBuildFieldsEnvelopeKeepsYutongJSONNumberMileage(t *testing.T) { env, err := yutongmqtt.ParseMessage( "yutong", "/ytforward/shln/1", []byte(`{"device":"LMRKH9AC2R1004106","time":"2026-07-17 11:50:13.021","data":{"LATITUDE":30.466027,"LONGITUDE":103.96096,"METER_SPEED":0,"TOTAL_MILEAGE":77081000}}`), 1784260213051, ) if err != nil { t.Fatalf("ParseMessage() error = %v", err) } if !EnsureParsedFields(&env) { t.Fatal("EnsureParsedFields() should compute ingress fields") } fieldsEnv, ok := BuildFieldsEnvelope(env) if !ok { t.Fatal("BuildFieldsEnvelope() should emit Yutong fields") } for _, field := range []string{ "yutong_mqtt.data.total_mileage", "yutong_mqtt.root.data.total_mileage", } { if got := fieldsEnv.Fields[field]; got != "77081000" { t.Fatalf("%s = %#v, want 77081000", field, got) } } mileageKM, found := telemetry.TotalMileageKM(fieldsEnv.Protocol, fieldsEnv.Fields) if !found || mileageKM != 77081 { t.Fatalf("TotalMileageKM() = %.3f, %v; want 77081, true", mileageKM, found) } } func TestBuildFieldsEnvelopeDropsNonPositiveTotalMileage(t *testing.T) { fieldsEnv, ok := BuildFieldsEnvelope(envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, VIN: "VIN001", MessageID: "0x0200", EventTimeMS: 1000, ReceivedAtMS: 1100, EventID: "event-1", ParsedFields: map[string]any{ "jt808.location.latitude": "31.259555", "jt808.location.longitude": "119.892413", "jt808.location.total_mileage_km": "0", }, }) if !ok { t.Fatal("BuildFieldsEnvelope() should still emit valid fields") } if _, exists := fieldsEnv.Fields["jt808.location.total_mileage_km"]; exists { t.Fatalf("fields envelope should drop non-positive mileage: %#v", fieldsEnv.Fields) } if fieldsEnv.Fields["jt808.location.latitude"] != "31.259555" { t.Fatalf("fields envelope should keep valid location fields: %#v", fieldsEnv.Fields) } } func TestBuildFieldsEnvelopeDropsNonPositiveRawTotalMileage(t *testing.T) { env := envelope.FrameEnvelope{ Protocol: envelope.ProtocolYutongMQTT, VIN: "LMRKH9AC3R1004101", MessageID: "MQTT", EventTimeMS: 1000, ReceivedAtMS: 1100, EventID: "event-yutong-zero-mileage", Parsed: map[string]any{ "data": map[string]any{ "LATITUDE": 30.590921, "LONGITUDE": 121.075044, "TOTAL_MILEAGE": 0, }, }, } if !EnsureParsedFields(&env) { t.Fatal("EnsureParsedFields() should compute ingress fields") } fieldsEnv, ok := BuildFieldsEnvelope(env) if !ok { t.Fatal("BuildFieldsEnvelope() should still emit non-mileage fields") } if _, exists := fieldsEnv.Fields["yutong_mqtt.data.total_mileage"]; exists { t.Fatalf("fields envelope should drop non-positive raw mileage: %#v", fieldsEnv.Fields) } if fieldsEnv.Fields["yutong_mqtt.data.latitude"] != "30.590921" { t.Fatalf("fields envelope should keep valid location fields: %#v", fieldsEnv.Fields) } } func TestBuildFieldsEnvelopeJSONOmitsDuplicateParsedPayload(t *testing.T) { fieldsEnv, ok := BuildFieldsEnvelope(envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, VIN: "VIN001", MessageID: "0x0200", EventTimeMS: 1000, ReceivedAtMS: 1100, EventID: "event-1", ParsedFields: map[string]any{ "jt808.location.total_mileage_km": "10241.2", "jt808.location.speed_kmh": "23", }, ParsedFieldTypes: map[string]string{ "jt808.location.total_mileage_km": "number", "jt808.location.speed_kmh": "number", }, }) if !ok { t.Fatal("BuildFieldsEnvelope() should emit mapped fields") } data, err := json.Marshal(fieldsEnv) if err != nil { t.Fatalf("Marshal(fieldsEnv) error = %v", err) } text := string(data) for _, duplicateKey := range []string{`"parsed_fields"`, `"parsed_field_types"`, `"parsed"`} { if strings.Contains(text, duplicateKey) { t.Fatalf("fields JSON should omit duplicate %s payload: %s", duplicateKey, text) } } if !strings.Contains(text, `"fields"`) || !strings.Contains(text, `"source_event_id"`) || !strings.Contains(text, `"field_mapping"`) { t.Fatalf("fields JSON missing slim payload metadata: %s", text) } } func TestBuildFieldsEnvelopeOmitsGB32960VendorFragmentFields(t *testing.T) { env := envelope.FrameEnvelope{ Protocol: envelope.ProtocolGB32960, VIN: "VIN001", MessageID: "0x02", EventTimeMS: 1000, ReceivedAtMS: 1100, Parsed: map[string]any{ "data_units": []any{ map[string]any{ "type": "0x30", "name": "gd_fc_stack", "value": map[string]any{ "stack_count": 1, "summaries": []any{ map[string]any{ "cell_count": 432, "stack_water_outlet_temp_c": 63, "frame_cell_start": 401, "frame_cell_count": 32, "frame_max_cell_voltage_v": 1, "frame_min_cell_voltage_v": 1, "hydrogen_inlet_pressure_kpa": 130, "air_inlet_pressure_kpa": 150, "stack_water_outlet_temp_extra": "kept", }, }, }, }, }, }, } if !EnsureParsedFields(&env) { t.Fatal("EnsureParsedFields() should compute ingress fields") } fieldsEnv, ok := BuildFieldsEnvelope(env) if !ok { t.Fatal("BuildFieldsEnvelope() should emit mapped fields") } for _, fragmentField := range []string{ "gb32960.gd_fc_stack.frame_cell_start", "gb32960.gd_fc_stack.frame_cell_count", "gb32960.gd_fc_stack.frame_max_cell_voltage_v", "gb32960.gd_fc_stack.frame_min_cell_voltage_v", } { if _, exists := fieldsEnv.Fields[fragmentField]; exists { t.Fatalf("fields envelope should omit fragment-only field %q: %#v", fragmentField, fieldsEnv.Fields) } } for _, keptField := range []string{ "gb32960.gd_fc_stack.stack_count", "gb32960.gd_fc_stack.stack_water_outlet_temp_c", "gb32960.gd_fc_stack.hydrogen_inlet_pressure_kpa", } { if _, exists := fieldsEnv.Fields[keptField]; !exists { t.Fatalf("fields envelope should keep current-state field %q: %#v", keptField, fieldsEnv.Fields) } } } func TestBuildFieldsEnvelopeRequiresIngressParsedFields(t *testing.T) { _, ok := BuildFieldsEnvelope(envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, VIN: "VIN001", MessageID: "0x0200", EventTimeMS: 1000, ReceivedAtMS: 1100, Parsed: map[string]any{ "location": map[string]any{"speed_kmh": 99}, }, }) if ok { t.Fatal("BuildFieldsEnvelope() must not re-flatten parsed payload downstream") } } func TestRealtimeKVFieldsFromJT808ParsedFields(t *testing.T) { jtRows := realtimeKVFields(envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, VIN: "VIN001", Fields: map[string]any{ envelope.FieldLongitude: 121.1, envelope.FieldLatitude: 30.2, envelope.FieldTotalMileageKM: 10241.2, }, }, map[string]any{ "header": map[string]any{"message_id": "0x0200", "phone": "13307795425"}, "location": map[string]any{ "longitude": 121.1, "latitude": 30.2, "total_mileage_km": 10241.2, "additional": []any{map[string]any{"id": "0x01", "value_hex": "00077235"}}, "io_status": map[string]any{ "value": uint16(2), "bits": map[string]bool{"deep_sleep": false, "sleep": true}, }, }, }) jtValues := kvMap(jtRows) if jtValues["jt808.header/message_id"] != "0x0200" || jtValues["jt808.header/phone"] != "13307795425" { t.Fatalf("jt808 header kv missing: %#v", jtValues) } if jtValues["jt808.location/total_mileage_km"] != "10241.2" || jtValues["jt808.location/longitude"] != "121.1" || jtValues["jt808.location/additional.additional_1.id"] != "0x01" || jtValues["jt808.location/io_status.bits.deep_sleep"] != "false" || jtValues["jt808.location/io_status.bits.sleep"] != "true" { t.Fatalf("jt808 location kv missing: %#v", jtValues) } if _, exists := jtValues["jt808.location/io_status.bits"]; exists { t.Fatalf("JT808 bit fields must be flattened instead of embedded JSON: %#v", jtValues) } if jtValues["jt808.location/soc_percent"] != "" { t.Fatalf("jt808 kv should not include standardized env.Fields-only values: %#v", jtValues) } } func TestRealtimeKVFieldsFromYutongMQTTParsedFields(t *testing.T) { mqttRows := realtimeKVFields(envelope.FrameEnvelope{ Protocol: envelope.ProtocolYutongMQTT, VIN: "VIN002", Fields: map[string]any{ envelope.FieldSOCPercent: 76, "gear": 3, }, }, map[string]any{ "endpoint": "yutong", "topic": "/ytforward/shln/3", "data": map[string]any{ "ACC_PEDAL_APT": 14, "BATTERY_CAPACITY_SOC": 78.4, "CURRENT_OF_FC": 54.9, "HYDROGEN_LOW_PRESSURE": 1.41, "TOTAL_MILEAGE": 119925000, "fuelCellCoolInTempt": 59, }, "root": map[string]any{ "device": "LMRKH9AC2R1004087", "version": "1.0", }, }) mqttValues := kvMap(mqttRows) if mqttValues["yutong_mqtt.data/acc_pedal_apt"] != "14" || mqttValues["yutong_mqtt.data/current_of_fc"] != "54.9" || mqttValues["yutong_mqtt.data/hydrogen_low_pressure"] != "1.41" || mqttValues["yutong_mqtt.data/total_mileage"] != "119925000" || mqttValues["yutong_mqtt.data/fuelcellcoolintempt"] != "59" { t.Fatalf("mqtt raw data kv missing: %#v", mqttValues) } if mqttValues["yutong_mqtt.root/device"] != "LMRKH9AC2R1004087" || mqttValues["yutong_mqtt.metadata/endpoint"] != "yutong" || mqttValues["yutong_mqtt.metadata/topic"] != "/ytforward/shln/3" { t.Fatalf("mqtt metadata kv missing: %#v", mqttValues) } if mqttValues["yutong_mqtt.data/soc_percent"] != "" || mqttValues["yutong_mqtt.data/gear"] != "" { t.Fatalf("mqtt kv should not include standardized env.Fields-only values: %#v", mqttValues) } } func kvMap(rows []RealtimeKVField) map[string]string { out := map[string]string{} for _, row := range rows { out[row.Domain+"/"+row.Field] = row.Value } return out }