package com.ruoyi.system.service.impl; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; 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.math.BigDecimal; import java.time.Clock; import java.time.Instant; import java.time.ZoneOffset; import java.util.Date; import java.util.HexFormat; import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.springframework.boot.test.system.CapturedOutput; import org.springframework.boot.test.system.OutputCaptureExtension; import com.battery.device.simulator.BtsDevelopmentSimulator; import com.battery.device.simulator.BtsDevelopmentSimulator.BtsCycleRecord; import com.battery.device.simulator.BtsDevelopmentSimulator.BtsStepRecord; import com.battery.device.simulator.BtsSimulatorStateStore.BtsHistoryRecord; import com.battery.device.simulator.BtsSimulatorStateStore.CollectorRealtimeMeasurement; import com.battery.device.simulator.DevelopmentCollectorSimulation.CollectorRealtimeSnapshot; import com.ruoyi.system.domain.TTest; import com.ruoyi.system.domain.TTestBattery; import com.ruoyi.system.domain.TTestChannelBinding; import com.ruoyi.system.domain.TTestCollectorChannel; import com.ruoyi.system.domain.TTestCycleRecord; import com.ruoyi.system.domain.TTestRealtimeRecord; import com.ruoyi.system.domain.TTestStepRecord; import com.ruoyi.system.mapper.TTestMapper; import com.ruoyi.system.service.ITTestService; /** Ticket08 通过接收入口验证三类持久化事实。 */ @ExtendWith(OutputCaptureExtension.class) class TestDataReceptionServiceImplTest { private static final Instant CAPTURED_AT = Instant.parse("2026-09-02T04:01:00Z"); private static final Clock CLOCK = Clock.fixed(CAPTURED_AT, ZoneOffset.UTC); @Test void savesOneCombinedRealtimeRowForOneBatteryPollingSlot() { Fixture fixture = fixture(); when(fixture.simulator.btsRecords(1, 1)).thenReturn(List.of(cabinetRecord())); when(fixture.simulator.collectorRealtime(9)).thenReturn(collectorSnapshot(true, List.of( collector("S-01", 10L, "TEMPERATURE", "25.100", "℃"), collector("S-02", 10L, "POSITIVE_POTENTIAL", "1.620", "V"), collector("S-03", 10L, "NEGATIVE_POTENTIAL", "1.610", "V"), collector("S-04", 10L, "AMBIENT_TEMPERATURE", "23.400", "℃")))); when(fixture.mapper.insertRealtimeRecord(any())).thenReturn(1); fixture.service().captureDevelopmentRealtimeSnapshots(); ArgumentCaptor saved = ArgumentCaptor.forClass(TTestRealtimeRecord.class); verify(fixture.mapper).insertRealtimeRecord(saved.capture()); TTestRealtimeRecord row = saved.getValue(); assertEquals(1L, row.getTestId()); assertEquals(4L, row.getTestBatteryId()); assertEquals(2L, row.getBindingId()); assertEquals("dev-sim:22:1:1:1", row.getSourceIdentity()); assertEquals("cabinet:7:collector:10", row.getSourceRecordId()); assertEquals(Date.from(Instant.parse("2026-09-02T04:00:30Z")), row.getCabinetDeviceTimeUtc()); assertEquals(Date.from(Instant.parse("2026-09-02T04:00:40Z")), row.getCollectorDeviceTimeUtc()); assertEquals(new BigDecimal("25.100"), row.getTemperature()); assertEquals(new BigDecimal("1.620"), row.getPositivePotential()); assertEquals(new BigDecimal("1.610"), row.getNegativePotential()); assertTrue(row.getExtensionMeasurementsJson().contains("AMBIENT_TEMPERATURE")); assertTrue(!row.getExtensionMeasurementsJson().contains("POSITIVE_POTENTIAL")); } @Test void savesCollectorFieldsWhenCabinetSourceFails() { Fixture fixture = fixture(); when(fixture.simulator.btsRecords(1, 1)).thenThrow(new IllegalArgumentException("offline")); when(fixture.simulator.collectorRealtime(9)).thenReturn(collectorSnapshot(true, List.of(collector("S-01", 11L, "TEMPERATURE", "26.2", "℃")))); when(fixture.mapper.insertRealtimeRecord(any())).thenReturn(1); fixture.service().captureDevelopmentRealtimeSnapshots(); ArgumentCaptor saved = ArgumentCaptor.forClass(TTestRealtimeRecord.class); verify(fixture.mapper).insertRealtimeRecord(saved.capture()); assertNull(saved.getValue().getCabinetSequenceId()); assertNull(saved.getValue().getCabinetDevtype()); assertNull(saved.getValue().getCabinetAdapterTimezoneId()); assertNull(saved.getValue().getVoltageV()); assertEquals(11L, saved.getValue().getCollectorSequenceId()); assertEquals("collector:11", saved.getValue().getSourceRecordId()); assertEquals(new BigDecimal("26.2"), saved.getValue().getTemperature()); assertNull(saved.getValue().getPositivePotential()); } @Test void savesOnlyDeviceReturnedStepAndCycleRecords() { Fixture fixture = fixture(); Instant started = Instant.parse("2026-09-02T04:00:10Z"); Instant ended = Instant.parse("2026-09-02T04:00:50Z"); when(fixture.simulator.btsSteps(1, 1)).thenReturn(List.of(new BtsStepRecord( 1, 2, 2, "恒流充电", "cc", 41, started, ended, 40, 2.5, 8.4, 3.1, 3.4, 3.4, 0))); when(fixture.simulator.btsCycles(1, 1)).thenReturn(List.of(new BtsCycleRecord( "CYCLE-1-1-1", 1, 80, started, ended, 2.5, 2.4, 8.4, 8.0, 96))); when(fixture.mapper.insertStepRecord(any())).thenReturn(1); when(fixture.mapper.insertCycleRecord(any())).thenReturn(1); fixture.service().captureDevelopmentCabinetRecords(); ArgumentCaptor step = ArgumentCaptor.forClass(TTestStepRecord.class); ArgumentCaptor cycle = ArgumentCaptor.forClass(TTestCycleRecord.class); verify(fixture.mapper).insertStepRecord(step.capture()); verify(fixture.mapper).insertCycleRecord(cycle.capture()); assertEquals("fallback:2026-09-02T04:00:50Z:1:2", step.getValue().getDedupeKey()); assertEquals("CYCLE-1-1-1", cycle.getValue().getSourceRecordId()); assertEquals("record:CYCLE-1-1-1", cycle.getValue().getDedupeKey()); assertEquals(Date.from(CAPTURED_AT), step.getValue().getServerReceivedAtUtc()); assertEquals(Date.from(CAPTURED_AT), cycle.getValue().getServerReceivedAtUtc()); } @Test void ignoresSameRealtimeContentOnRepeatedReception() { Fixture fixture = fixture(); when(fixture.simulator.btsRecords(1, 1)).thenReturn(List.of(cabinetRecord())); when(fixture.simulator.collectorRealtime(9)).thenReturn(collectorSnapshot(false, List.of())); when(fixture.mapper.insertRealtimeRecord(any())).thenReturn(1); fixture.service().captureDevelopmentRealtimeSnapshots(); ArgumentCaptor saved = ArgumentCaptor.forClass(TTestRealtimeRecord.class); verify(fixture.mapper).insertRealtimeRecord(saved.capture()); when(fixture.mapper.selectRealtimeRecordContentHash(eq(1L), eq(4L), any(Date.class))) .thenReturn(HexFormat.of().formatHex(saved.getValue().getContentSha256())); fixture.service().captureDevelopmentRealtimeSnapshots(); verify(fixture.mapper, times(1)).insertRealtimeRecord(any()); } @Test void rejectsConflictingRealtimeContentAndWritesApplicationLog(CapturedOutput output) { Fixture fixture = fixture(); when(fixture.simulator.btsRecords(1, 1)).thenReturn(List.of(cabinetRecord())); when(fixture.simulator.collectorRealtime(9)).thenReturn(collectorSnapshot(false, List.of())); when(fixture.mapper.selectRealtimeRecordContentHash(eq(1L), eq(4L), any(Date.class))) .thenReturn("different-content"); fixture.service().captureDevelopmentRealtimeSnapshots(); verify(fixture.mapper, never()).insertRealtimeRecord(any()); assertTrue(output.getOut().contains("实时采样内容冲突")); } private static Fixture fixture() { TTestMapper mapper = mock(TTestMapper.class); ITTestService tests = mock(ITTestService.class); BtsDevelopmentSimulator simulator = mock(BtsDevelopmentSimulator.class); TTest test = receivingTest(); when(mapper.selectReceivingTestIds()).thenReturn(List.of(1L)); when(mapper.selectTestByIdForUpdate(1L)).thenReturn(test); when(tests.selectTestById(1L)).thenReturn(test); when(simulator.persistenceIntervalMillis(1, 1)).thenReturn(60000L); return new Fixture(mapper, tests, simulator); } private static TTest receivingTest() { TTestChannelBinding binding = new TTestChannelBinding(); binding.setBindingId(2L); binding.setChannelId(3L); binding.setAdapterCode("dev-sim"); binding.setAdapterTimezoneId("Asia/Hong_Kong"); binding.setDevtype(22); binding.setDevid(1); binding.setSubdevid(1); binding.setChlid(1); TTestBattery battery = new TTestBattery(); battery.setTestBatteryId(4L); battery.setCollectorId(9L); battery.setBinding(binding); battery.setCollectorChannels(List.of( mapping("TEMPERATURE", "S-01"), mapping("POSITIVE_POTENTIAL", "S-02"), mapping("NEGATIVE_POTENTIAL", "S-03"), mapping("AMBIENT_TEMPERATURE", "S-04"))); TTest test = new TTest(); test.setTestId(1L); test.setTestStatus("RUNNING"); test.setReceptionStatus("RECEIVING"); test.setStartedAtUtc(Date.from(Instant.parse("2026-09-02T04:00:00Z"))); test.setReceptionBoundaryAtUtc(Date.from(Instant.parse("2026-09-02T04:00:00Z"))); test.setBatteries(List.of(battery)); return test; } private static TTestCollectorChannel mapping(String metric, String channel) { TTestCollectorChannel mapping = new TTestCollectorChannel(); mapping.setMeasurementCode(metric); mapping.setChannelCode(channel); return mapping; } private static BtsHistoryRecord cabinetRecord() { Instant deviceTime = Instant.parse("2026-09-02T04:00:30Z"); return new BtsHistoryRecord(7L, deviceTime, 1, 3.2, 2.5, 8.4, 1, 2, "恒流充电", "cc", Instant.parse("2026-09-02T04:00:35Z")); } private static CollectorRealtimeMeasurement collector(String channel, long sequenceId, String metric, String value, String unit) { return new CollectorRealtimeMeasurement("dev-sim", "collector-9", channel, sequenceId, Instant.parse("2026-09-02T04:00:40Z"), "2026-09-02 12:00:40.000", Instant.parse("2026-09-02T04:00:50Z"), metric, new BigDecimal(value), unit); } private static CollectorRealtimeSnapshot collectorSnapshot(boolean online, List measurements) { return new CollectorRealtimeSnapshot("collector-9", online, 1000, measurements); } private record Fixture(TTestMapper mapper, ITTestService tests, BtsDevelopmentSimulator simulator) { TestDataReceptionServiceImpl service() { return new TestDataReceptionServiceImpl(mapper, tests, simulator, CLOCK); } } }