package com.ruoyi.system.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.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.ruoyi.device.bts.BtsDevelopmentSimulator; import com.ruoyi.device.discovery.DeviceDiscoveryCandidateStore; import com.ruoyi.device.model.ChannelIdentity; import com.ruoyi.device.model.DeviceWorkStatus; import com.ruoyi.device.model.RealtimeSnapshot; import com.ruoyi.system.domain.TTest; import com.ruoyi.system.domain.TTestBattery; import com.ruoyi.system.domain.TTestChannelBinding; import com.ruoyi.system.service.ITTestService; import com.ruoyi.system.service.ITestLiveService.LiveState; /** 实时流客户端断连时应结束连接而不触发全局异常处理。 */ class TTestLiveServiceImplTest { @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")); } @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()); } 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) { DeviceDiscoveryCandidateStore candidates = mock(DeviceDiscoveryCandidateStore.class); BtsDevelopmentSimulator simulator = mock(BtsDevelopmentSimulator.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 BtsDevelopmentSimulator.BtsTestSnapshot(1, 1, 1, List.of()); when(simulator.btsTest(anyInt(), anyInt())).thenReturn(plan); when(simulator.btsTest(anyInt(), anyInt(), anyInt())).thenReturn(plan); return new CurrentLiveService(tests, candidates, simulator); } @Test void initialWriteFailureShouldCompleteStreamWithoutErrorDispatch() { RecordingEmitter emitter = new RecordingEmitter(0); TTestLiveServiceImpl service = new TestableLiveService(emitter); try { service.stream(4L); } finally { service.shutdown(); } assertTrue(emitter.completed); assertFalse(emitter.completedWithError); } @Test void scheduledWriteFailureShouldCompleteStreamWithoutErrorDispatch() { RecordingEmitter emitter = new RecordingEmitter(6); TTestLiveServiceImpl service = new TestableLiveService(emitter); try { service.stream(4L); Awaitility.await().atMost(Duration.ofSeconds(5)).until(() -> emitter.completed); } finally { service.shutdown(); } assertFalse(emitter.completedWithError); } private static final class TestableLiveService extends TTestLiveServiceImpl { private final RecordingEmitter emitter; private TestableLiveService(RecordingEmitter emitter) { super(mock(ITTestService.class), mock(DeviceDiscoveryCandidateStore.class), mock(BtsDevelopmentSimulator.class)); this.emitter = emitter; } @Override protected SseEmitter createEmitter() { return emitter; } @Override public LiveState current(Long testId) { return new LiveState(testId, "RUNNING", "RECEIVING", Instant.now(), List.of(), List.of()); } } private static final class CurrentLiveService extends TTestLiveServiceImpl { private CurrentLiveService(ITTestService tests, DeviceDiscoveryCandidateStore candidates, BtsDevelopmentSimulator simulator) { super(tests, candidates, simulator); } } 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; } } }