yeg
5 天以前 691f882740b46c5354fb33404fef6efb370e05dc
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
package com.battery.business.service.impl;
 
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.Clock;
import java.time.Duration;
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.DeviceAdapterException;
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.model.AdapterCapabilities;
import com.battery.device.api.model.ChannelIdentity;
import com.battery.device.api.model.DetectedChannel;
import com.battery.device.api.model.DeviceWorkStatus;
import com.battery.device.api.model.RealtimeSnapshot;
import com.battery.device.api.registry.AdapterInstanceConfig;
import com.battery.business.domain.TDeviceAdapter;
import com.battery.business.mapper.TDeviceAdapterMapper;
import org.mockito.Mockito;
 
/**
 * 推式来源适配器的观测契约:落地行投影成统一观测(身份、时间、单位、幂等身份),
 * 发现候选带最近数据时间且心跳缺失即明确失败,水位按 (设备时间, 协议序号) 取严格更晚的行。
 * 原始行由接收端点解析,所以这里只喂落地行,适配器里不应出现任何厂商列名。
 */
class PushIngestAdapterContractTest
{
    private static final String ADAPTER = "j22";
    private static final String TOKEN = "j22-token";
    /** 前两行同一个绝对秒(08:03:20 UTC)但协议序号不同;第三行晚一分钟。 */
    private static final String BATCH = """
            {"kind":"SAMPLE","database":"DB_20260927_155209","table":"Ws26_IV_0","channel":26,"rows":[
              {"record_time":"134349692000000000","Data_Point":420,"Step_Time":540.0,"Step_Index":4,
               "Cycle_Index":1,"Voltage":1.26778,"eCurrent":0.5,"ChargeCapacity":0.12,"ChargeEnergy":0.3},
              {"record_time":"134349698000000000","Data_Point":421,"Step_Time":600.0,"Step_Index":4,
               "Cycle_Index":1,"Voltage":1.2701,"eCurrent":0.5,"ChargeCapacity":0.121,"ChargeEnergy":0.31},
              {"record_time":"134349698000000000","Data_Point":422,"Step_Time":600.0,"Step_Index":5,
               "Cycle_Index":1,"Voltage":1.2702,"eCurrent":-0.4,"DischargeCapacity":0.05,"DischargeEnergy":0.1}]}
            """;
    /**
     * 两个循环的采样行:循环 1 是充电、搁置、放电三段(都结束了),循环 2 的第一段还在进行。
     * 容量列是工步累计:充电段从 0.10 涨到 0.12,放电段从 0.05 涨到 0.09。
     */
    private static final String TWO_CYCLES = """
            {"kind":"SAMPLE","database":"DB_20260927_155209","table":"Ws26_IV_0","channel":26,"rows":[
              {"record_time":"134349692000000000","Data_Point":420,"Step_Time":540.0,"Step_Index":1,"Cycle_Index":1,
               "Voltage":1.20,"eCurrent":1.0,"ChargeCapacity":0.10,"ChargeEnergy":0.30},
              {"record_time":"134349698000000000","Data_Point":421,"Step_Time":600.0,"Step_Index":1,"Cycle_Index":1,
               "Voltage":1.25,"eCurrent":1.0,"ChargeCapacity":0.12,"ChargeEnergy":0.36},
              {"record_time":"134349704000000000","Data_Point":422,"Step_Time":1200.0,"Step_Index":2,"Cycle_Index":1,
               "Voltage":1.25,"eCurrent":0.0,"ChargeCapacity":0.12,"DischargeCapacity":0.0},
              {"record_time":"134349764000000000","Data_Point":423,"Step_Time":1800.0,"Step_Index":3,"Cycle_Index":1,
               "Voltage":1.24,"eCurrent":-1.0,"ChargeCapacity":0.12,"DischargeCapacity":0.05,"DischargeEnergy":0.15},
              {"record_time":"134349824000000000","Data_Point":424,"Step_Time":3600.0,"Step_Index":3,"Cycle_Index":1,
               "Voltage":1.20,"eCurrent":-1.0,"ChargeCapacity":0.12,"DischargeCapacity":0.09,"DischargeEnergy":0.27},
              {"record_time":"134349830000000000","Data_Point":425,"Step_Time":60.0,"Step_Index":1,"Cycle_Index":2,
               "Voltage":1.18,"eCurrent":2.0,"ChargeCapacity":0.01,"ChargeEnergy":0.02}]}
            """;
    /** 第一段的电流正负混用:方向判不出来,明细照留但不进循环的任何一侧。 */
    private static final String MIXED_SIGN = """
            {"kind":"SAMPLE","database":"DB_20260927_155209","table":"Ws26_IV_0","channel":26,"rows":[
              {"record_time":"134349692000000000","Data_Point":420,"Step_Index":1,"Cycle_Index":1,
               "Voltage":1.20,"eCurrent":1.0,"ChargeCapacity":0.10},
              {"record_time":"134349698000000000","Data_Point":421,"Step_Index":1,"Cycle_Index":1,
               "Voltage":1.20,"eCurrent":-1.0,"ChargeCapacity":0.10,"DischargeCapacity":0.02},
              {"record_time":"134349704000000000","Data_Point":422,"Step_Index":2,"Cycle_Index":1,
               "Voltage":1.25,"eCurrent":1.0,"ChargeCapacity":0.12,"ChargeEnergy":0.36},
              {"record_time":"134349830000000000","Data_Point":425,"Step_Index":1,"Cycle_Index":2,
               "Voltage":1.18,"eCurrent":2.0,"ChargeCapacity":0.01}]}
            """;
 
