package com.battery.business.service.impl; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.time.ZoneId; import java.time.ZoneOffset; import java.util.ArrayList; import java.util.Date; import java.util.HexFormat; import java.util.List; import java.util.Map; import java.util.Set; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import com.battery.device.api.TestDataObservationAdapter; import com.battery.device.api.TestDataObservationAdapter.CabinetRealtime; import com.battery.device.api.TestDataObservationAdapter.CollectorRealtime; 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.DeviceObservation; import com.battery.business.domain.TTest; import com.battery.business.domain.TTestBattery; import com.battery.business.domain.TTestChannelBinding; import com.battery.business.domain.TTestRealtimeRecord; import com.battery.business.mapper.TTestMapper; import com.battery.business.service.ITTestService; /** * 逐行接收的归属边界:水位由"开始接收"与"继续接收"这一刻来源里的最新一行决定, * 只取严格晚于水位的数据行;同秒多行都保留;重复同内容幂等;同身份不同内容不覆盖。 */ class VendorSampleReceptionServiceTest { private static final Instant START = Instant.parse("2026-09-28T02:00:00Z"); private static final ChannelIdentity CHANNEL = new ChannelIdentity("neware76", 21, 36, 1, 2); private static final String IDENTITY = "neware76:21:36:1:2"; @Test void firstPollReadsTheWatermarkWithoutWritingDeviceHistory() { Fixture fixture = fixture(); fixture.adapter.watermark = new SampleWatermark(Instant.parse("2026-09-28T01:54:56Z"), 7436L); fixture.service.captureReceivedData(); assertEquals(1, fixture.adapter.watermarkReads); assertTrue(fixture.adapter.sampleReads.isEmpty()); verify(fixture.mapper, never()).insertRealtimeRecord(any()); } @Test void everyRowStrictlyAfterTheWatermarkBecomesATestFactIncludingSameSecondRows() { Fixture fixture = fixture(); fixture.adapter.watermark = new SampleWatermark(Instant.parse("2026-09-28T01:54:56Z"), 7436L); fixture.service.captureReceivedData(); // 同秒两行都落库:槽位留空,来源行身份把它们区分开。 fixture.adapter.samples = List.of(sample(7437L, "2026-09-28T01:55:56Z", "2026-09-28 09:55:56"), sample(7438L, "2026-09-28T01:55:56Z", "2026-09-28 09:55:56")); fixture.clock.advance(Duration.ofSeconds(60)); fixture.service.captureReceivedData(); ArgumentCaptor rows = ArgumentCaptor.forClass(TTestRealtimeRecord.class); verify(fixture.mapper, times(2)).insertRealtimeRecord(rows.capture()); assertEquals(List.of("neware:440:7437", "neware:440:7438"), rows.getAllValues().stream().map(TTestRealtimeRecord::getSourceRecordId).toList()); assertTrue(rows.getAllValues().stream().allMatch(row -> row.getCaptureSlotUtc() == null)); assertEquals(Instant.parse("2026-09-28T01:55:56Z"), rows.getAllValues().get(0).getCabinetDeviceTimeUtc().toInstant()); assertEquals("2026-09-28 09:55:56", rows.getAllValues().get(0).getCabinetDeviceTimeRaw()); assertEquals(IDENTITY, rows.getAllValues().get(0).getSourceIdentity()); } @Test void resumingReceptionReadsANewWatermarkSoThePauseGapIsNotBackfilled() { Fixture fixture = fixture(); fixture.adapter.watermark = new SampleWatermark(Instant.parse("2026-09-28T01:54:56Z"), 7436L); fixture.service.captureReceivedData(); fixture.adapter.samples = List.of(sample(7437L, "2026-09-28T01:55:56Z", "2026-09-28 09:55:56")); fixture.clock.advance(Duration.ofSeconds(60)); fixture.service.captureReceivedData(); assertEquals(List.of(new SampleWatermark(Instant.parse("2026-09-28T01:54:56Z"), 7436L)), fixture.adapter.sampleReads); // 暂停后继续:接收边界推进,水位重新读取;暂停期间产生的行不再进本测试。 fixture.test.setReceptionBoundaryAtUtc(Date.from(START.plusSeconds(600))); fixture.adapter.watermark = new SampleWatermark(Instant.parse("2026-09-28T02:05:56Z"), 7500L); fixture.adapter.samples = List.of(); fixture.clock.advance(Duration.ofSeconds(60)); fixture.service.captureReceivedData(); assertEquals(2, fixture.adapter.watermarkReads); // 取到新水位后,只追加新水位之后的行;暂停期间的空档不会被补回来。 fixture.clock.advance(Duration.ofSeconds(60)); fixture.service.captureReceivedData(); assertEquals(List.of(new SampleWatermark(Instant.parse("2026-09-28T01:54:56Z"), 7436L), new SampleWatermark(Instant.parse("2026-09-28T02:05:56Z"), 7500L)), fixture.adapter.sampleReads); } @Test void repeatedIdenticalRowIsIgnoredAndConflictingContentIsNotOverwritten() { Fixture fixture = fixture(); fixture.adapter.watermark = new SampleWatermark(Instant.parse("2026-09-28T01:54:56Z"), 7436L); fixture.service.captureReceivedData(); CabinetRealtime sample = sample(7437L, "2026-09-28T01:55:56Z", "2026-09-28 09:55:56"); fixture.adapter.samples = List.of(sample); fixture.clock.advance(Duration.ofSeconds(60)); fixture.service.captureReceivedData(); ArgumentCaptor first = ArgumentCaptor.forClass(TTestRealtimeRecord.class); verify(fixture.mapper).insertRealtimeRecord(first.capture()); // 同身份同内容:幂等忽略,不再插入。 when(fixture.mapper.selectRealtimeRecordHashBySourceRecord(4L, IDENTITY, "neware:440:7437")) .thenReturn(HexFormat.of().formatHex(first.getValue().getContentSha256())); fixture.adapter.watermark = new SampleWatermark(sample.deviceTime(), sample.sequenceId()); fixture.clock.advance(Duration.ofSeconds(60)); fixture.service.captureReceivedData(); verify(fixture.mapper, times(1)).insertRealtimeRecord(any()); // 同身份不同内容:保留原事实,拒绝覆盖。 when(fixture.mapper.selectRealtimeRecordHashBySourceRecord(4L, IDENTITY, "neware:440:7437")) .thenReturn("0000000000000000000000000000000000000000000000000000000000000000"); fixture.clock.advance(Duration.ofSeconds(60)); fixture.service.captureReceivedData(); verify(fixture.mapper, times(1)).insertRealtimeRecord(any()); } @Test void sourceWithoutWatermarkKeepsThePollingSlotPath() { Fixture fixture = fixture(); // 不提供水位的来源(例如开发模拟器)继续按轮询槽位保存柜端事实。 fixture.adapter.watermark = null; fixture.adapter.latest = sample(9L, "2026-09-28T02:00:30Z", "2026-09-28 10:00:30"); fixture.service.captureReceivedData(); assertEquals(1, fixture.adapter.watermarkReads); assertTrue(fixture.adapter.sampleReads.isEmpty()); ArgumentCaptor rows = ArgumentCaptor.forClass(TTestRealtimeRecord.class); verify(fixture.mapper).insertRealtimeRecord(rows.capture()); assertTrue(rows.getValue().getCaptureSlotUtc() != null); } private static CabinetRealtime sample(long sequenceId, String deviceTime, String raw) { return new CabinetRealtime(sequenceId, Instant.parse(deviceTime), -1.1889, 1.3733, 1.398611, 0.030648, 3, 12, null, "2", Instant.parse("2026-09-28T02:01:00Z"), raw, "Asia/Hong_Kong", 300L, "neware:440:" + sequenceId); } private static Fixture fixture() { TTestMapper mapper = mock(TTestMapper.class); ITTestService tests = mock(ITTestService.class); AdaptiveAdapter adapter = new AdaptiveAdapter(); TTest test = receivingTest(); when(mapper.selectReceivingTestIds()).thenReturn(List.of(1L)); when(mapper.selectTestByIdForUpdate(1L)).thenReturn(test); when(mapper.selectCollectorCode(9L)).thenReturn("collector-9"); when(tests.selectTestById(1L)).thenReturn(test); TestClock clock = new TestClock(START); return new Fixture(mapper, tests, adapter, test, clock, new TestDataReceptionServiceImpl(mapper, tests, adapter, clock)); } private static TTest receivingTest() { TTestChannelBinding binding = new TTestChannelBinding(); binding.setBindingId(2L); binding.setChannelId(3L); binding.setAdapterCode("neware76"); binding.setAdapterTimezoneId("Asia/Hong_Kong"); binding.setDevtype(21); binding.setDevid(36); binding.setSubdevid(1); binding.setChlid(2); TTestBattery battery = new TTestBattery(); battery.setTestBatteryId(4L); battery.setCollectorId(9L); battery.setBinding(binding); battery.setCollectorChannels(List.of()); TTest test = new TTest(); test.setTestId(1L); test.setTestStatus("RUNNING"); test.setReceptionStatus("RECEIVING"); test.setStartedAtUtc(Date.from(START)); test.setReceptionBoundaryAtUtc(Date.from(START)); test.setBatteries(List.of(battery)); return test; } private record Fixture(TTestMapper mapper, ITTestService tests, AdaptiveAdapter adapter, TTest test, TestClock clock, TestDataReceptionServiceImpl service) { } /** 只暴露水位与逐行读取的测试替身,并记录被问到的水位与逐行读取次数。 */ private static final class AdaptiveAdapter implements TestDataObservationAdapter { private SampleWatermark watermark; private List samples = new ArrayList<>(); private CabinetRealtime latest; private int watermarkReads; private final List sampleReads = new ArrayList<>(); @Override public String adapterCode() { return "neware76"; } @Override public AdapterCapabilities capabilities() { return AdapterCapabilities.observeOnly(); } @Override public List discoverChannels() { return List.of(); } @Override public Map observe(Set channels) { return Map.of(); } @Override public CabinetRealtime latestCabinet(ChannelIdentity channel) { return latest; } @Override public CollectorRealtime latestCollector(String adapterCode, String collectorCode) { return null; } @Override public List stepRecords(ChannelIdentity channel) { return List.of(); } @Override public List cycleRecords(ChannelIdentity channel) { return List.of(); } @Override public long persistenceIntervalMillis(ChannelIdentity channel) { return 60000L; } @Override public SampleWatermark currentWatermark(ChannelIdentity channel) { watermarkReads++; return watermark; } @Override public List samplesSince(ChannelIdentity channel, SampleWatermark since) { sampleReads.add(since); return List.copyOf(samples); } } /** 可推进的测试时钟:接收节奏与厂商写库节奏都按它推进。 */ private static final class TestClock extends Clock { private Instant now; private TestClock(Instant now) { this.now = now; } private void advance(Duration duration) { now = now.plus(duration); } @Override public ZoneId getZone() { return ZoneOffset.UTC; } @Override public Clock withZone(ZoneId zone) { return this; } @Override public Instant instant() { return now; } } }