yeg
2026-09-29 b78295e8da96c3e5e76befeef9582ce5273dbb74
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
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
package com.battery.device.neware;
 
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.assertTrue;
 
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
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.TestDataObservationAdapter.CycleRecord;
import com.battery.device.api.TestDataObservationAdapter.SampleWatermark;
import com.battery.device.api.TestDataObservationAdapter.StepRecord;
import com.battery.device.api.credentials.LocalCredentials;
import com.battery.device.api.model.AdapterCapabilities;
import com.battery.device.api.model.ChannelIdentity;
import com.battery.device.api.model.DetectedChannel;
import com.battery.device.api.model.DeviceConnectionStatus;
import com.battery.device.api.model.DeviceWorkStatus;
import com.battery.device.api.model.RealtimeSnapshot;
import com.battery.device.api.registry.AdapterInstanceConfig;
 
/**
 * 直连新威库的适配器契约测试:用可控的假厂商库验证发现清单与最近数据时间、
 * 最新测试按起始时间取、分表定位用测试记录给出的表名、活跃只看新采样行、单位换算、身份与水位。
 */
class NewareMysqlAdapterContractTest
{
    private static final String ADAPTER_CODE = "neware76";
    private static final long DEV_UID = 210036L;
    private static final ChannelIdentity CHANNEL = new ChannelIdentity(ADAPTER_CODE, 21, 36, 1, 2);
    private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-09-28T02:00:00Z"), ZoneOffset.UTC);
 
    private static final String ACTIVE_TABLE = "data_210036_2026_09_18";
 
    private NewareMysqlAdapter adapter(FakeVendorDatabase database)
    {
        AdapterInstanceConfig config = new AdapterInstanceConfig(ADAPTER_CODE,
                NewareMysqlAdapter.ADAPTER_TYPE, "26.1.2.3", 3306, 60000, "Asia/Hong_Kong", true,
                AdapterCapabilities.observeOnly(), Map.of("database", "bts63", "user", "visitor"));
        // 缓存关闭,让每个断言都读到假厂商库的当前内容。
        return new NewareMysqlAdapter(config, database.dataSource(), () -> { }, CLOCK, 0);
    }
 
    /** 一台在用设备(dev_uid 210036)、一台只出现在历史数据里但不在登记表中的设备(240098)。 */
    private FakeVendorDatabase activeDeviceDatabase()
    {
        return new FakeVendorDatabase()
                .registeredChannel(DEV_UID, 1, 1)
                .registeredChannel(DEV_UID, 1, 2)
                .registeredChannel(DEV_UID, 2, 1)
                .test(new FakeVendorDatabase.TestRow(DEV_UID, 1, 2, 440L, "2026-09-18 09:00:00",
                        ACTIVE_TABLE, null))
                // 已退役设备的历史数据:登记表里没有它,不得成为候选。
                .test(new FakeVendorDatabase.TestRow(240098L, 1, 1, 900L, "2021-09-30 09:00:00",
                        "data_240098_2021_09_30", null))
                .sample(new FakeVendorDatabase.SampleRow(ACTIVE_TABLE, 440L, 1, 2, 7435L, 3, 12, "2", 300000L,
                        13733, -11889, 50350024, 1103331, 50350024, 1103331, "2026-09-28 09:54:56"))
                .sample(new FakeVendorDatabase.SampleRow(ACTIVE_TABLE, 440L, 1, 2, 7436L, 3, 9, "4", 0L,
                        13700, 0, 0, 0, 0, 0, "2026-09-28 09:54:56"));
    }
 
    @Test
    void discoveryListsRegisteredChannelsWithLastDataTimeOnlyFromSampleRows()
    {
        List<DetectedChannel> channels = adapter(activeDeviceDatabase()).discoverChannels();
        assertEquals(3, channels.size());
        // 候选集合来自通道登记表:只出现在历史数据里的退役设备不出现。
        assertTrue(channels.stream().allMatch(item -> item.identity().devid() == 36));
        DetectedChannel active = channels.stream()
                .filter(item -> item.identity().chlid() == 2).findFirst().orElseThrow();
        assertEquals(new ChannelIdentity(ADAPTER_CODE, 21, 36, 1, 2), active.identity());
        assertTrue(active.available());
        // 最近数据时间取该通道最新采样行的设备时间(按适配器时区解释):Asia/Hong_Kong 09:54:56 = 01:54:56Z。
        assertEquals(Instant.parse("2026-09-28T01:54:56Z"), active.lastDataAt());
        // 没有采样行的通道不报最近数据时间:活跃只看有没有新采样行写入。
        DetectedChannel idle = channels.stream()
                .filter(item -> item.identity().chlid() == 1 && item.identity().subdevid() == 1)
                .findFirst().orElseThrow();
        assertNull(idle.lastDataAt());
    }
 