    private final FakeIngestLanding landing = new FakeIngestLanding();
    private final TDeviceAdapterMapper adapters = Mockito.mock(TDeviceAdapterMapper.class);
    private static final Instant NOW = Instant.parse("2026-09-27T09:00:00Z");
    /**
     * 环境量的确认结论落在这里:厂商列名只在配置里出现一次,代码里没有任何厂商列名。
     * 22 所的列语义与量纲仍未现场确认,所以生产配置里默认没有这一项。
     */
    private static final Map<String, String> AUXILIARY_METRICS = Map.of("auxiliaryMetrics",
            "[{\"vendorChannel\":26,\"channelCode\":\"S-01\",\"column\":\"Tempbox\","
                    + "\"metricCode\":\"TEMPERATURE\",\"unit\":\"℃\"}]");
 
    @Test
    void topologyPushMakesChannelsCandidatesWithTheirOwnLatestDataTime()
    {
        // 拓扑行里可以带 channelNo:来源自己软件里显示的通道号(现场核对:22 所界面号 = 表名号 + 1)
        ingest("{\"kind\":\"TOPOLOGY\",\"rows\":[{\"channel\":26,\"channelNo\":27},{\"channel\":18}]}");
        ingest(BATCH);
        PushIngestAdapter adapter = adapter(NOW);
 
        List<DetectedChannel> channels = adapter.discoverChannels();
 
        assertEquals(List.of(18, 26), channels.stream().map(channel -> channel.identity().chlid()).toList());
        DetectedChannel active = channels.stream().filter(channel -> channel.identity().chlid() == 26).findFirst()
                .orElseThrow();
        // 最近数据时间来自该通道自己的采样行,不是拓扑上报时间。
        assertEquals(Instant.parse("2026-09-27T08:03:20Z"), active.lastDataAt());
        assertTrue(active.available());
        // 候选的机器地址取实例配置:登记时会被带进检测柜的 IP 字段。
        assertEquals("127.0.0.1", active.serverIp());
        // 来源自己的通道叫法随候选一起交出(界面与登记用它显示),身份仍是表名里的号。
        assertEquals("27", active.sourceDisplayNo());
        assertEquals(26, active.identity().chlid());
        // 登记范围由操作员决定:没有采样行的通道照样出现在候选里,只是没有最近数据时间。
        assertNull(channels.stream().filter(channel -> channel.identity().chlid() == 18).findFirst()
                .orElseThrow().lastDataAt());
        // 来源没报叫法的通道保持为空,界面回退到「单元-通道」。
        assertNull(channels.stream().filter(channel -> channel.identity().chlid() == 18).findFirst()
                .orElseThrow().sourceDisplayNo());
    }
 
