package com.battery.device.bts;
|
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import java.time.Instant;
|
import java.time.ZoneId;
|
import java.util.List;
|
import java.util.Map;
|
import java.util.Set;
|
import org.junit.jupiter.api.Test;
|
import com.battery.device.api.TestDataObservationAdapter.CabinetRealtime;
|
import com.battery.device.api.TestDataObservationAdapter.StepRecord;
|
import com.battery.device.api.model.ChannelIdentity;
|
import com.battery.device.api.model.DeviceWorkStatus;
|
import com.battery.device.api.model.RealtimeSnapshot;
|
|
/**
|
* 新威接口的编解码器按**现场实测应答原文**验证。
|
*
|
* <p>这些夹具不是照协议文档写的,是从真实上位机(8.0.0.530)的管道应答里抄下来的原文,
|
* 因此它们同时是"实现必须吸收的实测差异"的回归防线:
|
* 小数秒的 {@code totaltime}、厂商拼错的 {@code donwload_resp}、五段式 {@code dev} 身份。
|
*/
|
class NewareBtsXmlCodecTest
|
{
|
private static final String ADAPTER = "neware-f";
|
private static final ChannelIdentity CHANNEL = new ChannelIdentity(ADAPTER, 25, 81, 1, 2);
|
private final BtsXmlCodec codec = new BtsXmlCodec(ADAPTER, ZoneId.of("Asia/Hong_Kong"));
|
|
@Test
|
void parsesRealRealtimeSnapshotWithFractionalSecondDurations()
|
{
|
String xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><bts version=\"1.0\"><cmd>inquire_resp</cmd>"
|
+ "<list count=\"1\"><inquire dev=\"25-81-1-2-0\" cycle_id=\"1\" step_id=\"6\" step_type=\"cc\""
|
+ " workstatus=\"working\" current=\"0.6853\" voltage=\"1.7337\" capacity=\"5.3894\""
|
+ " energy=\"9.3408\" totaltime=\"37737.6\" relativetime=\"28311.8\" open_or_close=\"0\""
|
+ " log_code=\"0\" /></list></bts>";
|
|
RealtimeSnapshot snapshot = codec.parseSnapshots(xml, Instant.parse("2026-10-10T15:50:52Z"))
|
.get(CHANNEL);
|
|
assertEquals(DeviceWorkStatus.WORKING, snapshot.workStatus());
|
assertEquals(Integer.valueOf(1), snapshot.cycleNo());
|
assertEquals(Integer.valueOf(6), snapshot.stepNo());
|
assertEquals("cc", snapshot.stepType());
|
// 实测给的是小数秒;早期按整数解析会在这里判成非法取值,整次观测失败。
|
assertEquals(Long.valueOf(37738L), snapshot.totalTimeSeconds());
|
assertEquals(Long.valueOf(28312L), snapshot.relativeTimeSeconds());
|
assertEquals("0", snapshot.logCode());
|
// 五段式 dev(末段是辅助序号):身份取前四段,后段忽略。
|
assertEquals(CHANNEL, snapshot.channel());
|
}
|
|
@Test
|
void parsesDataFileStateFromRealInquiredfResponse()
|
{
|
String xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><bts version=\"1.0\"><cmd>inquiredf_resp</cmd>"
|
+ "<list count=\"1\"><chl devtype=\"25\" devid=\"81\" subdevid=\"1\" chlid=\"2\" testid=\"316\""
|
+ " count=\"637\">false</chl></list></bts>";
|
|
BtsXmlCodec.DataFileState state = codec.parseDataFileState(xml);
|
|
assertEquals(316L, state.testId());
|
assertEquals(637L, state.count());
|
// 厂商自报"未传完":这段数据不得当完整事实(ADR-0068 未决 5)。
|
assertFalse(state.complete());
|
}
|
|
@Test
|
void parsesRealDownloadedPointsWithAbsoluteTimeAndFullPrecision()
|
{
|
String xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><bts version=\"1.0\"><cmd>donwload_resp</cmd>"
|
+ "<download devtype=\"25\" devid=\"81\" subdevid=\"1\" chlid=\"2\" testid=\"316\" startpos=\"527\""
|
+ " count=\"3\" auxid=\"0\" /><list count=\"3\">"
|
+ "<data seqid=\"527\" stepid=\"6\" cycleid=\"0\" steptype=\"cc\" testtime=\"21660000\""
|
+ " atime=\"2026-10-10 23:49:28\" volt=\"1.750143\" curr=\"0.6852889\""
|
+ " cap=\"4.12315481465658\" eng=\"7.12849144299129\" />"
|
+ "<data seqid=\"528\" stepid=\"6\" cycleid=\"0\" steptype=\"cc\" testtime=\"21720000\""
|
+ " atime=\"2026-10-10 23:50:28\" volt=\"1.751076\" curr=\"0.6852991\""
|
+ " cap=\"4.13457583333333\" eng=\"7.14849111111111\" />"
|
+ "<data seqid=\"529\" stepid=\"6\" cycleid=\"0\" steptype=\"cc\" testtime=\"21780000\""
|
+ " atime=\"2026-10-10 23:51:28\" volt=\"1.750529\" curr=\"0.6852911\""
|
+ " cap=\"4.14599735229492\" eng=\"7.16848481020665\" /></list></bts>";
|
|
List<CabinetRealtime> rows = codec.parseDownload(xml, Instant.parse("2026-10-10T15:51:30Z"));
|
|
assertEquals(3, rows.size());
|
CabinetRealtime first = rows.get(0);
|
assertEquals(Long.valueOf(527L), first.sequenceId());
|
// atime 是设备本地时间,按适配器时区解释成 UTC(香港 = UTC+8)。
|
assertEquals("2026-10-10T15:49:28Z", first.deviceTime().toString());
|
assertEquals("2026-10-10 23:49:28", first.deviceTimeRaw());
|
assertEquals(Integer.valueOf(6), first.stepNo());
|
// download 的 cycleid 是 0 起(与库里的 cycle 一致),而 inquire 的 cycle_id 是 1 起。
|
assertEquals(Integer.valueOf(0), first.cycleNo());
|
assertEquals("cc", first.stepType());
|
assertEquals("充电", first.stepName());
|
assertEquals(Long.valueOf(21660L), first.relativeTimeSeconds());
|
assertEquals(0.6852889, first.currentA(), 1e-12);
|
assertEquals(1.750143, first.voltageV(), 1e-12);
|
assertEquals(4.12315481465658, first.capacityAh(), 1e-12);
|
assertEquals(7.12849144299129, first.energyWh(), 1e-12);
|
// 行身份带测试号:seqid 在每个测试内从 1 重编,只按它会跨测试撞键。
|
assertEquals("316:527", first.sourceRecordId());
|
assertEquals("Asia/Hong_Kong", first.timezoneId());
|
}
|
|
@Test
|
void acceptsTheVendorsMisspelledDownloadCommandAndEmptyPages()
|
{
|
List<CabinetRealtime> rows = codec.parseDownload(
|
"<bts><cmd>donwload_resp</cmd><list count=\"0\" /></bts>", Instant.EPOCH);
|
|
assertTrue(rows.isEmpty());
|
}
|
|
@Test
|
void stillAcceptsTheCommandNameSpelledAsInTheProtocolDocument()
|
{
|
List<CabinetRealtime> rows = codec.parseDownload("<bts><cmd>download_resp</cmd><list count=\"0\" /></bts>",
|
Instant.EPOCH);
|
|
assertTrue(rows.isEmpty());
|
}
|
|
@Test
|
void rejectsADownloadResponseToAnotherCommand()
|
{
|
BtsProtocolException exception = assertThrows(BtsProtocolException.class,
|
() -> codec.parseDownload("<bts><cmd>inquire_resp</cmd><list count=\"0\" /></bts>", Instant.EPOCH));
|
|
assertEquals(BtsProtocolException.Reason.UNEXPECTED_COMMAND, exception.reason());
|
}
|
|
@Test
|
void reportsMissingDataFileRecordInsteadOfInventingAWatermark()
|
{
|
// 客户端不支持 inquiredf 时(实测 7.6.0.1475)应答里没有 chl 记录:这是"读不到",不是"零行"。
|
BtsProtocolException exception = assertThrows(BtsProtocolException.class,
|
() -> codec.parseDataFileState("<bts><cmd>inquiredf_resp</cmd><list count=\"0\" /></bts>"));
|
|
assertEquals(BtsProtocolException.Reason.MISSING_FIELD, exception.reason());
|
}
|
|
@Test
|
void buildsRequestsWithTheVendorIdentityAndPaging()
|
{
|
String inquire = codec.inquireRequest(Set.of(CHANNEL), false);
|
assertTrue(inquire.contains("<cmd>inquire</cmd>"));
|
assertTrue(inquire.contains("devtype=\"25\" devid=\"81\" subdevid=\"1\" chlid=\"2\""));
|
|
String inquiredf = codec.inquiredfRequest(CHANNEL);
|
assertTrue(inquiredf.contains("<cmd>inquiredf</cmd>"));
|
assertTrue(inquiredf.contains("testid=\"0\""));
|
|
String download = codec.downloadRequest(CHANNEL, 0L, 528L, 2);
|
assertTrue(download.contains("<cmd>download</cmd>"));
|
assertTrue(download.contains("startpos=\"528\""));
|
assertTrue(download.contains("count=\"2\""));
|
assertTrue(download.contains("auxid=\"0\""));
|
}
|
|
@Test
|
void refusesToAddressAnotherAdaptersChannel()
|
{
|
ChannelIdentity foreign = new ChannelIdentity("neware-e", 25, 81, 1, 2);
|
|
assertThrows(IllegalArgumentException.class, () -> codec.inquiredfRequest(foreign));
|
assertThrows(IllegalArgumentException.class, () -> codec.downloadRequest(foreign, 0L, 1L, 1));
|
}
|
|
@Test
|
void refusesPagingBeyondTheProtocolLimits()
|
{
|
assertThrows(IllegalArgumentException.class, () -> codec.downloadRequest(CHANNEL, 0L, 0L, 1));
|
assertThrows(IllegalArgumentException.class,
|
() -> codec.downloadRequest(CHANNEL, 0L, 1L, BtsXmlCodec.MAX_DOWNLOAD_PAGE + 1));
|
}
|
|
@Test
|
void reportsTheConfiguredSiteTimezone()
|
{
|
assertEquals("Asia/Hong_Kong", codec.timezoneId());
|
}
|
|
@Test
|
void keepsUnknownStepTypesWithoutInventingADirectionName()
|
{
|
String xml = "<bts><cmd>donwload_resp</cmd><download testid=\"9\" startpos=\"1\" count=\"1\" />"
|
+ "<list count=\"1\"><data seqid=\"1\" stepid=\"1\" cycleid=\"0\" steptype=\"--\" testtime=\"0\""
|
+ " atime=\"2026-10-10 15:08:57\" volt=\"0\" curr=\"0\" cap=\"0\" eng=\"0\" /></list></bts>";
|
|
CabinetRealtime row = codec.parseDownload(xml, Instant.EPOCH).get(0);
|
|
assertNull(row.stepName());
|
}
|
|
@Test
|
void parsesRealDeviceInventoryIncludingTheCapitalisedChannelIdAttribute()
|
{
|
String xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><bts version=\"1.0\"><cmd>getdevinfo_resp</cmd>"
|
+ "<serverip count=\"1\"><server ip=\"127.0.0.1\" port=\"3306\" /></serverip>"
|
+ "<middle count=\"2\">"
|
+ "<channel ip=\"127.0.0.1\" devtype=\"25\" devid=\"81\" subdevid=\"1\" Channelid=\"1\">true</channel>"
|
+ "<channel ip=\"127.0.0.1\" devtype=\"25\" devid=\"81\" subdevid=\"1\" Channelid=\"2\">false</channel>"
|
+ "</middle></bts>";
|
|
var channels = codec.parseDeviceInfo(xml);
|
|
assertEquals(2, channels.size());
|
assertEquals(new ChannelIdentity(ADAPTER, 25, 81, 1, 1), channels.get(0).identity());
|
assertTrue(channels.get(0).available());
|
assertFalse(channels.get(1).available());
|
}
|
|
@Test
|
void keepsTheRequestedIdentityWhenTheServerRewritesTheDeviceType()
|
{
|
// 实测第四台:getdevinfo 报 devtype=27,而 inquire 的应答把 devtype 改写成 25。
|
// 按应答归集会与调用方请求的身份对不上(表现为"通道查不到"),所以归集要按请求的身份。
|
ChannelIdentity requested = new ChannelIdentity(ADAPTER, 27, 76, 1, 1);
|
String xml = "<bts><cmd>inquire_resp</cmd><list count=\"1\"><inquire dev=\"25-76-1-1-0\""
|
+ " cycle_id=\"1\" step_type=\"--\" workstatus=\"finish\" current=\"0\" voltage=\"0\""
|
+ " totaltime=\"0\" relativetime=\"0\" /></list></bts>";
|
|
Map<ChannelIdentity, RealtimeSnapshot> snapshots = codec.parseSnapshots(xml, Set.of(requested), Instant.EPOCH);
|
|
assertEquals(1, snapshots.size());
|
RealtimeSnapshot snapshot = snapshots.get(requested);
|
assertEquals(requested, snapshot.channel());
|
assertEquals(DeviceWorkStatus.FINISHED, snapshot.workStatus());
|
// 不带请求集合时退回按应答自己的身份归集(设备类型取应答里的 25)。
|
assertEquals(new ChannelIdentity(ADAPTER, 25, 76, 1, 1),
|
codec.parseSnapshots(xml, null, Instant.EPOCH).keySet().iterator().next());
|
}
|
|
@Test
|
void leavesTheRestStepCarryOverValueOutOfTheSingleValuedColumns()
|
{
|
// 现场实测(C 那台):接口对搁置步给的是**跨步过渡值**,不是该步自身累计——
|
// 这一步给 +0.290039804444444(等于上一步充电的容量),另一处还给过 −0.178807573333333。
|
// 写成"搁置步容量 0.29 Ah"就是不成立的事实,所以搁置步的单项容量能量留空。
|
String xml = "<bts><cmd>downloadStepLayer_resp</cmd>"
|
+ "<downloadStepLayer devtype=\"24\" devid=\"98\" subdevid=\"1\" chlid=\"1\" testid=\"2818576812\" />"
|
+ "<list count=\"1\"><data startseqid=\"1624\" endseqid=\"1624\" stepindex=\"9\" stepid=\"7\" cycleid=\"1\""
|
+ " steptype=\"rest\" steptime=\"59007400\" endatime=\"2026-08-29 08:43:06\" startvolt=\"1.2977\""
|
+ " endvolt=\"1.2977\" startcurr=\"0\" endcurr=\"0\" cap=\"0.290039804444444\""
|
+ " eng=\"0.468939271111111\" dcir=\"0\" /></list></bts>";
|
|
StepRecord step = codec.parseStepLayer(xml, Instant.EPOCH).get(0);
|
|
assertEquals("搁置", step.stepName());
|
assertNull(step.capacityAh());
|
assertNull(step.energyWh());
|
// 原文仍留在明细里供核对,只是不当"该步自身累计"用。
|
assertEquals(0.290039804444444, step.measurements().get("capacity").doubleValue(), 1e-15);
|
}
|
|
@Test
|
void buildsTheStepLayerRequestWithoutTheOptionalElements()
|
{
|
String request = codec.downloadStepLayerRequest(CHANNEL);
|
|
assertTrue(request.contains("<cmd>downloadStepLayer</cmd>"));
|
assertTrue(request.contains("devtype=\"25\" devid=\"81\" subdevid=\"1\" chlid=\"2\""));
|
assertTrue(request.contains("testid=\"0\""));
|
assertTrue(request.contains("dcir=\"0\""));
|
}
|
|
@Test
|
void parsesRealStepLayerWithNormalisedCycleAndDerivedStartTime()
|
{
|
// 现场实测原文(`26.117.165.166` 的 `25-81-1-1`,测试 316)。三处要点都在这个夹具里:
|
// cycleid 是 1 起要减一;只有 endatime,开始时间由 endatime − steptime 还原;
|
// stepindex 才是身份(stepid 在循环里会重复)。
|
String xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><bts version=\"1.0\">"
|
+ "<cmd>downloadStepLayer_resp</cmd>"
|
+ "<downloadStepLayer devtype=\"25\" devid=\"81\" subdevid=\"1\" chlid=\"1\" testid=\"316\" />"
|
+ "<list count=\"4\">"
|
+ "<data startseqid=\"1\" endseqid=\"2\" stepindex=\"1\" stepid=\"1\" cycleid=\"1\" steptype=\"rest\""
|
+ " steptime=\"23100\" endatime=\"2026-10-10 09:09:14\" startvolt=\"0.806611083984375\""
|
+ " endvolt=\"0.80638037109375\" startcurr=\"0\" endcurr=\"0\" cap=\"0\" eng=\"0\" dcir=\"0\" />"
|
+ "<data startseqid=\"3\" endseqid=\"304\" stepindex=\"2\" stepid=\"2\" cycleid=\"1\" steptype=\"cc\""
|
+ " steptime=\"18000000\" endatime=\"2026-10-10 14:09:14\" startvolt=\"1.07542861328125\""
|
+ " endvolt=\"1.972108203125\" startcurr=\"0.563903076171875\" endcurr=\"0.563332702636719\""
|
+ " cap=\"2.81630277777778\" eng=\"5.58447055555556\" dcir=\"0\" />"
|
+ "<data startseqid=\"305\" endseqid=\"307\" stepindex=\"3\" stepid=\"3\" cycleid=\"1\" steptype=\"rest\""
|
+ " steptime=\"83600\" endatime=\"2026-10-10 14:10:38\" startvolt=\"1.7850955078125\""
|
+ " endvolt=\"1.42794287109375\" startcurr=\"0\" endcurr=\"0\" cap=\"0\" eng=\"0\" dcir=\"0\" />"
|
+ "<data startseqid=\"308\" endseqid=\"309\" stepindex=\"4\" stepid=\"4\" cycleid=\"1\" steptype=\"dc\""
|
+ " steptime=\"27600\" endatime=\"2026-10-10 14:11:05\" startvolt=\"1.27452001953125\""
|
+ " endvolt=\"0.49604326171875\" startcurr=\"-0.562971618652344\" endcurr=\"-0.5622275390625\""
|
+ " cap=\"0.00431017035590278\" eng=\"0.00426197048611111\" dcir=\"0\" /></list></bts>";
|
|
List<StepRecord> steps = codec.parseStepLayer(xml, Instant.parse("2026-10-10T14:12:00Z"));
|
|
assertEquals(4, steps.size());
|
StepRecord first = steps.get(0);
|
assertEquals(0, first.cycleNo());
|
assertEquals(1, first.stepNo());
|
assertEquals("rest", first.stepType());
|
assertEquals("搁置", first.stepName());
|
assertEquals("2026-10-10T01:09:14Z", first.endedAt().toString());
|
// 开始时间 = 结束时间 − 23100 ms;与该测试首行 atime(09:08:51 设备本地)对得上。
|
assertEquals("2026-10-10T01:08:50.900Z", first.startedAt().toString());
|
assertEquals("neware:316:step:1", first.sourceRecordId());
|
assertEquals("Asia/Hong_Kong", first.timezoneId());
|
assertEquals("2026-10-10 09:09:14", first.deviceTimeRaw());
|
|
StepRecord charge = steps.get(1);
|
assertEquals("充电", charge.stepName());
|
assertEquals(2.81630277777778, charge.capacityAh(), 1e-12);
|
assertEquals(5.58447055555556, charge.energyWh(), 1e-12);
|
assertEquals(1.972108203125, charge.endVoltageV(), 1e-12);
|
assertEquals(0.563332702636719, charge.endCurrentA(), 1e-12);
|
assertEquals(3, charge.measurements().get("startSeqId").intValue());
|
assertEquals(304, charge.measurements().get("endSeqId").intValue());
|
|
StepRecord discharge = steps.get(3);
|
assertEquals("放电", discharge.stepName());
|
assertEquals(0.00431017035590278, discharge.capacityAh(), 1e-15);
|
}
|
|
@Test
|
void leavesStepCapacityEmptyWhenTheDirectionIsUnknown()
|
{
|
// 方向未知时不给单项容量能量:cap 是单一值,方向没确认就不知道它属于哪一侧(ADR-0069 §3)。
|
String xml = "<bts><cmd>downloadStepLayer_resp</cmd>"
|
+ "<downloadStepLayer devtype=\"25\" devid=\"81\" subdevid=\"1\" chlid=\"1\" testid=\"9\" />"
|
+ "<list count=\"1\"><data startseqid=\"1\" endseqid=\"9\" stepindex=\"1\" stepid=\"1\" cycleid=\"2\""
|
+ " steptype=\"--\" steptime=\"60000\" endatime=\"2026-10-10 09:09:14\" startvolt=\"1\" endvolt=\"1\""
|
+ " startcurr=\"0\" endcurr=\"0\" cap=\"1.5\" eng=\"2.5\" dcir=\"0\" /></list></bts>";
|
|
StepRecord step = codec.parseStepLayer(xml, Instant.EPOCH).get(0);
|
|
assertNull(step.stepName());
|
assertNull(step.capacityAh());
|
assertNull(step.energyWh());
|
assertEquals(1, step.cycleNo());
|
// 明细里保留的是设备原文事实(起始电压、首末序号),没有按方向分派的单项值。
|
assertEquals(1.0, step.measurements().get("startVoltage").doubleValue(), 1e-9);
|
assertEquals(9, step.measurements().get("endSeqId").intValue());
|
}
|
|
@Test
|
void reportsAnEmptyStepLayerInsteadOfFailing()
|
{
|
// 该通道的缓冲里没有测试时(实测 D 那台就是),应答是空列表而不是错误。
|
List<StepRecord> steps = codec.parseStepLayer(
|
"<bts><cmd>downloadStepLayer_resp</cmd><list count=\"0\" /></bts>", Instant.EPOCH);
|
|
assertTrue(steps.isEmpty());
|
}
|
|
@Test
|
void rejectsAStepLayerResponseToAnotherCommand()
|
{
|
BtsProtocolException exception = assertThrows(BtsProtocolException.class,
|
() -> codec.parseStepLayer("<bts><cmd>inquire_resp</cmd><list count=\"0\" /></bts>", Instant.EPOCH));
|
|
assertEquals(BtsProtocolException.Reason.UNEXPECTED_COMMAND, exception.reason());
|
}
|
|
@Test
|
void keepsOnlyAttributesOutsideTheKnownFieldSetAsDynamicMeasurements()
|
{
|
// 字段集随客户端构建不同(8.0.0.484 有 barcode/auxtemp,8.0.0.530 没有,实测三种构建互不相同)。
|
// 已知字段各走自己的字段(barcode 是值、auxtemp 按已知字段表排除),未在字段表里的按动态测量原样保留。
|
String xml = "<bts><cmd>inquire_resp</cmd><list count=\"1\"><inquire dev=\"25-81-1-2-0\""
|
+ " workstatus=\"working\" barcode=\"BC1\" auxtemp=\"25.5\" T1=\"23.4\"/></list></bts>";
|
|
RealtimeSnapshot snapshot = codec.parseSnapshots(xml, Instant.EPOCH).get(CHANNEL);
|
|
assertEquals("BC1", snapshot.barcode());
|
assertEquals(23.4, snapshot.measurements().get("T1").value().doubleValue(), 1e-9);
|
assertFalse(snapshot.measurements().containsKey("barcode"));
|
assertFalse(snapshot.measurements().containsKey("auxtemp"));
|
}
|
}
|