package com.battery.business.web.device; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import java.lang.reflect.Method; import java.math.BigDecimal; import java.net.InetAddress; import java.time.Instant; import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.server.ResponseStatusException; import com.ruoyi.common.annotation.Anonymous; import com.battery.device.simulator.BtsDevelopmentSimulator; import com.battery.device.api.discovery.DeviceDiscoveryCandidateStore.CabinetCandidate; import com.battery.device.api.discovery.DeviceDiscoveryCandidateStore.ChannelCandidate; import com.battery.device.api.discovery.InMemoryDeviceDiscoveryCandidateStore; import com.battery.device.api.model.ChannelIdentity; import com.battery.device.api.model.DeviceWorkStatus; import com.battery.business.runtime.BtsDevelopmentSimulatorWebConfig; import com.battery.business.runtime.DevelopmentCollectorStatusSynchronizer; class BtsDevelopmentSimulatorControllerTest { @Test void invalidChannelConfigurationDoesNotReplaceSavedValues() throws Exception { try (var simulator = new BtsDevelopmentSimulator(0)) { var mvc = org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller(simulator)).build(); var before = simulator.snapshot().devices().get(0).channels().get(0).configuration(); String valid = com.alibaba.fastjson2.JSON.toJSONString(before); for (String property : List.of("stepNo", "cycleNo", "currentA", "steps")) { var invalid = com.alibaba.fastjson2.JSON.parseObject(valid); invalid.put(property, switch (property) { case "stepNo" -> 2; case "cycleNo" -> 0; case "steps" -> List.of(); default -> null; }); mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put( "/dev/bts-simulator/devices/1/channels/1/configuration") .contentType("application/json").content(invalid.toJSONString())) .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isBadRequest()); assertEquals(before, simulator.snapshot().devices().get(0).channels().get(0).configuration()); } } } @Test void channelConfigurationChangesMeasurementsAndPlanOnlyForSelectedChannel() throws Exception { try (var simulator = new BtsDevelopmentSimulator(0)) { var mvc = org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller(simulator)).build(); mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put( "/dev/bts-simulator/devices/1/channels/1/configuration") .contentType("application/json").content(""" {"currentA":-2.5,"voltageV":4.125,"capacityAh":8.2,"energyWh":33.8, "cycleNo":2,"stepNo":2,"plannedCycleCount":3,"totalTimeSeconds":120,"relativeTimeSeconds":30, "steps":[{"name":"充电","stepType":"cc","durationSeconds":60,"voltageV":4.2}, {"name":"放电","stepType":"dc","durationSeconds":90,"voltageV":3.0}]} """)) .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk()); var identity = simulator.discoverChannels().get(0).identity(); var actual = simulator.latestCabinet(identity); assertEquals(-2.5, actual.currentA()); assertEquals(4.125, actual.voltageV()); assertEquals(8.2, actual.capacityAh()); assertEquals(33.8, actual.energyWh()); assertEquals(2, actual.cycleNo()); assertEquals(2, actual.stepNo()); assertEquals("dc", actual.stepType()); assertEquals("放电", actual.stepName()); assertEquals(2, simulator.testPlan(identity).rows().size()); assertEquals(3, simulator.testPlan(identity).plannedCycleCount()); var other = simulator.discoverChannels().get(1).identity(); assertEquals(1, simulator.testPlan(other).rows().size()); assertEquals(1, simulator.latestCabinet(other).stepNo()); } } @Test void restChangesSoftwareAndCollectorChannelFactsAndRetiresPlayback() throws Exception { try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0)) { var mvc = org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller(simulator)).build(); mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put("/dev/bts-simulator/software") .contentType("application/json").content("{\"accessible\":false}")) .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk()); assertFalse(simulator.snapshot().softwareAccessible()); mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put( "/dev/bts-simulator/collectors/1/channels/S-01/status") .contentType("application/json").content("{\"workStatus\":\"PROTECTED\"}")) .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk()); assertEquals(DeviceWorkStatus.PROTECTED, simulator.snapshot().collectors().get(0).channels().get(0).workStatus()); for (String path : List.of("/devices/1/channels/1/playback/play", "/devices/1/channels/1/playback/next-step")) mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/dev/bts-simulator" + path)) .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isNotFound()); for (String path : List.of("/collectors/1/history", "/devices/1/channels/1/records")) mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/dev/bts-simulator" + path)) .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isNotFound()); } } @Test void controllerUpdatesDeviceAndChannelState() throws Exception { try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0)) { BtsDevelopmentSimulatorController controller = controller(simulator); controller.updateDevice(1, new BtsDevelopmentSimulatorController.DeviceStateCommand("127.0.0.2", 4, false)); controller.updateChannel(1, 3, new BtsDevelopmentSimulatorController.ChannelStateCommand(true, DeviceWorkStatus.PAUSED)); assertFalse(controller.state().devices().get(0).online()); assertEquals(DeviceWorkStatus.PAUSED, controller.state().devices().get(0).channels().get(2).workStatus()); } } @Test void controllerAddsUpdatesAndDeletesVirtualDevices() throws Exception { try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0)) { BtsDevelopmentSimulatorController controller = controller(simulator); var created = controller.createDevice( new BtsDevelopmentSimulatorController.CreateDeviceCommand("127.0.0.8", 6)); int deviceId = created.devices().stream() .filter(device -> device.ip().equals("127.0.0.8")) .findFirst().orElseThrow().deviceId(); controller.updateDevice(deviceId, new BtsDevelopmentSimulatorController.DeviceStateCommand("127.0.0.9", 3, false)); assertEquals(2, controller.state().devices().size()); assertEquals(3, controller.state().devices().stream() .filter(device -> device.deviceId() == deviceId) .findFirst().orElseThrow().channels().size()); controller.deleteDevice(deviceId); assertEquals(1, controller.state().devices().size()); } } @Test void controllerCreatesNewareDeviceFromExplicitModelProfile() throws Exception { try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0)) { BtsDevelopmentSimulatorController controller = controller(simulator); var created = controller.createDevice(new BtsDevelopmentSimulatorController.CreateDeviceCommand( "127.0.0.8", 0, "CT-4008T-5V6A-S1-F")); var device = created.devices().stream().filter(item -> item.ip().equals("127.0.0.8")) .findFirst().orElseThrow(); assertEquals(8, device.channels().size()); assertEquals("新威", device.profile().manufacturer()); assertEquals("CT-4008T-5V6A-S1-F", device.profile().model()); assertEquals(100, device.profile().sampleIntervalMs()); } } @Test void controllerConfiguresCollectorsSeparatelyFromCabinets() throws Exception { try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0)) { BtsDevelopmentSimulatorController controller = controller(simulator); var created = controller.createCollector( new BtsDevelopmentSimulatorController.CreateCollectorCommand("127.0.1.8", 6)); int collectorId = created.collectors().stream() .filter(collector -> collector.ip().equals("127.0.1.8")) .findFirst().orElseThrow().collectorId(); controller.updateCollector(collectorId, new BtsDevelopmentSimulatorController.CollectorStateCommand("127.0.1.9", 3, false)); assertEquals(1, controller.state().devices().size()); assertEquals(2, controller.state().collectors().size()); assertFalse(controller.state().collectors().stream() .filter(collector -> collector.collectorId() == collectorId) .findFirst().orElseThrow().online()); controller.deleteCollector(collectorId); assertEquals(1, controller.state().collectors().size()); assertEquals(1, controller.state().devices().size()); } } @Test void controllerExposesOnlyCurrentCollectorMeasurements() throws Exception { try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0)) { BtsDevelopmentSimulatorController controller = controller(simulator); controller.configureCollectorChannel(1, "S-01", new BtsDevelopmentSimulatorController.CollectorChannelCommand(List.of( new BtsDevelopmentSimulatorController.CollectorMetricCommand( "temperature", new BigDecimal("27.2"), "℃"), new BtsDevelopmentSimulatorController.CollectorMetricCommand( "custom_voltage", new BigDecimal("1.234"), "V")))); var realtime = controller.collectorRealtime(1); assertTrue(realtime.online()); assertTrue(realtime.measurements().stream().anyMatch(item -> item.metricCode().equals("custom_voltage"))); } } @Test void controllerChangesOnlineFactsWithoutResettingChannelStatus() throws Exception { try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0)) { BtsDevelopmentSimulatorController controller = controller(simulator); simulator.updateChannel(1, 1, true, DeviceWorkStatus.PAUSED); var poweredOff = controller.setPower( new BtsDevelopmentSimulatorController.SimulatorPowerCommand(false)); assertTrue(poweredOff.devices().stream().noneMatch(device -> device.online())); assertTrue(poweredOff.collectors().stream().noneMatch(collector -> collector.online())); assertEquals(DeviceWorkStatus.PAUSED, poweredOff.devices().get(0).channels().get(0).workStatus()); var poweredOn = controller.setPower( new BtsDevelopmentSimulatorController.SimulatorPowerCommand(true)); assertTrue(poweredOn.devices().stream().allMatch(device -> device.online() && device.channels().stream().allMatch(channel -> channel.online() ))); assertTrue(poweredOn.collectors().stream().allMatch(collector -> collector.online())); } } @Test void powerChangeImmediatelyReconcilesCollectorStatus() throws Exception { try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0)) { DevelopmentCollectorStatusSynchronizer synchronizer = mock(DevelopmentCollectorStatusSynchronizer.class); BtsDevelopmentSimulatorController controller = new BtsDevelopmentSimulatorController(simulator, new InMemoryDeviceDiscoveryCandidateStore(), synchronizer); controller.setPower(new BtsDevelopmentSimulatorController.SimulatorPowerCommand(true)); verify(synchronizer).synchronizeOnlineStatus(); } } @Test void simulatorPowerCommandRequiresExplicitState() throws Exception { try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0)) { BtsDevelopmentSimulatorController controller = controller(simulator); ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> controller.setPower(new BtsDevelopmentSimulatorController.SimulatorPowerCommand(null))); assertEquals(400, exception.getStatusCode().value()); assertTrue(controller.state().collectors().get(0).online()); } } @Test void virtualIpMustBeUniqueAcrossCabinetsAndCollectors() throws Exception { try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0)) { BtsDevelopmentSimulatorController controller = controller(simulator); assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> controller.createCollector( new BtsDevelopmentSimulatorController.CreateCollectorCommand("127.0.0.2", 4))); assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> controller.createDevice( new BtsDevelopmentSimulatorController.CreateDeviceCommand("127.0.1.2", 4))); } } @Test void developmentControlBoundaryOnlyAllowsLoopbackClients() throws Exception { var interceptor = new BtsDevelopmentSimulatorWebConfig.LocalOnlyInterceptor(); MockHttpServletResponse localResponse = new MockHttpServletResponse(); MockHttpServletRequest localRequest = requestFrom(InetAddress.getLoopbackAddress().getHostAddress()); MockHttpServletResponse remoteResponse = new MockHttpServletResponse(); MockHttpServletRequest remoteRequest = requestFrom("192.168.1.20"); assertTrue(interceptor.preHandle(localRequest, localResponse, new Object())); assertFalse(interceptor.preHandle(remoteRequest, remoteResponse, new Object())); assertEquals(403, remoteResponse.getStatus()); assertTrue(BtsDevelopmentSimulatorController.class.isAnnotationPresent(Anonymous.class)); } @Test void exposesStaticControlPageThroughAnonymousHandler() throws Exception { Method page = BtsDevelopmentSimulatorController.class.getMethod("page"); assertArrayEquals(new String[] { "/dev/bts-simulator.html" }, page.getAnnotation(GetMapping.class).value()); assertTrue(BtsDevelopmentSimulatorController.class.isAnnotationPresent(Anonymous.class)); } @Test void resettingSimulatorRemovesExistingDevelopmentCandidates() throws Exception { Instant now = Instant.parse("2026-08-27T00:00:00Z"); InMemoryDeviceDiscoveryCandidateStore candidateStore = new InMemoryDeviceDiscoveryCandidateStore(); candidateStore.recordCabinets(List.of(new CabinetCandidate("dev-sim", null, "dev-sim-22-8", 22, 8, "127.0.0.8", null, "旧模拟柜", null, null, List.of(new ChannelCandidate(new ChannelIdentity("dev-sim", 22, 8, 1, 1), true, DeviceWorkStatus.STOPPED, now)), now, now.plusSeconds(30)))); try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0)) { BtsDevelopmentSimulatorController controller = new BtsDevelopmentSimulatorController(simulator, candidateStore); controller.reset(); assertEquals(List.of(), candidateStore.snapshot(now).cabinets()); } } private BtsDevelopmentSimulatorController controller(BtsDevelopmentSimulator simulator) { return new BtsDevelopmentSimulatorController(simulator, new InMemoryDeviceDiscoveryCandidateStore()); } private MockHttpServletRequest requestFrom(String remoteAddress) { MockHttpServletRequest request = new MockHttpServletRequest("GET", "/dev/bts-simulator/state"); request.setRemoteAddr(remoteAddress); return request; } }