    /** 心跳缺失或过期即来源不可达:发现路径明确失败,业务侧据此把已登记设备置为离线并撤下旧快照。 */
    @Test
    void missingOrStaleHeartbeatFailsDiscoveryInsteadOfReturningStaleValues()
    {
        // 还没收到过任何推送:这是"取数程序没起来"这一条可见的失败,不是沉默的空清单。
        DeviceAdapterException missing = assertThrows(DeviceAdapterException.class, () -> adapter(NOW).discoverChannels());
        assertEquals("SOURCE_OFFLINE", missing.failureCode());
 
        ingest("{\"kind\":\"TOPOLOGY\",\"rows\":[{\"channel\":26}]}");
        // 收到推送即刷新心跳;容忍窗口是三个轮询周期,一轮没推不算离线。
        assertEquals(1, adapter(NOW.plus(Duration.ofSeconds(179))).discoverChannels().size());
        // 连丢三轮才算离线,且不必等候选过期。
        DeviceAdapterException stale = assertThrows(DeviceAdapterException.class,
                () -> adapter(NOW.plus(Duration.ofSeconds(181))).discoverChannels());
        assertEquals("SOURCE_OFFLINE", stale.failureCode());
    }
 
    @Test
    void samplesAreProjectedWithRawTimeUnscaledUnitsAndSourceRowIdentity()
    {
        ingest("{\"kind\":\"TOPOLOGY\",\"rows\":[{\"channel\":26}]}");
        ingest(BATCH);
        PushIngestAdapter adapter = adapter(NOW);
 
        CabinetRealtime latest = adapter.latestCabinet(channel());
 
        // 时间按厂商格式换算为 UTC,同时保留原始值与来源时区;单位是物理单位,不做缩放。
        assertEquals(Instant.parse("2026-09-27T08:03:20Z"), latest.deviceTime());
        assertEquals("134349698000000000", latest.deviceTimeRaw());
        assertEquals("Asia/Shanghai", latest.timezoneId());
        assertEquals(1.2702, latest.voltageV(), 1e-9);
        assertEquals(-0.4, latest.currentA(), 1e-9);
        assertEquals(600L, latest.relativeTimeSeconds());
        assertEquals(1, latest.cycleNo());
        assertEquals(5, latest.stepNo());
        // 放电容量的取法按电流方向,与直连来源同一口径;来源行身份供幂等与冲突判定。
        assertEquals(0.05, latest.capacityAh(), 1e-9);
        // 实时行也带方向:类型与名称都按电流符号判定(该行电流为负 → 放电),
        // 实时页的"工步名称"列用的就是它(用户对照甲方那一列「状态」)。
        assertEquals("discharge", latest.stepType());
        assertEquals("放电", latest.stepName());
        assertEquals("push:DB_20260927_155209/Ws26_IV_0:422", latest.sourceRecordId());
 
        // 实时快照:通道工作状态在读落地表路线下不可观测(ADR-0063)。
        Map<ChannelIdentity, RealtimeSnapshot> snapshots = adapter.inquire(Set.of(channel()));
        assertEquals(DeviceWorkStatus.UNOBSERVABLE, snapshots.get(channel()).workStatus());
        assertEquals(Instant.parse("2026-09-27T08:03:20Z"), snapshots.get(channel()).deviceTime());
    }
 
    @Test
    void watermarkAndSamplesSinceKeepSameSecondRowsDistinctAndSkipNothingBeforeTheBoundary()
    {
        ingest("{\"kind\":\"TOPOLOGY\",\"rows\":[{\"channel\":26}]}");
        ingest(BATCH);
        PushIngestAdapter adapter = adapter(NOW);
 
        // 水位是落地表里该通道最新一行:设备绝对时间与协议序号。
        assertEquals(new SampleWatermark(Instant.parse("2026-09-27T08:03:20Z"), 422L),
                adapter.currentWatermark(channel()));
 
        // 只取严格晚于水位的行:同一绝对秒内的多行靠协议序号各自成立。
        List<CabinetRealtime> rows = adapter.samplesSince(channel(),
                new SampleWatermark(Instant.parse("2026-09-27T07:53:20Z"), 420L));
        assertEquals(List.of(421L, 422L), rows.stream().map(CabinetRealtime::sequenceId).toList());
        assertEquals(0, adapter.samplesSince(channel(),
                new SampleWatermark(Instant.parse("2026-09-27T08:03:20Z"), 422L)).size());
        // 同一绝对秒内序号更大的行仍然成立:水位是二元组而不是秒。
        assertEquals(List.of(422L), adapter.samplesSince(channel(),
                new SampleWatermark(Instant.parse("2026-09-27T08:03:20Z"), 421L)).stream()
                .map(CabinetRealtime::sequenceId).toList());
        // 调用方尚未取得水位时不把来源里的历史并入本次接收。
        assertTrue(adapter.samplesSince(channel(), null).isEmpty());
        // 水位为空的二元组表示边界时刻来源里什么也没有:之后出现的行全部属于本次接收。
        assertEquals(3, adapter.samplesSince(channel(), new SampleWatermark(null, 0L)).size());
    }
 
