yeg
4 天以前 99fd4ccdf5d410e78a3785375036c34a2132ee04
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
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;
        }
    }
}