450 lines
15 KiB
Go
450 lines
15 KiB
Go
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
|
|
}
|