    @Test
    void emptySourceHasNoWatermarkAndNoRealtimeObservation()
    {
        ingest("{\"kind\":\"TOPOLOGY\",\"rows\":[{\"channel\":26}]}");
        PushIngestAdapter adapter = adapter(NOW);
 
        assertEquals(new SampleWatermark(null, 0L), adapter.currentWatermark(channel()));
        assertNull(adapter.latestCabinet(channel()));
        assertTrue(adapter.inquire(Set.of(channel())).isEmpty());
    }
 
    /** 未声明辅助测量列就不交出测量值:确认之前不按列名推断语义,也不写零占位。 */
    @Test
    void collectorObservationsStayEmptyWithoutDeclaredAuxiliaryMetrics()
    {
        ingest("{\"kind\":\"TOPOLOGY\",\"rows\":[{\"channel\":26}]}");
        ingest(BATCH);
        PushIngestAdapter adapter = adapter(NOW);
 
        assertNull(adapter.latestCollector(ADAPTER, "collector-1"));
        // 落地表在本地,取数不产生厂商负载,所以按展示节奏取数;厂商写库节奏仍是 60 秒。
        assertEquals(5000L, adapter.persistenceIntervalMillis(channel()));
        // 工步与循环的重算跟着来源的写库节奏走,不跟实时行的展示节奏:源头最快 60 秒才产出一行。
        assertEquals(60000L, adapter.aggregateIntervalMillis(channel()));
        AdapterCapabilities capabilities = adapter.capabilities();
        assertTrue(capabilities.discovery() && capabilities.realtimeObservation() && !capabilities.control());
    }
 
    /**
     * 来源没有工步层,工步与循环由采样行分段推导(ADR-0067):已结束的段各交出一条工步记录,
     * 取值来自该段末行,方向由该段自己的电流符号判定;最新一段仍在进行,不交出去。
     */
    @Test
    void closedSampleSegmentsBecomeStepRecordsWhileTheNewestSegmentStaysUnpublished()
    {
        ingest(BATCH);
        PushIngestAdapter adapter = adapter(NOW);
 
        List<StepRecord> steps = adapter.stepRecords(channel());
 
        // 三行落地行分成两段:(循环 1, 工步 4) 已结束,(循环 1, 工步 5) 还在进行。
        assertEquals(1, steps.size());
        StepRecord step = steps.get(0);
        assertEquals(1, step.cycleNo());
        assertEquals(4, step.stepNo());
        // 方向取自该段电流符号:段内两行恒正即充电。
        assertEquals("charge", step.stepType());
        // 工步名称用同一套词(甲方软件那一列「状态」显示的就是充电/放电/搁置):该来源的方案里
        // 只有「步_N」标号,这个名称是判定出的方向,不是设备给的名称。
        assertEquals("充电", step.stepName());
        // 容量与能量是工步累计,取该段末行的值;工步时间是工步内相对时间(秒)。
        assertEquals(0.121, step.capacityAh(), 1e-9);
        assertEquals(0.31, step.energyWh(), 1e-9);
        assertEquals(600.0, step.measurements().get("stepDuration").doubleValue(), 1e-9);
        assertEquals(1.26778, step.measurements().get("startVoltage").doubleValue(), 1e-9);
        // 设备没给的列不写零占位:该段只有充电侧。
        assertFalse(step.measurements().containsKey("dischargeCapacity"));
        assertEquals(1.2701, step.endVoltageV(), 1e-9);
        assertEquals(0.5, step.endCurrentA(), 1e-9);
        assertEquals(Instant.parse("2026-09-27T07:53:20Z"), step.startedAt());
        assertEquals(Instant.parse("2026-09-27T08:03:20Z"), step.endedAt());
        // 接收时间取我们收到该段末行的时刻(与推式实时路径同一口径),设备原始时间照留。
        assertEquals(NOW, step.serverReceivedAt());
        assertEquals("134349698000000000", step.deviceTimeRaw());
        // 来源行身份含来源库与段标识:同一次测试重算多少次都得到同一个身份。
        assertEquals("push:DB_20260927_155209/Ws26_IV_0:step:1:4", step.sourceRecordId());
        // 进行中的那一段所属的循环整体不交:半个循环不是事实。
        assertTrue(adapter.cycleRecords(channel()).isEmpty());
    }
 
