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.ArrayList; import java.util.List; import java.util.Map; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.junit.jupiter.api.Test; import com.battery.device.api.DeviceAdapter; import com.battery.device.api.TestDataObservationAdapter; import com.battery.device.api.TestDataObservationAdapter.SampleWatermark; import com.battery.device.api.model.ChannelIdentity; import com.battery.device.api.model.DeviceControlCommand; import com.battery.device.api.model.DeviceWorkStatus; /** * 直连新威接口的适配器契约:命令分工、水位与增量逐点、以及三处"不猜"的边界。 * *

传输用脚本化的假管道替代(真实管道只在现场那台机器上存在), * 但应答原文取自现场实测,所以这里验的是"适配器怎么用这些命令",不是"命令能不能通"。 */ class NewareBtsAdapterContractTest { private static final String ADAPTER = "neware-f"; private static final ChannelIdentity CHANNEL = new ChannelIdentity(ADAPTER, 25, 81, 1, 2); @Test void discoversChannelsAndObservesRealtimeThroughThePipe() { ScriptedPipe pipe = new ScriptedPipe() .on("getdevinfo", "" + "getdevinfo_resp" + "true" + "true" + "") .on("inquire", "inquire_resp" + ""); try (DeviceAdapter adapter = adapter(pipe)) { assertEquals(2, adapter.discoverChannels().size()); var observation = adapter.observe(Set.of(CHANNEL)).get(CHANNEL); assertTrue(observation.online()); // 读库路线拿不到通道工作状态,这正是本路线的主要收获。 assertEquals(DeviceWorkStatus.WORKING, observation.workStatus()); assertEquals(List.of("getdevinfo", "inquire"), pipe.commands()); } } @Test void latestCabinetCarriesRealtimeValuesAndTheServerReceiptTime() { ScriptedPipe pipe = new ScriptedPipe().on("inquire", "inquire_resp" + ""); try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe)) { var latest = adapter.latestCabinet(CHANNEL); assertEquals(0.6853, latest.currentA(), 1e-9); assertEquals(1.7337, latest.voltageV(), 1e-9); assertEquals(1, latest.cycleNo()); assertEquals("cc", latest.stepType()); assertEquals("充电", latest.stepName()); // inquire 不带绝对时间:这里的时间口径是服务器接收时间,序号留 0。 assertNull(latest.deviceTime()); assertEquals(0L, latest.sequenceId()); assertEquals(Long.valueOf(28312L), latest.relativeTimeSeconds()); } } @Test void watermarkIsTheLastPersistedRowAndSamplesAreStrictlyAfterIt() { ScriptedPipe pipe = new ScriptedPipe().rows(1, 5); try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe)) { SampleWatermark watermark = adapter.currentWatermark(CHANNEL); // 水位 = 已落盘行数那一行本身;行数就是最后一行的 seqid(实测一致)。 assertEquals(5L, watermark.sequenceId()); assertEquals(rowTime(5), watermark.deviceTime()); assertEquals(List.of("inquiredf", "download:5:1"), pipe.commands()); pipe.reset(); var rows = adapter.samplesSince(CHANNEL, new SampleWatermark(rowTime(3), 3L)); assertEquals(2, rows.size()); assertEquals(4L, rows.get(0).sequenceId()); assertEquals("316:4", rows.get(0).sourceRecordId()); // 分页从「水位 + 1」开始,只取到已落盘行数为止。 assertEquals(List.of("inquiredf", "download:4:2"), pipe.commands()); } } @Test void samplesSinceReadsNothingWithoutAWatermark() { ScriptedPipe pipe = new ScriptedPipe().rows(1, 5); try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe)) { assertTrue(adapter.samplesSince(CHANNEL, null).isEmpty()); // 没有水位就不该去读来源:来源里的设备历史不得并入本次接收。 assertTrue(pipe.commands().isEmpty()); } } @Test void treatsAClientWithoutInquiredfAsNotRowBasedInsteadOfFailing() { // 实测 7.6.0.1475 不支持 inquiredf(应答里没有 chl 记录)。这不是失败,是该构建的真实能力。 ScriptedPipe pipe = new ScriptedPipe().on("inquiredf", "inquiredf_resp"); try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe)) { assertNull(adapter.currentWatermark(CHANNEL)); } } @Test void aChannelWithNoTestHasAZeroWatermarkAndNoSamples() { ScriptedPipe pipe = new ScriptedPipe().on("inquiredf", "inquiredf_resptrue"); try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe)) { SampleWatermark watermark = adapter.currentWatermark(CHANNEL); // 空时间表示"这一刻来源里没有任何可用行":之后出现的行全部属于本次接收。 assertNull(watermark.deviceTime()); assertEquals(0L, watermark.sequenceId()); assertTrue(adapter.samplesSince(CHANNEL, watermark).isEmpty()); } } @Test void doesNotInventStepCyclesCollectorOrPlanFacts() { ScriptedPipe pipe = new ScriptedPipe(); try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe)) { // 工步层要未验证的 downloadStepLayer;采集器测量 ADR-0068 已复核不可得;协议里没有读计划的命令。 assertTrue(adapter.stepRecords(CHANNEL).isEmpty()); assertTrue(adapter.cycleRecords(CHANNEL).isEmpty()); assertNull(adapter.latestCollector(ADAPTER, "collector-1")); assertNull(adapter.testPlan(CHANNEL)); assertEquals(NewareBtsAdapter.SAMPLE_INTERVAL_MS, adapter.persistenceIntervalMillis(CHANNEL)); assertTrue(pipe.commands().isEmpty()); } } @Test void readOnlySurfaceStaysClosedForEveryControlCommand() { ScriptedPipe pipe = new ScriptedPipe(); try (DeviceAdapter adapter = adapter(pipe)) { assertTrue(adapter.capabilities().discovery()); assertTrue(adapter.capabilities().realtimeObservation()); assertTrue(adapter.capabilities().historyDownload()); assertFalse(adapter.capabilities().control()); for (DeviceControlCommand command : DeviceControlCommand.values()) assertThrows(UnsupportedOperationException.class, () -> adapter.control(CHANNEL, command)); assertTrue(pipe.commands().isEmpty()); } } private static DeviceAdapter adapter(ScriptedPipe pipe) { return new NewareBtsAdapter(ADAPTER, pipe, new BtsXmlCodec(ADAPTER, ZoneId.of(DEVICE_ZONE))); } /** 假管道里第 {@code sequenceId} 行对应的设备绝对时间(每行间隔 60 秒)。 */ private static Instant rowTime(int sequenceId) { return Instant.parse("2026-10-10T15:00:00Z").plusSeconds(sequenceId * 60L); } /** 设备本地时间文本(厂商格式):与适配器时区一致,编解码器按它解释回 UTC。 */ private static final java.time.format.DateTimeFormatter DEVICE_TIME_TEXT = java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); private static final String DEVICE_ZONE = "Asia/Hong_Kong"; private static String rowTimeText(int sequenceId) { return DEVICE_TIME_TEXT.withZone(ZoneId.of(DEVICE_ZONE)).format(rowTime(sequenceId)); } /** * 脚本化的假管道:按 {@code } 返回固定应答,{@code download}/{@code inquiredf} 按内存里的行集动态生成。 * *

它也把只读白名单当断言用:任何越权命令在到达"对端"之前就被拒绝,测试会因此失败。 */ private static final class ScriptedPipe implements BtsTransport { private static final Pattern COMMAND = Pattern.compile("\\s*([A-Za-z_]+)\\s*"); private static final Pattern START_POSITION = Pattern.compile("startpos=\"(\\d+)\""); private static final Pattern COUNT = Pattern.compile("count=\"(\\d+)\""); private static final long TEST_ID = 316L; private final Map responses = new java.util.LinkedHashMap<>(); private final List commands = new ArrayList<>(); private int firstRow = 0; private int lastRow = 0; ScriptedPipe on(String command, String response) { responses.put(command, response); return this; } /** 生成 {@code [first, last]} 的逐点行集,供 download/inquiredf 按窗口回答。 */ ScriptedPipe rows(int first, int last) { firstRow = first; lastRow = last; return this; } void reset() { commands.clear(); } List commands() { return List.copyOf(commands); } @Override public String exchange(String request) { NewareBtsReadOnlyCommands.requireAllowed(request); Matcher matcher = COMMAND.matcher(request); if (!matcher.find()) throw new IllegalStateException("请求缺少命令:" + request); String command = matcher.group(1).toLowerCase(java.util.Locale.ROOT); if ("inquiredf".equals(command)) { commands.add("inquiredf"); if (responses.containsKey("inquiredf")) return responses.get("inquiredf"); return "inquiredf_respfalse"; } if ("download".equals(command)) { long from = Long.parseLong(value(START_POSITION, request, "1")); int count = Integer.parseInt(value(COUNT, request, "1")); commands.add("download:" + from + ":" + count); return downloadResponse(from, count); } commands.add(command); String response = responses.get(command); if (response == null) throw new IllegalStateException("测试脚本没有为命令准备应答:" + command); return response; } private String downloadResponse(long from, int count) { StringBuilder rows = new StringBuilder(); int returned = 0; for (int sequenceId = (int) from; sequenceId <= lastRow && returned < count; sequenceId++, returned++) rows.append(""); return "donwload_resp" + rows + ""; } private static String value(Pattern pattern, String request, String fallback) { Matcher matcher = pattern.matcher(request); return matcher.find() ? matcher.group(1) : fallback; } } }