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:
@@ -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 报警数据 10B:level=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.Stack:1 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"));
|
||||
|
||||
// selector:lingniu 账号 → 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;
|
||||
}
|
||||
Reference in New Issue
Block a user