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;
|
}
|
}
|