1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
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.CycleRecord;
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 doesNotInventCollectorOrPlanFacts()
    {
        ScriptedPipe pipe = new ScriptedPipe();
        try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe))
        {
            // 采集器测量 ADR-0068 已复核不可得;协议里没有读计划的命令。
            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());
        }
    }
 
    @Test
    void handsOutEveryStepAndCycleWhenTheChannelHasEnded()
    {
        ScriptedPipe pipe = finishedTestPipe(DeviceWorkStatus.FINISHED);
        try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe))
        {
            var steps = adapter.stepRecords(CHANNEL);
            var cycles = adapter.cycleRecords(CHANNEL);
 
            assertEquals(4, steps.size());
            assertEquals("充电", steps.get(1).stepName());
            // 循环号已按 0 起归一到与厂商库、逐点同口径(应答里的 cycleid 是 1 起)。
            assertEquals(0, steps.get(0).cycleNo());
 
            assertEquals(1, cycles.size());
            var cycle = cycles.get(0);
            assertEquals(0, cycle.cycleNo());
            assertEquals(2.81630277777778, cycle.chargeCapacityAh(), 1e-9);
            assertEquals(0.00431017035590278, cycle.dischargeCapacityAh(), 1e-12);
            assertEquals(5.58447055555556, cycle.chargeEnergyWh(), 1e-9);
            assertEquals(0.00426197048611111, cycle.dischargeEnergyWh(), 1e-12);
            // 效率 = 放电容量 / 充电容量 × 100;搁置工步不进任何一侧。
            assertEquals(0.153, cycle.efficiency(), 0.0005);
            assertEquals("neware:316:cycle:0", cycle.sourceRecordId());
            assertEquals(CycleRecord.SOURCE_AGGREGATED, cycle.sourceKind());
        }
    }
 
    @Test
    void holdsBackTheRunningStepAndItsCycleWhileTheTestIsStillRunning()
    {
        // 实测这条命令会把正在进行的那一步也交出来,而它的 endseqid 恰好等于已落盘行数
        // (四个测试都相等),所以只能靠通道工作状态判断它有没有结束。
        ScriptedPipe pipe = finishedTestPipe(DeviceWorkStatus.WORKING);
        try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe))
        {
            var steps = adapter.stepRecords(CHANNEL);
            var cycles = adapter.cycleRecords(CHANNEL);
 
            // 最后一步(stepindex=4)与它所在的循环都不交出,等它结束再交。
            assertEquals(3, steps.size());
            assertEquals("neware:316:step:3", steps.get(2).sourceRecordId());
            assertTrue(cycles.isEmpty());
        }
    }
 
    @Test
    void readsTheStepLayerOnceForBothStepsAndCycles()
    {
        // 工步与循环必须来自同一次读取:否则一个工步边界落在两次读取之间时,
        // 循环合计会包含工步列表里没有的那一步,使循环记录每轮内容都不同。
        ScriptedPipe pipe = finishedTestPipe(DeviceWorkStatus.FINISHED);
        try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe))
        {
            adapter.stepRecords(CHANNEL);
            adapter.cycleRecords(CHANNEL);
 
            assertEquals(1, pipe.commands().stream().filter("downloadsteplayer"::equals).count());
        }
    }
 
    @Test
    void aChannelWithNoTestInTheBufferHasNoStepsOrCycles()
    {
        ScriptedPipe pipe = new ScriptedPipe()
                .on("downloadStepLayer", "<bts><cmd>downloadStepLayer_resp</cmd><list count=\"0\" /></bts>");
        try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe))
        {
            assertTrue(adapter.stepRecords(CHANNEL).isEmpty());
            assertTrue(adapter.cycleRecords(CHANNEL).isEmpty());
            // 缓冲里没有测试就直接是空,不必再去问工作状态。
            assertEquals(List.of("downloadsteplayer"), pipe.commands());
        }
    }
 
    @Test
    void aFailedStateReadSkipsTheRoundInsteadOfHandingOutTheRunningStep()
    {
        // 读取失败不吞:调用方本来就有"来源读不到就本轮放弃、下一轮重试"的约定,
        // 所以一次传输抖动不会变成"把一个还在跑的工步交出去",也不会被当成结论缓存下来。
        ScriptedPipe pipe = finishedTestPipe(DeviceWorkStatus.WORKING).failing("inquire");
        try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe))
        {
            assertThrows(RuntimeException.class, () -> adapter.stepRecords(CHANNEL));
 
            pipe.recover();
            // 恢复后重新读,拿到的是完整结论(在跑 → 交出 3 步、按住最后一步),不是上一次的空结果。
            assertEquals(3, adapter.stepRecords(CHANNEL).size());
        }
    }
 
    /** 一个已结束测试的工步层(现场实测原文,测试 316):4 步都在循环 1(归一后为 0)。 */
    private static ScriptedPipe finishedTestPipe(DeviceWorkStatus workStatus)
    {
        return new ScriptedPipe()
                .on("inquire", "<bts><cmd>inquire_resp</cmd><list count=\"1\"><inquire dev=\"25-81-1-2-0\""
                        + " cycle_id=\"1\" step_id=\"4\" step_type=\"dc\" workstatus=\""
                        + workStatus.name().toLowerCase(java.util.Locale.ROOT)
                        + "\" current=\"0\" voltage=\"1.2\" /></list></bts>")
                .on("downloadStepLayer", "<bts><cmd>downloadStepLayer_resp</cmd>"
                        + "<downloadStepLayer devtype=\"25\" devid=\"81\" subdevid=\"1\" chlid=\"2\" testid=\"316\" />"
                        + "<list count=\"4\">"
                        + "<data startseqid=\"1\" endseqid=\"2\" stepindex=\"1\" stepid=\"1\" cycleid=\"1\""
                        + " steptype=\"rest\" steptime=\"23100\" endatime=\"2026-10-10 09:09:14\""
                        + " startvolt=\"0.806611083984375\" endvolt=\"0.80638037109375\" startcurr=\"0\""
                        + " endcurr=\"0\" cap=\"0\" eng=\"0\" dcir=\"0\" />"
                        + "<data startseqid=\"3\" endseqid=\"304\" stepindex=\"2\" stepid=\"2\" cycleid=\"1\""
                        + " steptype=\"cc\" steptime=\"18000000\" endatime=\"2026-10-10 14:09:14\""
                        + " startvolt=\"1.07542861328125\" endvolt=\"1.972108203125\""
                        + " startcurr=\"0.563903076171875\" endcurr=\"0.563332702636719\""
                        + " cap=\"2.81630277777778\" eng=\"5.58447055555556\" dcir=\"0\" />"
                        + "<data startseqid=\"305\" endseqid=\"307\" stepindex=\"3\" stepid=\"3\" cycleid=\"1\""
                        + " steptype=\"rest\" steptime=\"83600\" endatime=\"2026-10-10 14:10:38\""
                        + " startvolt=\"1.7850955078125\" endvolt=\"1.42794287109375\" startcurr=\"0\""
                        + " endcurr=\"0\" cap=\"0\" eng=\"0\" dcir=\"0\" />"
                        + "<data startseqid=\"308\" endseqid=\"309\" stepindex=\"4\" stepid=\"4\" cycleid=\"1\""
                        + " steptype=\"dc\" steptime=\"27600\" endatime=\"2026-10-10 14:11:05\""
                        + " startvolt=\"1.27452001953125\" endvolt=\"0.49604326171875\""
                        + " startcurr=\"-0.562971618652344\" endcurr=\"-0.5622275390625\""
                        + " cap=\"0.00431017035590278\" eng=\"0.00426197048611111\" dcir=\"0\" /></list></bts>");
    }
 
    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 final java.util.Set<String> failing = new java.util.LinkedHashSet<>();
        private int firstRow = 0;
        private int lastRow = 0;
 
        ScriptedPipe on(String command, String response)
        {
            // 请求里的命令名按小写匹配(与生产传输的比对口径一致)。
            responses.put(command.toLowerCase(java.util.Locale.ROOT), response);
            return this;
        }
 
        ScriptedPipe failing(String command)
        {
            failing.add(command.toLowerCase(java.util.Locale.ROOT));
            return this;
        }
 
        void recover()
        {
            failing.clear();
        }
 
        /** 生成 {@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 (failing.contains(command))
                throw new BtsTransportException(BtsTransportException.Reason.TIMEOUT, "测试脚本让 " + command + " 失败", null);
            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;
        }
    }
}