    @Test
    void latestTestIsPickedByStartTimeNotByTestId()
    {
        FakeVendorDatabase database = new FakeVendorDatabase()
                .registeredChannel(DEV_UID, 1, 2)
                // 测试编号更大的那条起始时间更早:按 test_id 排序会取错。
                .test(new FakeVendorDatabase.TestRow(DEV_UID, 1, 2, 2818575907L, "2026-02-20 09:00:00",
                        "data_210036_2026_02_20", null))
                .test(new FakeVendorDatabase.TestRow(DEV_UID, 1, 2, 2818575540L, "2026-09-28 09:00:00",
                        "data_210036_2026_09_28", null))
                .sample(new FakeVendorDatabase.SampleRow("data_210036_2026_02_20", 2818575907L, 1, 2, 10L, 1, 1, "1",
                        1000L, 5000, 1000, 10, 10, 10, 10, "2026-02-20 09:00:01"))
                .sample(new FakeVendorDatabase.SampleRow("data_210036_2026_09_28", 2818575540L, 1, 2, 20L, 4, 7, "2",
                        60000L, 31541, -12000, 700000, 900000, 800000, 1000000, "2026-09-28 09:01:00"));
        CabinetRealtime latest = adapter(database).latestCabinet(CHANNEL);
        assertEquals(20L, latest.sequenceId());
        assertEquals(Instant.parse("2026-09-28T01:01:00Z"), latest.deviceTime());
    }
 
    @Test
    void shardTableComesFromTheTestRecordInsteadOfTheDeviceTimeDate()
    {
        // 实测表名按建表日:9-28 启动的测试写进 9-18 的表。适配器必须用测试记录给出的表名。
        CabinetRealtime latest = adapter(activeDeviceDatabase()).latestCabinet(CHANNEL);
        assertEquals("2026-09-28 09:54:56", latest.deviceTimeRaw());
        assertEquals(Instant.parse("2026-09-28T01:54:56Z"), latest.deviceTime());
        assertEquals("Asia/Hong_Kong", latest.timezoneId());
        assertEquals(CLOCK.instant(), latest.serverReceivedAt());
    }
 
    @Test
    void sampledValuesAreConvertedWithTheFieldMeasuredFormula()
    {
        // 同秒两行的第一行承载本次实测的换算样本。
        List<CabinetRealtime> rows = adapter(activeDeviceDatabase()).samplesSince(CHANNEL,
                new SampleWatermark(Instant.parse("2026-09-28T01:54:56Z"), 7434L));
        CabinetRealtime latest = rows.get(0);
        // test_vol 13733 → 1.3733 V;test_cur -11889 → -1.1889 A;放电容量与能量除以 36000000。
        assertEquals(1.3733, latest.voltageV(), 1e-9);
        assertEquals(-1.1889, latest.currentA(), 1e-9);
        assertEquals(50350024d / 36000000d, latest.capacityAh(), 1e-9);
        assertEquals(1103331d / 36000000d, latest.energyWh(), 1e-9);
        // test_time 单位为毫秒,作为工步内相对时长输出秒。
        assertEquals(300L, latest.relativeTimeSeconds());
        assertEquals(3, latest.cycleNo());
        assertEquals(12, latest.stepNo());
        assertEquals("2", latest.stepType());
        assertEquals("neware:440:7435", latest.sourceRecordId());
        // 工步类型保留设备给出的数字,不按电流符号猜方向;同一秒的另一行靠工步与序号区分。
        assertEquals(9, rows.get(1).stepNo());
    }
 
