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;
}
}
}