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;
|
|
/**
|
* 直连新威接口的适配器契约:命令分工、水位与增量逐点、以及三处"不猜"的边界。
|
*
|
* <p>传输用脚本化的假管道替代(真实管道只在现场那台机器上存在),
|
* 但应答原文取自现场实测,所以这里验的是"适配器怎么用这些命令",不是"命令能不能通"。
|
*/
|
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", "<?xml version=\"1.0\" encoding=\"UTF-8\"?><bts version=\"1.0\">"
|
+ "<cmd>getdevinfo_resp</cmd><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\">true</channel>"
|
+ "</middle></bts>")
|
.on("inquire", "<bts><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\" /></list></bts>");
|
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",
|
"<bts><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\" relativetime=\"28311.8\" />"
|
+ "</list></bts>");
|
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",
|
"<bts><cmd>inquiredf_resp</cmd><list count=\"0\" /></bts>");
|
try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe))
|
{
|
assertNull(adapter.currentWatermark(CHANNEL));
|
}
|
}
|
|
@Test
|
void aChannelWithNoTestHasAZeroWatermarkAndNoSamples()
|
{
|
ScriptedPipe pipe = new ScriptedPipe().on("inquiredf",
|
"<bts><cmd>inquiredf_resp</cmd><list count=\"1\"><chl devtype=\"25\" devid=\"81\" subdevid=\"1\""
|
+ " chlid=\"2\" testid=\"316\" count=\"0\">true</chl></list></bts>");
|
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 <cmd>} 返回固定应答,{@code download}/{@code inquiredf} 按内存里的行集动态生成。
|
*
|
* <p>它也把只读白名单当断言用:任何越权命令在到达"对端"之前就被拒绝,测试会因此失败。
|
*/
|
private static final class ScriptedPipe implements BtsTransport
|
{
|
private static final Pattern COMMAND = Pattern.compile("<cmd>\\s*([A-Za-z_]+)\\s*</cmd>");
|
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<String, String> responses = new java.util.LinkedHashMap<>();
|
private final List<String> 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<String> 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 "<bts><cmd>inquiredf_resp</cmd><list count=\"1\"><chl devtype=\"25\" devid=\"81\""
|
+ " subdevid=\"1\" chlid=\"2\" testid=\"" + TEST_ID + "\" count=\"" + lastRow
|
+ "\">false</chl></list></bts>";
|
}
|
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("<data seqid=\"").append(sequenceId).append("\" stepid=\"6\" cycleid=\"0\""
|
+ " steptype=\"cc\" testtime=\"").append(sequenceId * 60000L).append("\""
|
+ " atime=\"").append(rowTimeText(sequenceId)).append("\"")
|
.append(" volt=\"1.75\" curr=\"0.685\" cap=\"4.1\" eng=\"7.1\" />");
|
return "<bts><cmd>donwload_resp</cmd><download devtype=\"25\" devid=\"81\" subdevid=\"1\" chlid=\"2\""
|
+ " testid=\"" + TEST_ID + "\" startpos=\"" + from + "\" count=\"" + returned
|
+ "\" auxid=\"0\" /><list count=\"" + returned + "\">" + rows + "</list></bts>";
|
}
|
|
private static String value(Pattern pattern, String request, String fallback)
|
{
|
Matcher matcher = pattern.matcher(request);
|
return matcher.find() ? matcher.group(1) : fallback;
|
}
|
}
|
}
|