    @Test
    void onlineFactIsSourceReachabilityAndChannelWorkStatusStaysUnobservable()
    {
        NewareMysqlAdapter adapter = adapter(activeDeviceDatabase());
        Map<ChannelIdentity, RealtimeSnapshot> snapshots = adapter.inquire(Set.of(CHANNEL));
        RealtimeSnapshot snapshot = snapshots.get(CHANNEL);
        assertEquals(DeviceConnectionStatus.ONLINE, snapshot.connectionStatus());
        // 读库路线下通道工作状态不可观测,不按数据行推断。
        assertEquals(DeviceWorkStatus.UNOBSERVABLE, snapshot.workStatus());
        assertFalse(adapter.capabilities().control());
        // 辅助测量来自导入,不来自新威辅助表族。
        assertNull(adapter.latestCollector(ADAPTER_CODE, "anything"));
        assertEquals(NewareMysqlAdapter.SAMPLE_INTERVAL_MS, adapter.persistenceIntervalMillis(CHANNEL));
    }
 
    @Test
    void samplesSinceKeepsEveryRowInTheSameSecondAndDropsRowsAtOrBeforeTheWatermark()
    {
        NewareMysqlAdapter adapter = adapter(activeDeviceDatabase());
        SampleWatermark watermark = new SampleWatermark(Instant.parse("2026-09-28T01:54:56Z"), 7434L);
        List<CabinetRealtime> rows = adapter.samplesSince(CHANNEL, watermark);
        // 同一绝对秒内的两行都保留,并且靠协议序号区分。
        assertEquals(2, rows.size());
        assertEquals(List.of(7435L, 7436L), rows.stream().map(CabinetRealtime::sequenceId).toList());
        assertEquals(List.of("neware:440:7435", "neware:440:7436"),
                rows.stream().map(CabinetRealtime::sourceRecordId).toList());
        // 严格大于水位:等于水位的行不返回。
        assertEquals(1, adapter.samplesSince(CHANNEL,
                new SampleWatermark(Instant.parse("2026-09-28T01:54:56Z"), 7435L)).size());
        // 水位之后没有新行时返回空。
        assertTrue(adapter.samplesSince(CHANNEL,
                new SampleWatermark(Instant.parse("2026-09-28T01:54:56Z"), 7436L)).isEmpty());
    }
 
    @Test
    void samplesWithoutWatermarkReturnNothingSoDeviceHistoryNeverEntersTheTest()
    {
        NewareMysqlAdapter adapter = adapter(activeDeviceDatabase());
        assertTrue(adapter.samplesSince(CHANNEL, null).isEmpty());
    }
 
    @Test
    void emptyWatermarkMeansTheChannelHadNoRowsSoEveryLaterRowBelongsToTheReception()
    {
        // 边界时该通道在来源里没有任何行(例如通道尚未开始测试),之后出现的行全部属于本次接收。
        List<CabinetRealtime> rows = adapter(activeDeviceDatabase())
                .samplesSince(CHANNEL, new SampleWatermark(null, 0L));
        assertEquals(2, rows.size());
    }
 
    @Test
    void watermarkIsTheLatestRowOfTheLatestTest()
    {
        SampleWatermark watermark = adapter(activeDeviceDatabase()).currentWatermark(CHANNEL);
        assertEquals(Instant.parse("2026-09-28T01:54:56Z"), watermark.deviceTime());
        assertEquals(7436L, watermark.sequenceId());
        // 通道没有测试记录时水位为空,接收路径不把设备历史并入本测试。
        NewareMysqlAdapter empty = adapter(new FakeVendorDatabase().registeredChannel(DEV_UID, 1, 2));
        assertNull(empty.currentWatermark(CHANNEL).deviceTime());
    }
 
