test(gb32960): end-to-end test for Guangdong fuel-cell vendor profile

Adds GuangdongFcEndToEndTest covering the full vendor extension pipeline:
build a synthetic V2016 realtime frame containing standard blocks
(0x01/0x05/0x07/0x08/0x09) plus the Guangdong-specific 0x30 stack (with
4 sample cell voltages), 0x32 DC/DC, 0x33 air conditioner, 0x34 vehicle
info, and 0x80 demo extension. Decodes it through Gb32960MessageDecoder
with a hand-wired BodyParser whose Gb32960ProfileRegistry knows about
the guangdong-fc catalog entry and a RuleBasedVendorExtensionSelector
matching platformAccount=lingniu.

Two cases:
- parsesRealtimeFrameWithGuangdongFcExtensions: passes "lingniu" as the
  platform account, asserts each GuangdongFc.* record is present and
  field values match the synthesized bytes, and that no Raw fallback
  is produced.
- unmatchedAccountFallsBackToDefaultAndProducesRawForVendorBlocks: passes
  null as the account, asserts standard blocks still parse but the
  vendor 0x30 segment now lands in InfoBlock.Raw via the default-profile
  unknown-typeCode path. This pins down the selector's gate behavior.

Test count: 30 -> 32. All green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lingniu-dev
2026-04-15 16:56:04 +08:00
parent dd8f75e0da
commit 184963e1c2

View File

