package com.battery.business.service.impl; import static org.junit.jupiter.api.Assertions.assertFalse; 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.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.io.IOException; import java.math.BigDecimal; import java.time.Duration; import java.time.Instant; import java.util.List; import org.awaitility.Awaitility; import org.junit.jupiter.api.Test; import org.springframework.web.context.request.async.AsyncRequestNotUsableException; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import com.battery.device.api.TestDataObservationAdapter; import com.battery.business.web.TestLiveStream; import com.battery.business.service.ITestLiveService; import com.battery.device.api.discovery.DeviceDiscoveryCandidateStore; import com.battery.device.api.model.ChannelIdentity; import com.battery.device.api.model.DeviceWorkStatus; import com.battery.device.api.model.RealtimeSnapshot; import com.battery.business.domain.TTest; import com.battery.business.domain.TTestBattery; import com.battery.business.domain.TTestChannelBinding; import com.battery.business.domain.TTestWarningException; import com.battery.business.mapper.TTestMapper; import com.battery.business.service.ITTestService; import com.battery.business.service.ITestLiveService.LiveState; /** 实时流客户端断连时应结束连接而不触发全局异常处理。 */ class TTestLiveServiceImplTest { @Test void nonReceivingConsoleReadsCollectorStatusWithoutRequestingMeasurements() throws Exception { try (var simulator = new com.battery.device.simulator.BtsDevelopmentSimulator(0)) { TTest test = test("RECEIVING"); test.getBatteries().get(0).setCollectorId(7L); var tests = mock(ITTestService.class); when(tests.selectTestById(10L)).thenReturn(test); var mapper = mock(TTestMapper.class); when(mapper.selectCollectorCode(7L)).thenReturn("dev-sim-collector-1"); var candidates = new com.battery.device.api.discovery.InMemoryDeviceDiscoveryCandidateStore(); Instant now = Instant.now(); candidates.replaceCollectors("dev-sim", List.of(new DeviceDiscoveryCandidateStore.CollectorCandidate( "dev-sim", "dev-sim-collector-1", "127.0.1.2", null, "模拟采集器", null, null, 4, true, now, now.plusSeconds(30)))); var service = new TTestLiveServiceImpl(tests, candidates, simulator, mapper); service.current(10L); var received = simulator.snapshot().recentRequests(); assertTrue(received.stream().anyMatch(item -> item.command().equals("collector-realtime"))); for (String status : List.of("PAUSED", "ENDED", "NOT_STARTED")) { test.setReceptionStatus(status); var source = service.current(10L).sources().stream() .filter(item -> item.sourceType().equals("COLLECTOR")).findFirst().orElseThrow(); assertEquals("ONLINE", source.deviceStatus()); assertTrue(source.latestSnapshot().isEmpty()); assertEquals(received, simulator.snapshot().recentRequests()); } } } @Test void notStartedShouldNotExposePreTestDeviceSnapshot() { Instant now = Instant.parse("2026-09-02T06:00:00Z"); TTest test = test("NOT_STARTED"); TTestLiveServiceImpl service = liveService(test, now); LiveState state = service.current(10L); assertTrue(state.sources().stream().allMatch(source -> source.latestSnapshot().isEmpty())); } @Test void receivingShouldExposeBtsCoreMeasurements() { Instant now = Instant.parse("2026-09-02T06:00:00Z"); TTest test = test("RECEIVING"); TTestLiveServiceImpl service = liveService(test, now); LiveState state = service.current(10L); var snapshot = state.sources().stream().filter(source -> "CABINET_CHANNEL".equals(source.sourceType())) .findFirst().orElseThrow().latestSnapshot(); assertEquals(new BigDecimal("3.700"), snapshot.get("voltageV")); assertEquals(new BigDecimal("1.200"), snapshot.get("currentA")); assertEquals(new BigDecimal("2.500"), snapshot.get("capacityAh")); assertEquals(now.toEpochMilli(), snapshot.get("observedAt")); } @Test void onlineCabinetWithStoppedWorkStatusShouldRemainOnlineAndNotRaiseSourceAlarm() { Instant now = Instant.parse("2026-09-02T06:00:00Z"); TTestLiveServiceImpl service = liveService(test("RECEIVING"), now, true, DeviceWorkStatus.STOPPED); LiveState state = service.current(10L); var cabinet = state.sources().stream().filter(source -> "CABINET_CHANNEL".equals(source.sourceType())) .findFirst().orElseThrow(); assertEquals("ONLINE", cabinet.deviceStatus()); assertTrue(state.exceptions().isEmpty()); } @Test void pausedShouldNotExposeUnavailableSourceAsAlarm() { Instant now = Instant.parse("2026-09-02T06:00:00Z"); TTest test = test("PAUSED"); TTestLiveServiceImpl service = liveService(test, now, false); LiveState state = service.current(10L); assertTrue(state.exceptions().isEmpty()); } @Test void completedTestShouldNotExposeOpenWarningInLiveAlarm() { Instant now = Instant.parse("2026-09-02T06:00:00Z"); TTestMapper mapper = mock(TTestMapper.class); TTestWarningException warning = new TTestWarningException(); warning.setExceptionId(1L); warning.setSourceType("COLLECTOR"); warning.setMetricCode("TEMPERATURE"); warning.setAbnormalValue(new BigDecimal("25.3")); warning.setUnit("℃"); warning.setExceptionStatus("OPEN"); when(mapper.selectWarningExceptions(10L, "OPEN")).thenReturn(List.of(warning)); TTestLiveServiceImpl service = liveService(test("ENDED"), now, true, DeviceWorkStatus.STOPPED, mapper); LiveState state = service.current(10L); assertTrue(state.exceptions().isEmpty()); verify(mapper, never()).selectWarningExceptions(10L, "OPEN"); } private static TTest test(String receptionStatus) { TTest test = new TTest(); test.setTestId(10L); test.setTestStatus("RECEIVING".equals(receptionStatus) ? "RUNNING" : "WAITING"); test.setReceptionStatus(receptionStatus); TTestBattery battery = new TTestBattery(); battery.setBatteryNo("B-01"); TTestChannelBinding binding = new TTestChannelBinding(); binding.setChannelId(1L); binding.setAdapterCode("dev-sim"); binding.setDevtype(1); binding.setDevid(1); binding.setSubdevid(0); binding.setChlid(1); battery.setBinding(binding); test.setBatteries(List.of(battery)); return test; } private static TTestLiveServiceImpl liveService(TTest test, Instant now) { return liveService(test, now, true); } private static TTestLiveServiceImpl liveService(TTest test, Instant now, boolean online) { return liveService(test, now, online, DeviceWorkStatus.WORKING); } private static TTestLiveServiceImpl liveService(TTest test, Instant now, boolean online, DeviceWorkStatus workStatus) { return liveService(test, now, online, workStatus, null); } private static TTestLiveServiceImpl liveService(TTest test, Instant now, boolean online, DeviceWorkStatus workStatus, TTestMapper mapper) { DeviceDiscoveryCandidateStore candidates = mock(DeviceDiscoveryCandidateStore.class); TestDataObservationAdapter simulator = mock(TestDataObservationAdapter.class); ITTestService tests = mock(ITTestService.class); when(tests.selectTestById(10L)).thenReturn(test); ChannelIdentity identity = new ChannelIdentity("dev-sim", 1, 1, 0, 1); RealtimeSnapshot snapshot = new RealtimeSnapshot(identity, workStatus, 1, 1, "cc", null, new BigDecimal("1.200"), new BigDecimal("3.700"), new BigDecimal("2.500"), new BigDecimal("9.250"), 12L, 12L, "0", now, java.util.Map.of()); DeviceDiscoveryCandidateStore.ChannelCandidate channel = new DeviceDiscoveryCandidateStore.ChannelCandidate( identity, online, workStatus, now, snapshot); DeviceDiscoveryCandidateStore.CabinetCandidate cabinet = new DeviceDiscoveryCandidateStore.CabinetCandidate( "dev-sim", 1L, "dev-sim", 1, 1, "127.0.0.2", "mac", "cabinet", "vendor", "model", List.of(channel), now, now.plusSeconds(60)); when(candidates.snapshot(any(Instant.class))).thenReturn( new DeviceDiscoveryCandidateStore.CandidateSnapshot(List.of(cabinet), List.of())); var plan = new TestDataObservationAdapter.TestPlan(1, 1, 1, List.of()); when(simulator.testPlan(any(ChannelIdentity.class))).thenReturn(plan); return new CurrentLiveService(tests, candidates, simulator, mapper); } @Test void initialWriteFailureShouldCompleteStreamWithoutErrorDispatch() { RecordingEmitter emitter = new RecordingEmitter(0); TestLiveStream service = new TestableLiveService(emitter); try { service.stream(4L); } finally { service.close(); } assertTrue(emitter.completed); assertFalse(emitter.completedWithError); } @Test void scheduledWriteFailureShouldCompleteStreamWithoutErrorDispatch() { RecordingEmitter emitter = new RecordingEmitter(6); TestLiveStream service = new TestableLiveService(emitter); try { service.stream(4L); Awaitility.await().atMost(Duration.ofSeconds(5)).until(() -> emitter.completed); } finally { service.close(); } assertFalse(emitter.completedWithError); } private static final class TestableLiveService extends TestLiveStream { private final RecordingEmitter emitter; private TestableLiveService(RecordingEmitter emitter) { super(testLive()); this.emitter = emitter; } @Override protected SseEmitter createEmitter() { return emitter; } private static ITestLiveService testLive() { ITestLiveService live = mock(ITestLiveService.class); when(live.current(any())).thenReturn(new LiveState(4L, "RUNNING", "RECEIVING", Instant.now(), List.of(), List.of())); return live; } } private static final class CurrentLiveService extends TTestLiveServiceImpl { private CurrentLiveService(ITTestService tests, DeviceDiscoveryCandidateStore candidates, TestDataObservationAdapter simulator, TTestMapper mapper) { super(tests, candidates, simulator, mapper); } } private static final class RecordingEmitter extends SseEmitter { private final int successfulWrites; private int writes; private volatile boolean completed; private volatile boolean completedWithError; private RecordingEmitter(int successfulWrites) { this.successfulWrites = successfulWrites; } @Override public void send(SseEventBuilder builder) throws IOException { if (writes++ >= successfulWrites) throw new AsyncRequestNotUsableException("client disconnected", new IOException("connection closed")); } @Override public synchronized void complete() { completed = true; } @Override public synchronized void completeWithError(Throwable error) { completedWithError = true; } } }