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
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.List;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.Test;
import com.battery.device.api.TestDataObservationAdapter.CabinetRealtime;
import com.battery.device.api.model.ChannelIdentity;
import com.battery.device.api.model.DeviceWorkStatus;
import com.battery.device.api.model.RealtimeSnapshot;
 
/**
 * 新威接口的编解码器按**现场实测应答原文**验证。
 *
 * <p>这些夹具不是照协议文档写的,是从真实上位机(8.0.0.530)的管道应答里抄下来的原文,
 * 因此它们同时是"实现必须吸收的实测差异"的回归防线:
 * 小数秒的 {@code totaltime}、厂商拼错的 {@code donwload_resp}、五段式 {@code dev} 身份。
 */
class NewareBtsXmlCodecTest
{
    private static final String ADAPTER = "neware-f";
    private static final ChannelIdentity CHANNEL = new ChannelIdentity(ADAPTER, 25, 81, 1, 2);
    private final BtsXmlCodec codec = new BtsXmlCodec(ADAPTER, ZoneId.of("Asia/Hong_Kong"));
 
    @Test
    void parsesRealRealtimeSnapshotWithFractionalSecondDurations()
    {
        String xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><bts version=\"1.0\"><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\" open_or_close=\"0\""
                + " log_code=\"0\" /></list></bts>";
 
        RealtimeSnapshot snapshot = codec.parseSnapshots(xml, Instant.parse("2026-10-10T15:50:52Z"))
                .get(CHANNEL);
 
        assertEquals(DeviceWorkStatus.WORKING, snapshot.workStatus());
        assertEquals(Integer.valueOf(1), snapshot.cycleNo());
        assertEquals(Integer.valueOf(6), snapshot.stepNo());
        assertEquals("cc", snapshot.stepType());
        // 实测给的是小数秒;早期按整数解析会在这里判成非法取值,整次观测失败。
        assertEquals(Long.valueOf(37738L), snapshot.totalTimeSeconds());
        assertEquals(Long.valueOf(28312L), snapshot.relativeTimeSeconds());
        assertEquals("0", snapshot.logCode());
        // 五段式 dev(末段是辅助序号):身份取前四段,后段忽略。
        assertEquals(CHANNEL, snapshot.channel());
    }
 
    @Test
    void parsesDataFileStateFromRealInquiredfResponse()
    {
        String xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><bts version=\"1.0\"><cmd>inquiredf_resp</cmd>"
                + "<list count=\"1\"><chl devtype=\"25\" devid=\"81\" subdevid=\"1\" chlid=\"2\" testid=\"316\""
                + " count=\"637\">false</chl></list></bts>";
 
        BtsXmlCodec.DataFileState state = codec.parseDataFileState(xml);
 
        assertEquals(316L, state.testId());
        assertEquals(637L, state.count());
        // 厂商自报"未传完":这段数据不得当完整事实(ADR-0068 未决 5)。
        assertFalse(state.complete());
    }
 
    @Test
    void parsesRealDownloadedPointsWithAbsoluteTimeAndFullPrecision()
    {
        String xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><bts version=\"1.0\"><cmd>donwload_resp</cmd>"
                + "<download devtype=\"25\" devid=\"81\" subdevid=\"1\" chlid=\"2\" testid=\"316\" startpos=\"527\""
                + " count=\"3\" auxid=\"0\" /><list count=\"3\">"
                + "<data seqid=\"527\" stepid=\"6\" cycleid=\"0\" steptype=\"cc\" testtime=\"21660000\""
                + " atime=\"2026-10-10 23:49:28\" volt=\"1.750143\" curr=\"0.6852889\""
                + " cap=\"4.12315481465658\" eng=\"7.12849144299129\" />"
                + "<data seqid=\"528\" stepid=\"6\" cycleid=\"0\" steptype=\"cc\" testtime=\"21720000\""
                + " atime=\"2026-10-10 23:50:28\" volt=\"1.751076\" curr=\"0.6852991\""
                + " cap=\"4.13457583333333\" eng=\"7.14849111111111\" />"
                + "<data seqid=\"529\" stepid=\"6\" cycleid=\"0\" steptype=\"cc\" testtime=\"21780000\""
                + " atime=\"2026-10-10 23:51:28\" volt=\"1.750529\" curr=\"0.6852911\""
                + " cap=\"4.14599735229492\" eng=\"7.16848481020665\" /></list></bts>";
 
        List<CabinetRealtime> rows = codec.parseDownload(xml, Instant.parse("2026-10-10T15:51:30Z"));
 
        assertEquals(3, rows.size());
        CabinetRealtime first = rows.get(0);
        assertEquals(Long.valueOf(527L), first.sequenceId());
        // atime 是设备本地时间,按适配器时区解释成 UTC(香港 = UTC+8)。
        assertEquals("2026-10-10T15:49:28Z", first.deviceTime().toString());
        assertEquals("2026-10-10 23:49:28", first.deviceTimeRaw());
        assertEquals(Integer.valueOf(6), first.stepNo());
        // download 的 cycleid 是 0 起(与库里的 cycle 一致),而 inquire 的 cycle_id 是 1 起。
        assertEquals(Integer.valueOf(0), first.cycleNo());
        assertEquals("cc", first.stepType());
        assertEquals("充电", first.stepName());
        assertEquals(Long.valueOf(21660L), first.relativeTimeSeconds());
        assertEquals(0.6852889, first.currentA(), 1e-12);
        assertEquals(1.750143, first.voltageV(), 1e-12);
        assertEquals(4.12315481465658, first.capacityAh(), 1e-12);
        assertEquals(7.12849144299129, first.energyWh(), 1e-12);
        // 行身份带测试号:seqid 在每个测试内从 1 重编,只按它会跨测试撞键。
        assertEquals("316:527", first.sourceRecordId());
        assertEquals("Asia/Hong_Kong", first.timezoneId());
    }
 