@@ -0,0 +1,296 @@
package com.lingniu.ingest.protocol.gb32960.codec;
import com.lingniu.ingest.codec.BccChecksum;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.AlarmV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.DriveMotorV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.PositionV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.TemperatureV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VehicleV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VoltageV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong.GdFcAirConditionerBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong.GdFcAuxiliaryBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong.GdFcDcDcBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong.GdFcDemoExtensionBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong.GdFcStackBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong.GdFcVehicleInfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.profile.Gb32960ProfileRegistry;
import com.lingniu.ingest.protocol.gb32960.codec.profile.RuleBasedVendorExtensionSelector;
import com.lingniu.ingest.protocol.gb32960.codec.profile.VendorExtensionCatalog;
import com.lingniu.ingest.protocol.gb32960.config.Gb32960Properties;
import com.lingniu.ingest.protocol.gb32960.inbound.Gb32960ChannelHandler;
import com.lingniu.ingest.protocol.gb32960.model.Gb32960Message;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.within;
/**
* 端到端集成测试:构造一帧带广东燃料电池规范扩展块的 V2016 帧,验证
* vendor profile selector 路由 + GuangdongFc 系列 parser 全链路解码。
*
* <p>覆盖:
* <ul>
* <li>9 个 GB/T 32960 标准块0x01~0x09原生解析
* <li>0x30 GuangdongFc.Stack含 1 个电堆、4 个单体电压样本)
* <li>0x32 GuangdongFc.DcDc
* <li>0x33 GuangdongFc.AirConditioner
* <li>0x34 GuangdongFc.VehicleInfo
* <li>0x80 GuangdongFc.DemoExtension
* <li>VendorExtensionSelector 按 platformAccount=lingniu 命中
* </ul>
*
* <p>0x31 Auxiliary 没有放进帧里(变长且字段相对琐碎),单独由 unit test 覆盖。
*/
class GuangdongFcEndToEndTest {
private final Gb32960MessageDecoder decoder = buildDecoder();
@Test
void parsesRealtimeFrameWithGuangdongFcExtensions() {
byte[] frame = buildFrame();
// 用 platformAccount=lingniu 触发 vendor profile
Gb32960Message msg = decoder.decode(ByteBuffer.wrap(frame), "lingniu");
// ---- 标准块 ----
var v = msg.findBlock(InfoBlock.Gb32960V2016.Vehicle.class).orElseThrow();
assertThat(v.socPercent()).isEqualTo(85);
var pos = msg.findBlock(InfoBlock.Gb32960V2016.Position.class).orElseThrow();
assertThat(pos.longitude()).isCloseTo(120.848158, within(1e-6));
// ---- vendor 块0x30 Stack ----
var stack = msg.findBlock(InfoBlock.GuangdongFc.Stack.class).orElseThrow();
assertThat(stack.stackCount()).isEqualTo(1);
var s0 = stack.stacks().get(0);
assertThat(s0.engineWorkState()).isEqualTo(1);
assertThat(s0.stackWaterOutletTempC()).isEqualTo(50); // 90-40
assertThat(s0.maxCellVoltageV()).isCloseTo(0.745, within(1e-6)); // 745 mV
assertThat(s0.minCellVoltageV()).isCloseTo(0.730, within(1e-6));
assertThat(s0.avgCellVoltageV()).isCloseTo(0.738, within(1e-6));
assertThat(s0.cellCount()).isEqualTo(400);
assertThat(s0.frameCellCount()).isEqualTo(4);
assertThat(s0.frameCellVoltagesV()).hasSize(4)
.allSatisfy(c -> assertThat(c).isBetween(0.7, 0.8));
// ---- vendor 块0x32 DcDc ----
var dcdc = msg.findBlock(InfoBlock.GuangdongFc.DcDc.class).orElseThrow();
assertThat(dcdc.inputVoltageV()).isCloseTo(300.0, within(1e-6));
assertThat(dcdc.outputCurrentA()).isCloseTo(110.0, within(1e-6));
// ---- vendor 块0x33 AirConditioner ----
var ac = msg.findBlock(InfoBlock.GuangdongFc.AirConditioner.class).orElseThrow();
assertThat(ac.status()).isEqualTo(1);
assertThat(ac.powerKw()).isEqualTo(15);
// ---- vendor 块0x34 VehicleInfo ----
var info = msg.findBlock(InfoBlock.GuangdongFc.VehicleInfo.class).orElseThrow();
assertThat(info.collisionAlarm()).isEqualTo(0);
assertThat(info.ambientTempC()).isEqualTo(30);
assertThat(info.hydrogenMassKg()).isCloseTo(8.0, within(1e-6));
// ---- vendor 块0x80 DemoExtension ----
var demo = msg.findBlock(InfoBlock.GuangdongFc.DemoExtension.class).orElseThrow();
assertThat(demo.stackTempC()).isEqualTo(50);
assertThat(demo.airCompressorVoltageV()).isCloseTo(372.0, within(1e-6));
// 不应有 Raw 兜底:所有块都被结构化解析
assertThat(msg.findBlock(InfoBlock.Raw.class)).isEmpty();
}
@Test
void unmatchedAccountFallsBackToDefaultAndProducesRawForVendorBlocks() {
byte[] frame = buildFrame();
// 不传 account → selector 返回 null → default profile无 vendor parser
Gb32960Message msg = decoder.decode(ByteBuffer.wrap(frame), null);
// 标准块仍能解析
assertThat(msg.findBlock(InfoBlock.Gb32960V2016.Vehicle.class)).isPresent();
// 但 0x30 应触发 unknown → Raw 兜底
assertThat(msg.findBlock(InfoBlock.Raw.class)).isPresent();
assertThat(msg.findBlock(InfoBlock.GuangdongFc.Stack.class)).isEmpty();
}
// ====================================================================
// 帧构造:标准块 0x01/0x05/0x07/0x08/0x09 + vendor 块 0x30/0x32/0x33/0x34/0x80
// 不带 0x02/0x04/0x06/0x31纯粹为了控制帧长和测试焦点。
// ====================================================================
private static byte[] buildFrame() {
ByteArrayOutputStream body = new ByteArrayOutputStream();
// 时间戳 6B
body.write(24); body.write(4); body.write(15); body.write(11); body.write(0); body.write(0);
// 0x01 整车数据 20B
body.write(0x01);
body.write(0x01); // vehicleState 启动
body.write(0x03); // chargingState 未充电
body.write(0x02); // runningMode 混动
writeU16(body, 0); // 速度
writeU32(body, 100_000L); // 累计里程 = 10000 km
writeU16(body, 5605); // totalVoltage 560.5 V
writeU16(body, 10000); // totalCurrent 0 A (after -1000 offset)
body.write(85); // SOC
body.write(0x01); // DC-DC 工作
body.write(0x00); // 挡位
writeU16(body, 10000); // 绝缘电阻
body.write(0x00); // accelPedal
body.write(0x00); // brakePedal
// 0x05 位置数据 9B
body.write(0x05);
body.write(0x00); // 状态:有效定位
writeU32(body, 120_848_158L); // 经度 120.848158
writeU32(body, 31_497_236L); // 纬度 31.497236
// 0x07 报警数据 10Blevel=0 + flag=0 + 4 个空 fault list
body.write(0x07);
body.write(0x00); // maxLevel
writeU32(body, 0); // generalFlag
body.write(0x00); // batteryFaultCount
body.write(0x00); // motorFaultCount
body.write(0x00); // engineFaultCount
body.write(0x00); // otherFaultCount
// 0x08 储能电压1 sub × 0 cell保持简洁
body.write(0x08);
body.write(0x01); // subCount
body.write(0x01); // batteryIndex
writeU16(body, 5605); // voltage
writeU16(body, 10000); // current
writeU16(body, 0); // cellCount
writeU16(body, 1); // frameCellStart
body.write(0x00); // frameCellCount = 0
// 0x09 储能温度1 sub × 0 probe
body.write(0x09);
body.write(0x01); // subCount
body.write(0x01); // batteryIndex
writeU16(body, 0); // probeCount = 0
// 0x30 GuangdongFc.Stack1 stack + 4 单体电压
body.write(0x30);
body.write(0x01); // stackCount
body.write(0x01); // engineWorkState 打开
body.write(90); // 水温 90-40 = 50°C
writeU16(body, 1850); // h2 入口压力 raw=1850 → 85 kPa
writeU16(body, 1500); // air 入口压力 raw=1500 → 50 kPa
body.write(70); // air 入口温度 70-40 = 30°C
writeU16(body, 12); // maxCellId
writeU16(body, 88); // minCellId
writeU16(body, 745); // maxCellV 745 mV
writeU16(body, 730); // minCellV 730 mV
writeU16(body, 738); // avgCellV 738 mV
writeU16(body, 400); // cellCount
writeU16(body, 1); // frameCellStart
body.write(0x04); // frameCellCount = 4
writeU16(body, 745);
writeU16(body, 740);
writeU16(body, 735);
writeU16(body, 730);
// 0x32 GuangdongFc.DcDc 9B
body.write(0x32);
writeU16(body, 3000); // inputV 300.0
writeU16(body, 1200); // inputC 120.0
writeU16(body, 5600); // outputV 560.0
writeU16(body, 1100); // outputC 110.0
body.write(105); // ctrlTemp 105-40 = 65°C
// 0x33 GuangdongFc.AirConditioner 5B
body.write(0x33);
body.write(0x01); // status 启动
writeU16(body, 20); // power 20-5 = 15 kw
writeU16(body, 5400); // compressorInputV 540.0
// 0x34 GuangdongFc.VehicleInfo 7B
body.write(0x34);
body.write(0x00); // collision 无
writeU16(body, 70); // ambientTemp 70-40 = 30°C
writeU16(body, 1100); // ambientPressure raw=1100 → 10.0 kPa
writeU16(body, 80); // h2Mass 80*0.1 = 8.0 kg
// 0x80 GuangdongFc.DemoExtension 12B含 2B 内层 length=9
body.write(0x80);
writeU16(body, 9); // declared length
body.write(90); // stackTemp 50°C
writeU16(body, 3720); // ac voltage 372.0
writeU16(body, 10000); // ac current 0 A
writeU16(body, 13); // h2 pump voltage 1.3
writeU16(body, 10000); // h2 pump current 0 A
byte[] bodyBytes = body.toByteArray();
// 帧封装
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(0x23); out.write(0x23);
out.write(0x02); // cmd realtime
out.write(0xFE); // 命令包
byte[] vin = "LNVFC000000000001".getBytes(StandardCharsets.US_ASCII);
out.write(vin, 0, 17);
out.write(0x01); // 不加密
writeU16(out, bodyBytes.length);
out.write(bodyBytes, 0, bodyBytes.length);
byte[] almost = out.toByteArray();
byte bcc = BccChecksum.compute(almost, 2, almost.length - 2);
out.write(bcc & 0xFF);
return out.toByteArray();
}
private static void writeU16(ByteArrayOutputStream os, int v) {
os.write((v >> 8) & 0xFF);
os.write(v & 0xFF);
}
private static void writeU32(ByteArrayOutputStream os, long v) {
os.write((int) ((v >> 24) & 0xFF));
os.write((int) ((v >> 16) & 0xFF));
os.write((int) ((v >> 8) & 0xFF));
os.write((int) (v & 0xFF));
}
// ====================================================================
// Decoder 装配:手动构造一个挂着 guangdong-fc 路由的 BodyParser
// ====================================================================
private static Gb32960MessageDecoder buildDecoder() {
var standardParsers = List.of(
(com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser) new VehicleV2016BlockParser(),
new DriveMotorV2016BlockParser(),
new PositionV2016BlockParser(),
new AlarmV2016BlockParser(),
new VoltageV2016BlockParser(),
new TemperatureV2016BlockParser());
var catalog = new VendorExtensionCatalog(java.util.Map.of(
"guangdong-fc", List.of(
new GdFcStackBlockParser(),
new GdFcAuxiliaryBlockParser(),
new GdFcDcDcBlockParser(),
new GdFcAirConditionerBlockParser(),
new GdFcVehicleInfoBlockParser(),
new GdFcDemoExtensionBlockParser())));
var profileRegistry = new Gb32960ProfileRegistry(
standardParsers, catalog, Set.of("guangdong-fc"));
// selectorlingniu 账号 → guangdong-fc
var entry = new Gb32960Properties.VendorExtension();
entry.setName("guangdong-fc");
var match = new Gb32960Properties.VendorExtension.Match();
match.setPlatformAccounts(List.of("lingniu"));
entry.setMatch(match);
var selector = new RuleBasedVendorExtensionSelector(
List.of(entry), Set.of("guangdong-fc"));
var bodyParser = new Gb32960BodyParser(profileRegistry, selector);
// PLATFORM_ACCOUNT_ATTR 仅供 handler 使用,这里不需要
return new Gb32960MessageDecoder(bodyParser);
}
// suppress unused-import warning for the channel handler attr key
@SuppressWarnings("unused")
private static final Object KEEP_ATTR_REF = Gb32960ChannelHandler.PLATFORM_ACCOUNT_ATTR;
}