package gb32960 import ( "bytes" "encoding/hex" "fmt" "reflect" "strings" "testing" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" ) const feichiCustom80Payload = "3B1428271014282710024E01560000" func TestFeichiCustom80UsesDeclaredLengthAndContinuesWithStandardData(t *testing.T) { for _, command := range []byte{0x02, 0x03} { for _, length := range []int{0, 1, 9, 15, 32, 513} { t.Run(fmt.Sprintf("command_%02X_length_%d", command, length), func(t *testing.T) { payload := bytes.Repeat([]byte{0x80, 0x05, 0x02, 0x23, 0x23}, (length+4)/5)[:length] if length == 15 { payload, _ = hex.DecodeString(feichiCustom80Payload) } body := []byte{0x1a, 0x0a, 0x17, 0x0a, 0x00, 0x00, 0x80, byte(length >> 8), byte(length)} body = append(body, payload...) body = append(body, feichiTestPositionUnit()...) body = append(body, 0x34, 0, 0, 0, 0, 0, 0, 0x2b) frame := buildFrame(command, 0xfe, "LA9GG68L7PBAF4803", body) env, err := ParseFrameForPlatform(frame, 1234, "183.239.48.25:2147", "Feichi") if err != nil { t.Fatal(err) } if env.ParseStatus != envelope.ParseOK || env.RawHex != strings.ToUpper(hex.EncodeToString(frame)) { t.Fatalf("status=%s raw=%s", env.ParseStatus, env.RawHex) } units := env.Parsed["data_units"].([]map[string]any) if len(units) != 3 || units[0]["name"] != "feichi_custom_80" || units[1]["name"] != "position" || units[2]["name"] != "gd_fc_vehicle_info" { t.Fatalf("wrong unit boundaries: %#v", units) } block := feichiBlock(t, units[0], 1) if block["declared_length"] != length || block["actual_length"] != length || block["payload_hex"] != strings.ToUpper(hex.EncodeToString(payload)) { t.Fatalf("block=%#v", block) } if want := fmt.Sprintf("80%04X%X", length, payload); block["raw_hex"] != want { t.Fatalf("raw=%v want=%s", block["raw_hex"], want) } assertFloatField(t, env, envelope.FieldLongitude, 121) assertFloatField(t, env, envelope.FieldLatitude, 30.56) assertFloatField(t, env, "gd_fc_vehicle_hydrogen_mass_kg", 4.3) if _, exists := env.Fields["gd_fc_demo_stack_temp_c"]; exists { t.Fatal("Feichi opaque bytes must not become Guangdong measurement fields") } }) } } } func TestFeichiCustom80TruncationKeepsEveryReceivedByte(t *testing.T) { for _, tc := range []struct { name string data []byte error string payload string }{ {"missing_length", nil, "truncated_length", ""}, {"partial_length", []byte{0}, "truncated_length", ""}, {"short_payload", []byte{0, 15, 0x3b, 0x14, 0x28}, "truncated_payload", "3B1428"}, {"oversized_length", []byte{0xff, 0xff, 0x80}, "truncated_payload", "80"}, } { t.Run(tc.name, func(t *testing.T) { body := append([]byte{0x1a, 0x0a, 0x17, 0x0a, 0, 0}, feichiTestPositionUnit()...) body = append(body, 0x80) body = append(body, tc.data...) frame := buildFrame(0x02, 0xfe, "LA9GG68L7PBAF4803", body) env, err := ParseFrameForPlatform(frame, 1234, "source", "Feichi") if err != nil { t.Fatal(err) } if env.ParseStatus != envelope.ParsePartial || !strings.Contains(env.ParseError, tc.error) { t.Fatalf("status=%s error=%q", env.ParseStatus, env.ParseError) } if env.RawHex != strings.ToUpper(hex.EncodeToString(frame)) { t.Fatal("whole RAW frame was changed") } units := env.Parsed["data_units"].([]map[string]any) if len(units) != 2 || units[0]["name"] != "position" { t.Fatalf("units=%#v", units) } assertFloatField(t, env, envelope.FieldLongitude, 121) block := feichiBlock(t, units[1], 1) if block["raw_hex"] != fmt.Sprintf("80%X", tc.data) || block["payload_hex"] != tc.payload || block["parse_error"] != tc.error { t.Fatalf("received bytes not preserved: %#v", block) } }) } } func TestPlatformParserPreservesExisting32960Accounts(t *testing.T) { frame, err := hex.DecodeString(realFuelCellFrame) if err != nil { t.Fatal(err) } want, err := ParseFrame(frame, 1234, "same-source") if err != nil { t.Fatal(err) } for _, account := range []string{"", "Hyundai", "YueJin", "GuangJiaoTou", "feichi", "Feichi-other"} { t.Run(account, func(t *testing.T) { got, err := ParseFrameForPlatform(frame, 1234, "same-source", account) if err != nil || !reflect.DeepEqual(got, want) { t.Fatalf("legacy account changed: account=%q err=%v", account, err) } }) } assertIntField(t, want, "gd_fc_demo_stack_temp_c", 65) assertFloatField(t, want, "gd_fc_vehicle_hydrogen_mass_kg", 4.3) } func TestFeichiFramesWithoutCustom80KeepExistingResults(t *testing.T) { for _, command := range []byte{0x02, 0x03} { frame := buildFrame(command, 0xfe, "LA9GG68L7PBAF4803", append([]byte{0x1a, 0x0a, 0x17, 0x0a, 0, 0}, feichiTestPositionUnit()...)) want, err := ParseFrame(frame, 1234, "same-source") if err != nil { t.Fatal(err) } got, err := ParseFrameForPlatform(frame, 1234, "same-source", "Feichi") if err != nil || !reflect.DeepEqual(got, want) { t.Fatalf("standard Feichi frame changed: %v", err) } } } func feichiBlock(t *testing.T, unit map[string]any, index int) map[string]any { t.Helper() return unit["value"].(map[string]any)["blocks"].(map[string]any)[fmt.Sprintf("block_%d", index)].(map[string]any) } func feichiTestPositionUnit() []byte { return []byte{0x05, 0x00, 0x07, 0x36, 0x50, 0x40, 0x01, 0xd2, 0x4f, 0x00} }