    /**
     * 循环由已结束的工步按方向求和(搁置段两向都不增长,贡献 0),效率是放电容量与充电容量之比;
     * 推导值带来源类别,界面与导出据此标注它是聚合出来的。
     */
    @Test
    void cyclesAggregateClosedStepsByDirectionAndSkipTheRunningCycle()
    {
        ingest(TWO_CYCLES);
        PushIngestAdapter adapter = adapter(NOW);
 
        List<StepRecord> steps = adapter.stepRecords(channel());
        List<CycleRecord> cycles = adapter.cycleRecords(channel());
 
        // 循环 1 的三段都已结束(后面已经出现循环 2 的第一段);循环 2 的第一段仍在进行。
        assertEquals(List.of("1:1:charge", "1:2:rest", "1:3:discharge"), steps.stream()
                .map(step -> step.cycleNo() + ":" + step.stepNo() + ":" + step.stepType()).toList());
        // 搁置段自身的累计是 0(该段什么都没充放),不是缺失。
        assertEquals(0.0, steps.get(1).capacityAh(), 1e-9);
        // 搁置段的名称同样是判定出来的。
        assertEquals("搁置", steps.get(1).stepName());
        assertEquals(0.0, steps.get(1).energyWh(), 1e-9);
        assertEquals(List.of(1), cycles.stream().map(CycleRecord::cycleNo).toList());
 
        CycleRecord cycle = cycles.get(0);
        assertEquals(0.12, cycle.chargeCapacityAh(), 1e-9);
        assertEquals(0.09, cycle.dischargeCapacityAh(), 1e-9);
        assertEquals(0.36, cycle.chargeEnergyWh(), 1e-9);
        assertEquals(0.27, cycle.dischargeEnergyWh(), 1e-9);
        // 效率是百分数:0.09 / 0.12。
        assertEquals(75.0, cycle.efficiency(), 1e-9);
        assertEquals(CycleRecord.SOURCE_AGGREGATED, cycle.sourceKind());
        assertEquals("push:DB_20260927_155209/Ws26_IV_0:cycle:1", cycle.sourceRecordId());
    }
 
    /** 方向判不出来时(段内电流正负混用)明细照留、单项容量留空,也不进循环的任何一侧。 */
    @Test
    void segmentsWithMixedCurrentSignsStayOutOfTheCycleAggregate()
    {
        ingest(MIXED_SIGN);
        PushIngestAdapter adapter = adapter(NOW);
 
        List<StepRecord> steps = adapter.stepRecords(channel());
        List<CycleRecord> cycles = adapter.cycleRecords(channel());
 
        StepRecord undecided = steps.get(0);
        assertEquals(1, undecided.cycleNo());
        assertEquals(1, undecided.stepNo());
        // 方向不唯一时不猜:类型留空、单项容量与能量留空。
        assertNull(undecided.stepType());
        // 方向判不出来时名称也留空:不写一个没根据的名字。
        assertNull(undecided.stepName());
        assertNull(undecided.capacityAh());
        assertNull(undecided.energyWh());
        // 四组容量能量作为设备事实照原样保留,供核对。
        assertEquals(0.02, undecided.measurements().get("dischargeCapacity").doubleValue(), 1e-9);
 
        // 该段不进任何一侧:循环 1 的放电侧算出来是 0,而不是把这一段里的 0.02 算进去。
        assertEquals(1, cycles.size());
        assertEquals(0.12, cycles.get(0).chargeCapacityAh(), 1e-9);
        assertEquals(0.0, cycles.get(0).dischargeCapacityAh(), 1e-9);
    }
 
