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; /** * 新威接口的编解码器按**现场实测应答原文**验证。 * *

这些夹具不是照协议文档写的,是从真实上位机(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 buildsTheStepLayerRequestWithoutTheOptionalElements() { String request = codec.downloadStepLayerRequest(CHANNEL); assertTrue(request.contains("downloadStepLayer")); 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 = "" + "downloadStepLayer_resp" + "" + "" + "" + "" + "" + ""; List 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 = "downloadStepLayer_resp" + "" + ""; 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 steps = codec.parseStepLayer( "downloadStepLayer_resp", Instant.EPOCH); assertTrue(steps.isEmpty()); } @Test void rejectsAStepLayerResponseToAnotherCommand() { BtsProtocolException exception = assertThrows(BtsProtocolException.class, () -> codec.parseStepLayer("inquire_resp", 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 = "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")); } }