    @Test
    void acceptsTheVendorsMisspelledDownloadCommandAndEmptyPages()
    {
        List<CabinetRealtime> rows = codec.parseDownload(
                "<bts><cmd>donwload_resp</cmd><list count=\"0\" /></bts>", Instant.EPOCH);
 
        assertTrue(rows.isEmpty());
    }
 
    @Test
    void stillAcceptsTheCommandNameSpelledAsInTheProtocolDocument()
    {
        List<CabinetRealtime> rows = codec.parseDownload("<bts><cmd>download_resp</cmd><list count=\"0\" /></bts>",
                Instant.EPOCH);
 
        assertTrue(rows.isEmpty());
    }
 
    @Test
    void rejectsADownloadResponseToAnotherCommand()
    {
        BtsProtocolException exception = assertThrows(BtsProtocolException.class,
                () -> codec.parseDownload("<bts><cmd>inquire_resp</cmd><list count=\"0\" /></bts>", Instant.EPOCH));
 
        assertEquals(BtsProtocolException.Reason.UNEXPECTED_COMMAND, exception.reason());
    }
 
    @Test
    void reportsMissingDataFileRecordInsteadOfInventingAWatermark()
    {
        // 客户端不支持 inquiredf 时(实测 7.6.0.1475)应答里没有 chl 记录:这是"读不到",不是"零行"。
        BtsProtocolException exception = assertThrows(BtsProtocolException.class,
                () -> codec.parseDataFileState("<bts><cmd>inquiredf_resp</cmd><list count=\"0\" /></bts>"));
 
        assertEquals(BtsProtocolException.Reason.MISSING_FIELD, exception.reason());
    }
 
    @Test
    void buildsRequestsWithTheVendorIdentityAndPaging()
    {
        String inquire = codec.inquireRequest(Set.of(CHANNEL), false);
        assertTrue(inquire.contains("<cmd>inquire</cmd>"));
        assertTrue(inquire.contains("devtype=\"25\" devid=\"81\" subdevid=\"1\" chlid=\"2\""));
 
        String inquiredf = codec.inquiredfRequest(CHANNEL);
        assertTrue(inquiredf.contains("<cmd>inquiredf</cmd>"));
        assertTrue(inquiredf.contains("testid=\"0\""));
 
        String download = codec.downloadRequest(CHANNEL, 0L, 528L, 2);
        assertTrue(download.contains("<cmd>download</cmd>"));
        assertTrue(download.contains("startpos=\"528\""));
        assertTrue(download.contains("count=\"2\""));
        assertTrue(download.contains("auxid=\"0\""));
    }
 
    @Test
    void refusesToAddressAnotherAdaptersChannel()
    {
        ChannelIdentity foreign = new ChannelIdentity("neware-e", 25, 81, 1, 2);
 
        assertThrows(IllegalArgumentException.class, () -> codec.inquiredfRequest(foreign));
        assertThrows(IllegalArgumentException.class, () -> codec.downloadRequest(foreign, 0L, 1L, 1));
    }
 