    /** 环境量与量纲确认后落到配置:列名、指标编码、单位、采集器通道都由声明给出,代码里没有厂商列名。 */
    @Test
    void declaredAuxiliaryMetricIsProjectedWithItsOwnSourceIdentityValueUnitAndDeviceTime()
    {
        ingest("{\"kind\":\"TOPOLOGY\",\"rows\":[{\"channel\":26}]}");
        ingest("""
                {"kind":"SAMPLE","database":"DB_20260927_155209","table":"Ws26_IV_0","channel":26,"rows":[
                  {"record_time":"134349692000000000","Data_Point":420,"Voltage":1.26778,"eCurrent":0.5,"Tempbox":26.5}]}
                """);
        PushIngestAdapter adapter = adapter(NOW, AUXILIARY_METRICS);
 
        var collector = adapter.latestCollector(ADAPTER, "collector-1");
 
        assertTrue(collector.online());
        var measurement = collector.measurements().get(0);
        assertEquals("S-01", measurement.channelCode());
        assertEquals("TEMPERATURE", measurement.metricCode());
        assertEquals(0, new java.math.BigDecimal("26.5").compareTo(measurement.value()));
        assertEquals("℃", measurement.unit());
        // 设备时间与来源身份来自这一行自己:不与检测柜记录共用时间,也不伪造配对。
        assertEquals(Instant.parse("2026-09-27T07:53:20Z"), measurement.deviceTime());
        assertEquals("134349692000000000", measurement.deviceTimeRaw());
    }
 
    /** 落地行里没有这一列(设备没给)就不写这一项,也不写零占位。 */
    @Test
    void declaredAuxiliaryMetricAbsentFromTheRowIsNotFabricated()
    {
        ingest("{\"kind\":\"TOPOLOGY\",\"rows\":[{\"channel\":26}]}");
        ingest(BATCH);
        PushIngestAdapter adapter = adapter(NOW, AUXILIARY_METRICS);
 
        var collector = adapter.latestCollector(ADAPTER, "collector-1");
 
        assertTrue(collector.online());
        assertTrue(collector.measurements().isEmpty());
    }
 
    /** 来源离线(心跳超时)时不交出任何测量值:离线不伪造测量值,业务侧据此也不落采集器行。 */
    @Test
    void staleHeartbeatReportsCollectorSourceOfflineWithoutMeasurements()
    {
        ingest("{\"kind\":\"TOPOLOGY\",\"rows\":[{\"channel\":26}]}");
        ingest("""
                {"kind":"SAMPLE","database":"DB_20260927_155209","table":"Ws26_IV_0","channel":26,"rows":[
                  {"record_time":"134349692000000000","Data_Point":420,"Voltage":1.26778,"eCurrent":0.5,"Tempbox":26.5}]}
                """);
        PushIngestAdapter adapter = adapter(NOW.plus(Duration.ofMinutes(10)), AUXILIARY_METRICS);
 
        var collector = adapter.latestCollector(ADAPTER, "collector-1");
 
        assertFalse(collector.online());
        assertTrue(collector.measurements().isEmpty());
    }
 
    @Test
    void channelIdentityFromAnotherInstanceFailsInsteadOfFallingBack()
    {
        PushIngestAdapter adapter = adapter(NOW);
 
        DeviceAdapterException failure = assertThrows(DeviceAdapterException.class,
                () -> adapter.currentWatermark(new ChannelIdentity("neware76", 21, 36, 1, 2)));
        assertTrue(failure.getMessage().contains("neware76"));
        assertFalse(adapter.adapterCode().isEmpty());
    }
 
    private ChannelIdentity channel()
    {
        return new ChannelIdentity(ADAPTER, 0, 0, 1, 26);
    }
 
    private PushIngestAdapter adapter(Instant now)
    {
        return adapter(now, Map.of("tokenEnv", "BATTERY_INGEST_TOKEN"));
    }
 
    private PushIngestAdapter adapter(Instant now, Map<String, String> sourceOptions)
    {
        return new PushIngestAdapter(config(sourceOptions), landing, Clock.fixed(now, ZoneOffset.UTC));
    }
 
    private static AdapterInstanceConfig config(Map<String, String> sourceOptions)
    {
        return new AdapterInstanceConfig(ADAPTER, PushIngestAdapter.ADAPTER_TYPE, "127.0.0.1", 8080, 60000,
                "Asia/Shanghai", true, AdapterCapabilities.observeOnly(), sourceOptions);
    }
 
    private void ingest(String body)
    {
        TDeviceAdapter source = new TDeviceAdapter();
        source.setAdapterCode(ADAPTER);
        source.setAdapterType(PushIngestAdapter.ADAPTER_TYPE);
        source.setStatus("0");
        source.setTimezoneId("Asia/Shanghai");
        Mockito.when(adapters.selectDeviceAdapterByCode(ADAPTER)).thenReturn(source);
        new DeviceIngestServiceImpl(adapters, landing, Clock.fixed(NOW, ZoneOffset.UTC), key -> TOKEN)
                .ingest(ADAPTER, TOKEN, body);
    }
}