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.SampleWatermark; 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}]} """; 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"); @Test void topologyPushMakesChannelsCandidatesWithTheirOwnLatestDataTime() { // 拓扑行里可以带 channelNo:来源自己软件里显示的通道号(现场核对:22 所界面号 = 表名号 + 1) ingest("{\"kind\":\"TOPOLOGY\",\"rows\":[{\"channel\":26,\"channelNo\":27},{\"channel\":18}]}"); ingest(BATCH); PushIngestAdapter adapter = adapter(NOW); List 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("push:DB_20260927_155209/Ws26_IV_0:422", latest.sourceRecordId()); // 实时快照:通道工作状态在读落地表路线下不可观测(ADR-0063)。 Map 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 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()); } /** 采集器辅助测量按 ADR-0064 逐来源确认;22 所环境量的列语义未确认,因此不交出测量值。 */ @Test void collectorAndStepCycleObservationsStayEmptyForThisStorageForm() { ingest("{\"kind\":\"TOPOLOGY\",\"rows\":[{\"channel\":26}]}"); ingest(BATCH); PushIngestAdapter adapter = adapter(NOW); assertNull(adapter.latestCollector(ADAPTER, "collector-1")); assertTrue(adapter.stepRecords(channel()).isEmpty()); assertTrue(adapter.cycleRecords(channel()).isEmpty()); // 落地表在本地,取数不产生厂商负载,所以按展示节奏取数;厂商写库节奏仍是 60 秒。 assertEquals(5000L, adapter.persistenceIntervalMillis(channel())); AdapterCapabilities capabilities = adapter.capabilities(); assertTrue(capabilities.discovery() && capabilities.realtimeObservation() && !capabilities.control()); } @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 new PushIngestAdapter(config(), landing, Clock.fixed(now, ZoneOffset.UTC)); } private static AdapterInstanceConfig config() { return new AdapterInstanceConfig(ADAPTER, PushIngestAdapter.ADAPTER_TYPE, "127.0.0.1", 8080, 60000, "Asia/Shanghai", true, AdapterCapabilities.observeOnly(), Map.of("tokenEnv", "BATTERY_INGEST_TOKEN")); } 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); } }