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.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 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"));
|
}
|
}
|