    @Test
    void rejectsChannelIdentitiesThatBelongToAnotherInstance()
    {
        NewareMysqlAdapter adapter = adapter(activeDeviceDatabase());
        ChannelIdentity foreign = new ChannelIdentity("other-source", 21, 36, 1, 2);
        org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class, () -> adapter.latestCabinet(foreign));
        assertTrue(adapter.discoverChannels().stream()
                .allMatch(item -> ADAPTER_CODE.equals(item.identity().adapterCode())));
    }
 
    // ---------------------------------------------------------------- 工步层与循环聚合
 
    /**
     * 工步记录来自设备工步层:每工步一条,取该工步的结束行;四组容量能量与中值电压放在扩展测量里,
     * 中值电压由设备字段承载,不用峰值或谷值替代。
     */
    @Test
    void stepRecordsComeFromTheDeviceStepLayerAndCarryAllFourCapacityGroups()
    {
        List<StepRecord> steps = adapter(stepLayerDatabase()).stepRecords(CHANNEL);
        // 只有进入行的工步仍在进行中,不出现:设备还没写后续工步的行。
        assertEquals(9, steps.size());
        assertTrue(steps.stream().noneMatch(step -> step.cycleNo() == 2));
 
        StepRecord rest = step(steps, 0, 1);
        assertEquals("4", rest.stepType());
        assertNull(rest.stepName());
        assertEquals(Instant.parse("2026-09-28T01:00:00Z"), rest.startedAt());
        assertEquals(Instant.parse("2026-09-28T01:00:38Z"), rest.endedAt());
        assertEquals(1.3588, rest.endVoltageV(), 1e-9);
        // 工步记录同时带结束时的电流读数。
        assertEquals(0d, rest.endCurrentA(), 1e-9);
        assertEquals(-1.1889, step(steps, 1, 11).endCurrentA(), 1e-9);
        // 工步时间是设备给的 test_time(毫秒 → 秒),不是按采样时间推算的。
        assertEquals(0, new java.math.BigDecimal("38.800").compareTo(rest.measurements().get("stepDuration")));
        assertEquals(0, new java.math.BigDecimal("1.358800000000").compareTo(rest.measurements().get("startVoltage")));
 
        StepRecord charge = step(steps, 0, 4);
        assertEquals("1", charge.stepType());
        // 单项容量只在方向已确认时给出:充电工步取充电侧。
        assertEquals(119277040d / 36000000d, charge.capacityAh(), 1e-9);
        assertEquals(195220576d / 36000000d, charge.energyWh(), 1e-9);
        // 充电中值电压直接取设备工步层的 middle_vol,设备给零就保留零。
        assertEquals(0, java.math.BigDecimal.ZERO.compareTo(charge.measurements().get("chargeMedianVoltage")));
        assertNull(charge.measurements().get("dischargeMedianVoltage"));
 
        StepRecord discharge = step(steps, 0, 6);
        assertEquals("2", discharge.stepType());
        assertEquals(68462032d / 36000000d, discharge.capacityAh(), 1e-9);
        assertTrue(discharge.measurements().containsKey("dischargeMedianVoltage"));
        // 四组容量能量都是设备事实:每一组都保留,不只保留方向匹配的那一组。
        for (String field : List.of("chargeCapacity", "dischargeCapacity", "chargeEnergy", "dischargeEnergy"))
            assertTrue(charge.measurements().containsKey(field) && discharge.measurements().containsKey(field));
        assertEquals("neware:440:step:0:4", charge.sourceRecordId());
        assertEquals("Asia/Hong_Kong", charge.timezoneId());
        assertEquals(charge.endedAt(), charge.serverReceivedAt());
    }
 
    /** 未知工步类型保留明细并保持未知:不按电流符号,也不按容量出现在哪一组去猜方向。 */
    @Test
    void unknownStepTypeKeepsItsDetailAndStaysOutOfTheCycleAggregate()
    {
        List<StepRecord> steps = adapter(stepLayerDatabase()).stepRecords(CHANNEL);
        StepRecord unknown = step(steps, 1, 3);
        assertEquals("7", unknown.stepType());
        // 单项容量留空:方向未确认时不给出可归属的单值,但四组明细照原样保留供核对。
        assertNull(unknown.capacityAh());
        assertNull(unknown.energyWh());
        assertEquals(500000000d / 36000000d, unknown.measurements().get("chargeCapacity").doubleValue(), 1e-9);
 
        CycleRecord cycle = cycle(adapter(stepLayerDatabase()).cycleRecords(CHANNEL), 1);
        // 未知方向的那一步容量没有进任何一侧:本循环的充放电仍只由已知方向的工步构成。
        assertEquals((119267664d + 85510512d) / 36000000d, cycle.chargeCapacityAh(), 1e-9);
        assertEquals((67587520d + 47559464d) / 36000000d, cycle.dischargeCapacityAh(), 1e-9);
    }
 
    /**
     * 循环统计由工步层按循环聚合并标注为聚合来源:同一循环内同方向的工步容量求和
     * (实测该化成协议 cycle 1 有两个充电工步与两个放电工步),效率是放电容量除以充电容量。
     */
    @Test
    void cycleRecordsAreAggregatedFromTheStepLayerAndMarkedAsAggregated()
    {
        List<CycleRecord> cycles = adapter(stepLayerDatabase()).cycleRecords(CHANNEL);
        // 仍在进行的循环整体不出现:工步行还没写完,聚合出来的是半个循环。
        assertEquals(2, cycles.size());
 
        CycleRecord first = cycle(cycles, 0);
        assertEquals(119277040d / 36000000d, first.chargeCapacityAh(), 1e-9);
        // 两个放电工步求和:16219380 + 68462032。
        assertEquals(84681412d / 36000000d, first.dischargeCapacityAh(), 1e-9);
        assertEquals(195220576d / 36000000d, first.chargeEnergyWh(), 1e-9);
        assertEquals(40000000d / 36000000d, first.dischargeEnergyWh(), 1e-9);
        assertEquals(70.9956, first.efficiency(), 1e-3);
        // 来源类别是聚合:界面与导出据此标注"这个数是推导出来的"。
        assertEquals(CycleRecord.SOURCE_AGGREGATED, first.sourceKind());
        assertEquals("neware:440:cycle:0", first.sourceRecordId());
        // 循环的起止取该循环首末工步的设备时间,服务器接收时间同样取设备时刻,暂停与继续据此留白。
        assertEquals(Instant.parse("2026-09-28T01:00:00Z"), first.startedAt());
        assertEquals(Instant.parse("2026-09-28T14:04:47Z"), first.endedAt());
        assertEquals(first.endedAt(), first.serverReceivedAt());
 
        CycleRecord second = cycle(cycles, 1);
        assertEquals((119267664d + 85510512d) / 36000000d, second.chargeCapacityAh(), 1e-9);
        assertEquals((67587520d + 47559464d) / 36000000d, second.dischargeCapacityAh(), 1e-9);
        assertEquals(56.2301, second.efficiency(), 1e-3);
    }
 
    /** 循环没有可用充电容量时效率留空:未知比率不写成零。 */
    @Test
    void cycleEfficiencyStaysEmptyWhenThereIsNoChargeCapacity()
    {
        FakeVendorDatabase database = new FakeVendorDatabase()
                .registeredChannel(DEV_UID, 1, 5)
                .test(new FakeVendorDatabase.TestRow(DEV_UID, 1, 5, 440L, "2026-09-28 09:00:00", ACTIVE_TABLE, null))
                .capStep(new FakeVendorDatabase.CapStepRow(440L, DEV_UID, 1, 5, 1L, 9, 1, "2", 3600000L,
                        13000, -5000, 0, 0, 50000000, 9000000, 0, "2026-09-28 12:00:00"))
                .capStep(new FakeVendorDatabase.CapStepRow(440L, DEV_UID, 1, 5, 2L, 10, 1, "4", 60000L,
                        13000, 0, 0, 0, 0, 0, 0, "2026-09-28 12:01:00"));
        List<CycleRecord> cycles = adapter(database)
                .cycleRecords(new ChannelIdentity(ADAPTER_CODE, 21, 36, 1, 5));
        assertEquals(1, cycles.size());
        assertEquals(0d, cycles.get(0).chargeCapacityAh(), 1e-9);
        assertNull(cycles.get(0).efficiency());
    }
 
    /** 没有测试记录、没有工步行或通道属于别实例时,工步与循环都保持为空而不报错。 */
    @Test
    void stepAndCycleRecordsStayEmptyWithoutAStepLayerOrTest()
    {
        NewareMysqlAdapter withoutTest = adapter(new FakeVendorDatabase().registeredChannel(DEV_UID, 1, 2));
        assertTrue(withoutTest.stepRecords(CHANNEL).isEmpty());
        assertTrue(withoutTest.cycleRecords(CHANNEL).isEmpty());
        assertTrue(adapter(activeDeviceDatabase()).stepRecords(CHANNEL).isEmpty());
        org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class,
                () -> adapter(activeDeviceDatabase()).cycleRecords(new ChannelIdentity("other-source", 21, 36, 1, 2)));
    }
 
    private static StepRecord step(List<StepRecord> steps, int cycleNo, int stepNo)
    {
        return steps.stream().filter(item -> item.cycleNo() == cycleNo && item.stepNo() == stepNo)
                .findFirst().orElseThrow();
    }
 
    private static CycleRecord cycle(List<CycleRecord> cycles, int cycleNo)
    {
        return cycles.stream().filter(item -> item.cycleNo() == cycleNo).findFirst().orElseThrow();
    }
 
    /**
     * 工步层假厂商库:cycle 0 走完搁置、放电、充电、放电,cycle 1 有两个充电工步、两个放电工步
     * 与一个未知类型工步;末组(cycle 2 的 step 9)只有进入行——它仍在进行中。
     */
    private FakeVendorDatabase stepLayerDatabase()
    {
        return new FakeVendorDatabase()
                .registeredChannel(DEV_UID, 1, 2)
                .test(new FakeVendorDatabase.TestRow(DEV_UID, 1, 2, 440L, "2026-09-28 09:00:00",
                        ACTIVE_TABLE, null))
                // cycle 0:搁置、放电、充电、放电;结束行承载该工步累计容量与能量。
                .capStep(step(1L, 0, 1, "4", 0L, 13588, 0, 0, 0, 0, 0, "2026-09-28 09:00:00"))
                .capStep(step(2L, 0, 1, "4", 38800L, 13588, 0, 0, 0, 0, 0, "2026-09-28 09:00:38"))
                .capStep(step(3L, 0, 2, "2", 0L, 13222, -4735, 0, 0, 0, 0, "2026-09-28 09:00:38"))
                .capStep(step(4L, 0, 2, "2", 3425000L, 9998, -4736, 0, 0, 16219380, 10000000,
                        "2026-09-28 10:04:37"))
                .capStep(step(5L, 0, 4, "1", 0L, 13507, 4732, 0, 0, 0, 0, "2026-09-28 11:03:51"))
                .capStep(step(6L, 0, 4, "1", 25200000L, 17085, 4733, 119277040, 195220576, 0, 0,
                        "2026-09-28 18:03:51"))
                .capStep(step(7L, 0, 6, "2", 0L, 14574, -4735, 0, 0, 0, 0, "2026-09-28 18:03:51"))
                .capStep(step(8L, 0, 6, "2", 14455700L, 9998, -4736, 0, 0, 68462032, 30000000,
                        "2026-09-28 22:04:47"))
                // cycle 1:两个充电工步 + 两个放电工步 + 一个未知类型工步。
                .capStep(step(9L, 1, 1, "1", 0L, 13507, 4732, 0, 0, 0, 0, "2026-09-28 22:04:47"))
                .capStep(step(10L, 1, 1, "1", 25200000L, 17085, 4733, 119267664, 196020320, 0, 0,
                        "2026-09-29 05:04:47"))
                .capStep(step(11L, 1, 2, "2", 0L, 14574, -4735, 0, 0, 0, 0, "2026-09-29 05:04:47"))
                .capStep(step(12L, 1, 2, "2", 14271500L, 9998, -4736, 0, 0, 67587520, 86258072,
                        "2026-09-29 09:02:38"))
                // 未知类型 7:充电组有值,但方向未确认,所以不进聚合、单项容量留空。
                .capStep(step(13L, 1, 3, "7", 0L, 13000, 500, 500000000, 900000000, 0, 0, "2026-09-29 09:02:39"))
                .capStep(step(14L, 1, 3, "7", 600000L, 13200, 600, 500000000, 900000000, 0, 0,
                        "2026-09-29 09:12:39"))
                .capStep(step(15L, 1, 9, "1", 0L, 13092, 11860, 0, 0, 0, 0, "2026-09-29 09:12:40"))
                .capStep(step(16L, 1, 9, "1", 7200000L, 18176, 11875, 85510512, 145614320, 0, 0,
                        "2026-09-29 11:12:40"))
                .capStep(step(17L, 1, 11, "2", 0L, 14313, -11878, 0, 0, 0, 0, "2026-09-29 11:12:41"))
                .capStep(step(18L, 1, 11, "2", 4001000L, 9998, -11889, 0, 0, 47559464, 58816460,
                        "2026-09-29 12:19:21"))
                // 仍在进行的工步:只有进入行。
                .capStep(step(19L, 2, 9, "1", 0L, 13774, 11871, 0, 0, 0, 0, "2026-09-29 12:19:22"));
    }
 
    /** 工步层一行的简写:本用例只用本通道的数据,中值电压按现场实测给出零。 */
    private static FakeVendorDatabase.CapStepRow step(long seqId, int cycle, int stepId, String stepType,
            long testTimeMillis, int voltage, int current, int capacityCharge, int energyCharge,
            int capacityDischarge, int energyDischarge, String testAtime)
    {
        return new FakeVendorDatabase.CapStepRow(440L, DEV_UID, 1, 2, seqId, cycle, stepId, stepType,
                testTimeMillis, voltage, current, capacityCharge, energyCharge, capacityDischarge, energyDischarge,
                0, testAtime);
    }
 
    /** 凭据只用现场取得的服务端只读账号;已知的全权限账号不进入配置。 */
    @Test
    void credentialsFallBackToTheReadOnlyAccountAndRefuseFullPrivilegeAccounts()
    {
        assertEquals("visitor", NewareMysqlAdapterFactory.readOnlyUser(Map.of()));
        assertEquals("visitor", NewareMysqlAdapterFactory.readOnlyUser(Map.of("user", " ")));
        assertEquals("another_ro", NewareMysqlAdapterFactory.readOnlyUser(Map.of("user", "another_ro")));
        for (String account : List.of("BtsServer", "btsserver", "root", "ROOT"))
        {
            var failure = org.junit.jupiter.api.Assertions.assertThrows(
                    com.battery.device.api.DeviceAdapterException.class,
                    () -> NewareMysqlAdapterFactory.readOnlyUser(Map.of("user", account)));
            assertTrue(failure.getMessage().contains(account));
        }
    }
 
    /**
     * 口令取值顺序:本地凭据文件 → 环境变量 → 启动参数;
     * 三者都没有时失败信息同时点名文件路径与键名,使"忘了放口令"是可见的失败。
     */
    @Test
    void credentialPrefersTheLocalFileThenEnvironmentThenStartupParameter() throws Exception
    {
        java.nio.file.Path directory = java.nio.file.Files.createTempDirectory("vendor-credentials");
        java.nio.file.Path file = directory.resolve("vendor-credentials.properties");
        String key = "BATTERY_VENDOR_DB_PASSWORD";
        try
        {
            // 文件、环境变量、启动参数都在时取文件。
            java.nio.file.Files.writeString(file, key + "=from-file\n");
            assertEquals("from-file", LocalCredentials.resolve(key, file,
                    Map.of(key, "from-env"), name -> "from-parameter"));
 
            // 没有文件时退到环境变量。
            java.nio.file.Files.delete(file);
            assertEquals("from-env", LocalCredentials.resolve(key, file,
                    Map.of(key, "from-env"), name -> "from-parameter"));
 
            // 前两者都没有时退到启动参数。
            assertEquals("from-parameter", LocalCredentials.resolve(key, file,
                    Map.of(), name -> "from-parameter"));
 
            // 三者都没有:失败信息里同时出现文件路径与键名。
            var failure = org.junit.jupiter.api.Assertions.assertThrows(
                    com.battery.device.api.DeviceAdapterException.class,
                    () -> LocalCredentials.resolve(key, file, Map.of(), name -> null));
            assertTrue(failure.getMessage().contains(file.toString()));
            assertTrue(failure.getMessage().contains(key));
        }
        finally
        {
            java.nio.file.Files.deleteIfExists(file);
            java.nio.file.Files.deleteIfExists(directory);
        }
    }
 
    /** 凭据文件里的空白值不算配置过:否则一个空模板会把失败推到连接阶段。 */
    @Test
    void blankCredentialInTheFileIsTreatedAsMissing() throws Exception
    {
        java.nio.file.Path directory = java.nio.file.Files.createTempDirectory("vendor-credentials-blank");
        java.nio.file.Path file = directory.resolve("vendor-credentials.properties");
        String key = "BATTERY_VENDOR_DB_PASSWORD";
        try
        {
            java.nio.file.Files.writeString(file, key + "=\n");
            assertEquals("from-env", LocalCredentials.resolve(key, file, Map.of(key, "from-env"),
                    name -> null));
        }
        finally
        {
            java.nio.file.Files.deleteIfExists(file);
            java.nio.file.Files.deleteIfExists(directory);
        }
    }
}