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;
/**
* 新威接口的编解码器按**现场实测应答原文**验证。
*
*
这些夹具不是照协议文档写的,是从真实上位机(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 = "inquire_resp"
+ "
";
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 = "inquiredf_resp"
+ "false
";
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 = "donwload_resp"
+ ""
+ ""
+ ""
+ "
";
List 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 rows = codec.parseDownload(
"donwload_resp
", Instant.EPOCH);
assertTrue(rows.isEmpty());
}
@Test
void stillAcceptsTheCommandNameSpelledAsInTheProtocolDocument()
{
List rows = codec.parseDownload("download_resp
",
Instant.EPOCH);
assertTrue(rows.isEmpty());
}
@Test
void rejectsADownloadResponseToAnotherCommand()
{
BtsProtocolException exception = assertThrows(BtsProtocolException.class,
() -> codec.parseDownload("inquire_resp
", 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("inquiredf_resp
"));
assertEquals(BtsProtocolException.Reason.MISSING_FIELD, exception.reason());
}
@Test
void buildsRequestsWithTheVendorIdentityAndPaging()
{
String inquire = codec.inquireRequest(Set.of(CHANNEL), false);
assertTrue(inquire.contains("inquire"));
assertTrue(inquire.contains("devtype=\"25\" devid=\"81\" subdevid=\"1\" chlid=\"2\""));
String inquiredf = codec.inquiredfRequest(CHANNEL);
assertTrue(inquiredf.contains("inquiredf"));
assertTrue(inquiredf.contains("testid=\"0\""));
String download = codec.downloadRequest(CHANNEL, 0L, 528L, 2);
assertTrue(download.contains("download"));
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 = "donwload_resp"
+ "
";
CabinetRealtime row = codec.parseDownload(xml, Instant.EPOCH).get(0);
assertNull(row.stepName());
}
@Test
void parsesRealDeviceInventoryIncludingTheCapitalisedChannelIdAttribute()
{
String xml = "getdevinfo_resp"
+ ""
+ ""
+ "true"
+ "false"
+ "";
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 = "inquire_resp
";
Map 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 = "inquire_resp
";
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"));
}
}