    @Test
    void refusesPagingBeyondTheProtocolLimits()
    {
        assertThrows(IllegalArgumentException.class, () -> codec.downloadRequest(CHANNEL, 0L, 0L, 1));
        assertThrows(IllegalArgumentException.class,
                () -> codec.downloadRequest(CHANNEL, 0L, 1L, BtsXmlCodec.MAX_DOWNLOAD_PAGE + 1));
    }
 
    @Test
    void reportsTheConfiguredSiteTimezone()
    {
        assertEquals("Asia/Hong_Kong", codec.timezoneId());
    }
 
    @Test
    void keepsUnknownStepTypesWithoutInventingADirectionName()
    {
        String xml = "<bts><cmd>donwload_resp</cmd><download testid=\"9\" startpos=\"1\" count=\"1\" />"
                + "<list count=\"1\"><data seqid=\"1\" stepid=\"1\" cycleid=\"0\" steptype=\"--\" testtime=\"0\""
                + " atime=\"2026-10-10 15:08:57\" volt=\"0\" curr=\"0\" cap=\"0\" eng=\"0\" /></list></bts>";
 
        CabinetRealtime row = codec.parseDownload(xml, Instant.EPOCH).get(0);
 
        assertNull(row.stepName());
    }
 
    @Test
    void parsesRealDeviceInventoryIncludingTheCapitalisedChannelIdAttribute()
    {
        String xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><bts version=\"1.0\"><cmd>getdevinfo_resp</cmd>"
                + "<serverip count=\"1\"><server ip=\"127.0.0.1\" port=\"3306\" /></serverip>"
                + "<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\">false</channel>"
                + "</middle></bts>";
 
        var channels = codec.parseDeviceInfo(xml);
 
        assertEquals(2, channels.size());
        assertEquals(new ChannelIdentity(ADAPTER, 25, 81, 1, 1), channels.get(0).identity());
        assertTrue(channels.get(0).available());
        assertFalse(channels.get(1).available());
    }
 
    @Test
    void keepsTheRequestedIdentityWhenTheServerRewritesTheDeviceType()
    {
        // 实测第四台:getdevinfo 报 devtype=27,而 inquire 的应答把 devtype 改写成 25。
        // 按应答归集会与调用方请求的身份对不上(表现为"通道查不到"),所以归集要按请求的身份。
        ChannelIdentity requested = new ChannelIdentity(ADAPTER, 27, 76, 1, 1);
        String xml = "<bts><cmd>inquire_resp</cmd><list count=\"1\"><inquire dev=\"25-76-1-1-0\""
                + " cycle_id=\"1\" step_type=\"--\" workstatus=\"finish\" current=\"0\" voltage=\"0\""
                + " totaltime=\"0\" relativetime=\"0\" /></list></bts>";
 
        Map<ChannelIdentity, RealtimeSnapshot> snapshots = codec.parseSnapshots(xml, Set.of(requested), Instant.EPOCH);
 
        assertEquals(1, snapshots.size());
        RealtimeSnapshot snapshot = snapshots.get(requested);
        assertEquals(requested, snapshot.channel());
        assertEquals(DeviceWorkStatus.FINISHED, snapshot.workStatus());
        // 不带请求集合时退回按应答自己的身份归集(设备类型取应答里的 25)。
        assertEquals(new ChannelIdentity(ADAPTER, 25, 76, 1, 1),
                codec.parseSnapshots(xml, null, Instant.EPOCH).keySet().iterator().next());
    }
 
    @Test
    void keepsOnlyAttributesOutsideTheKnownFieldSetAsDynamicMeasurements()
    {
        // 字段集随客户端构建不同(8.0.0.484 有 barcode/auxtemp,8.0.0.530 没有,实测三种构建互不相同)。
        // 已知字段各走自己的字段(barcode 是值、auxtemp 按已知字段表排除),未在字段表里的按动态测量原样保留。
        String xml = "<bts><cmd>inquire_resp</cmd><list count=\"1\"><inquire dev=\"25-81-1-2-0\""
                + " workstatus=\"working\" barcode=\"BC1\" auxtemp=\"25.5\" T1=\"23.4\"/></list></bts>";
 
        RealtimeSnapshot snapshot = codec.parseSnapshots(xml, Instant.EPOCH).get(CHANNEL);
 
        assertEquals("BC1", snapshot.barcode());
        assertEquals(23.4, snapshot.measurements().get("T1").value().doubleValue(), 1e-9);
        assertFalse(snapshot.measurements().containsKey("barcode"));
        assertFalse(snapshot.measurements().containsKey("auxtemp"));
    }
}