package com.battery.device.contract;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
import com.alibaba.fastjson2.JSON;
import com.battery.device.api.DeviceAdapter;
import com.battery.device.simulator.BtsDevelopmentSimulator;
import com.battery.device.bts.BtsDeviceAdapter;
import com.battery.device.bts.BtsXmlCodec;
import com.battery.device.bts.BtsTcpTransport;
import com.battery.device.simulator.ScriptedBtsTcpSimulator;
import com.battery.device.api.model.DetectedChannel;
import com.battery.device.api.model.DeviceWorkStatus;
import com.battery.device.simulator.BtsSimulatorStateStore;
import com.battery.device.simulator.BtsSimulatorStateStore.ChannelState;
import com.battery.device.simulator.BtsSimulatorStateStore.CollectorChannelState;
import com.battery.device.simulator.BtsSimulatorStateStore.CollectorMetricState;
import com.battery.device.simulator.BtsSimulatorStateStore.CollectorState;
import com.battery.device.simulator.BtsSimulatorStateStore.DeviceState;
import com.battery.device.simulator.BtsSimulatorStateStore.SimulatorState;
import com.battery.device.simulator.BtsSimulatorStateStore.RecentRequest;
import com.battery.device.simulator.InMemoryBtsSimulatorStateStore;
class SimulatorPublicContractTest
{
@Test
void collectorRealtimeDropsMeasurementsRemovedFromCurrentConfiguration() throws Exception
{
Instant startedAt = Instant.parse("2026-08-29T04:00:00Z");
InMemoryBtsSimulatorStateStore store = new InMemoryBtsSimulatorStateStore();
try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0, store))
{
simulator.collectorRealtime(1);
assertEquals(12, simulator.snapshot().collectors().get(0).latestMeasurements().size());
simulator.configureCollectorChannel(1, "S-01", List.of(
new CollectorMetricState("temperature", new BigDecimal("27.0"), "℃")));
simulator.updateCollector(1, "127.0.1.2", 2, true);
simulator.collectorRealtime(1);
var snapshot = simulator.snapshot().collectors().get(0);
assertEquals(4, snapshot.latestMeasurements().size());
assertTrue(snapshot.latestMeasurements().stream()
.noneMatch(item -> item.channelCode().equals("S-03")
|| item.channelCode().equals("S-04")
|| item.channelCode().equals("S-01")
&& item.metricCode().equals("positive_potential")));
}
try (BtsDevelopmentSimulator restored = new BtsDevelopmentSimulator(0, store))
{
assertEquals(4, restored.collectorRealtime(1).measurements().size());
}
}
@Test
void collectorRejectsMetricWhitespaceThatWouldBreakRestore() throws Exception
{
try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
{
assertThrows(IllegalArgumentException.class, () -> simulator.configureCollectorChannel(1, "S-01",
List.of(new CollectorMetricState("temperature ", new BigDecimal("25.0"), "℃"))));
assertThrows(IllegalArgumentException.class, () -> simulator.configureCollectorChannel(1, "S-01",
List.of(new CollectorMetricState("temperature", new BigDecimal("25.0"), " ℃"))));
}
}
@Test
void simulatorIsReusableOutsideTheBtsPackage() throws Exception
{
try (ScriptedBtsTcpSimulator simulator = new ScriptedBtsTcpSimulator();
BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", simulator.port(),
Duration.ofSeconds(1), Duration.ofSeconds(1)))
{
simulator.enqueue("getdevinfo_resp");
assertEquals("getdevinfo_resp",
transport.exchange("getdevinfo"));
assertEquals(1, simulator.requests().size());
}
}
@Test
void simulatorPreservesRequestEndingWithDelimiterPrefix() throws Exception
{
try (ScriptedBtsTcpSimulator simulator = new ScriptedBtsTcpSimulator();
BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", simulator.port(),
Duration.ofSeconds(1), Duration.ofSeconds(1)))
{
simulator.enqueue("ok");
assertEquals("ok", transport.exchange("payload#"));
assertEquals("payload#", simulator.requests().get(0));
}
}
@Test
void developmentSimulatorSupportsRepeatedDiscoveryAndObservation() throws Exception
{
try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0);
BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", simulator.port(),
Duration.ofSeconds(1), Duration.ofSeconds(1));
DeviceAdapter adapter = new BtsDeviceAdapter("development-bts", transport,
new BtsXmlCodec("development-bts", ZoneId.of("Asia/Hong_Kong"))))
{
var firstDiscovery = adapter.discoverChannels();
var secondDiscovery = adapter.discoverChannels();
assertEquals(4, firstDiscovery.size());
assertEquals(firstDiscovery, secondDiscovery);
assertEquals("开发模拟器", firstDiscovery.get(0).deviceMetadata().manufacturer());
assertEquals("BTS Virtual Cabinet", firstDiscovery.get(0).deviceMetadata().model());
assertEquals(1, firstDiscovery.stream().map(channel -> channel.identity().devid()).distinct().count());
assertEquals(4, firstDiscovery.stream().filter(channel -> channel.available()).count());
Set channels = firstDiscovery.stream()
.map(channel -> channel.identity())
.collect(java.util.stream.Collectors.toSet());
var firstObservation = adapter.inquire(channels);
var secondObservation = adapter.inquire(channels);
assertEquals(4, firstObservation.size());
assertEquals(DeviceWorkStatus.STOPPED, firstObservation.values().iterator().next().workStatus());
assertEquals(firstObservation.keySet(), secondObservation.keySet());
}
}
@Test
void developmentSimulatorSupportsNewareProfilesAndDynamicObservation() throws Exception
{
try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0);
BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", simulator.port(),
Duration.ofSeconds(1), Duration.ofSeconds(1));
DeviceAdapter adapter = new BtsDeviceAdapter("dev-sim", transport,
new BtsXmlCodec("dev-sim", ZoneId.of("Asia/Hong_Kong"))))
{
int tDevice = simulator.addNewareDevice("127.0.0.8", "CT-4008T-5V6A-S1-F");
int tnDevice = simulator.addNewareDevice("127.0.0.9", "CT-4008Tn-5V12A-S1-F");
simulator.updateChannel(tDevice, 1, true, DeviceWorkStatus.WORKING);
var discovered = adapter.discoverChannels();
var tChannels = discovered.stream().filter(channel -> channel.serverIp().equals("127.0.0.8")).toList();
var tnChannels = discovered.stream().filter(channel -> channel.serverIp().equals("127.0.0.9")).toList();
assertEquals(4, discovered.stream().filter(channel -> channel.serverIp().equals("127.0.0.2")).count());
assertEquals(8, tChannels.size());
assertEquals(8, tnChannels.size());
assertEquals("新威", tChannels.get(0).deviceMetadata().manufacturer());
assertEquals("CT-4008T-5V6A-S1-F", tChannels.get(0).deviceMetadata().model());
assertEquals("CT-4008Tn-5V12A-S1-F", tnChannels.get(0).deviceMetadata().model());
assertEquals(8, simulator.snapshot().devices().stream()
.filter(device -> device.deviceId() == tDevice || device.deviceId() == tnDevice)
.mapToInt(device -> device.channels().size()).sum() / 2);
Set channel = tChannels.stream()
.map(DetectedChannel::identity).filter(identity -> identity.chlid() == 1)
.collect(java.util.stream.Collectors.toSet());
var first = adapter.inquire(channel).values().iterator().next();
var second = adapter.inquire(channel).values().iterator().next();
assertEquals(DeviceWorkStatus.WORKING, first.workStatus());
assertNotEquals(first.currentA(), second.currentA());
assertTrue(first.voltageV().doubleValue() >= 0.0 && first.voltageV().doubleValue() <= 5.0);
}
}
@Test
void developmentSimulatorPersistsNewareProfileMetadata() throws Exception
{
InMemoryBtsSimulatorStateStore store = new InMemoryBtsSimulatorStateStore();
try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0, store))
{
simulator.addNewareDevice("127.0.0.8", "CT-4008Tn-5V12A-S1-F");
}
try (BtsDevelopmentSimulator restored = new BtsDevelopmentSimulator(0, store);
BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", restored.port(),
Duration.ofSeconds(1), Duration.ofSeconds(1));
DeviceAdapter adapter = new BtsDeviceAdapter("dev-sim", transport,
new BtsXmlCodec("dev-sim", ZoneId.of("Asia/Hong_Kong"))))
{
var profileChannels = adapter.discoverChannels().stream()
.filter(channel -> channel.serverIp().equals("127.0.0.8")).toList();
assertEquals(8, profileChannels.size());
assertEquals("CT-4008Tn-5V12A-S1-F", profileChannels.get(0).deviceMetadata().model());
assertEquals("新威", profileChannels.get(0).deviceMetadata().manufacturer());
}
}
@Test
void developmentSimulatorPublishesOperatorControlledChannelFacts() throws Exception
{
try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0);
BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", simulator.port(),
Duration.ofSeconds(1), Duration.ofSeconds(1));
DeviceAdapter adapter = new BtsDeviceAdapter("dev-sim", transport,
new BtsXmlCodec("dev-sim", ZoneId.of("Asia/Hong_Kong"))))
{
simulator.updateChannel(1, 1, true, DeviceWorkStatus.WORKING);
simulator.updateChannel(1, 2, false, DeviceWorkStatus.PAUSED);
var discovered = adapter.discoverChannels();
Set available = discovered.stream()
.filter(channel -> channel.available())
.map(channel -> channel.identity())
.collect(java.util.stream.Collectors.toSet());
var observed = adapter.inquire(available);
assertEquals(4, discovered.size());
assertEquals(3, available.size());
assertEquals(DeviceWorkStatus.WORKING, observed.entrySet().stream()
.filter(entry -> entry.getKey().chlid() == 1)
.findFirst().orElseThrow().getValue().workStatus());
simulator.updateDevice(1, "127.0.0.2", 4, false);
assertEquals(0, adapter.discoverChannels().stream().filter(channel -> channel.available()).count());
assertEquals(4, simulator.snapshot().devices().get(0).channels().size());
assertEquals(3, simulator.snapshot().recentRequests().size());
}
}
@Test
void developmentSimulatorSupportsMultipleConfigurableDevices() throws Exception
{
try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0);
BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", simulator.port(),
Duration.ofSeconds(1), Duration.ofSeconds(1));
DeviceAdapter adapter = new BtsDeviceAdapter("dev-sim", transport,
new BtsXmlCodec("dev-sim", ZoneId.of("Asia/Hong_Kong"))))
{
int secondDeviceId = simulator.addDevice("127.0.0.3", 2);
simulator.updateDevice(1, "127.0.0.2", 4, true);
var discovered = adapter.discoverChannels();
assertEquals(6, discovered.size());
assertEquals(2, discovered.stream().map(channel -> channel.identity().devid()).distinct().count());
assertEquals(4, discovered.stream().filter(channel -> channel.serverIp().equals("127.0.0.2")).count());
assertEquals(2, discovered.stream().filter(channel -> channel.serverIp().equals("127.0.0.3")).count());
simulator.deleteDevice(secondDeviceId);
for (int index = 0; index < 3; index++)
{
var withTombstone = adapter.discoverChannels();
assertEquals(6, withTombstone.size());
assertEquals(4, withTombstone.stream().filter(DetectedChannel::available).count());
}
assertEquals(4, adapter.discoverChannels().size());
}
}
@Test
void developmentCollectorsNeverEnterBtsCabinetDiscovery() throws Exception
{
try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0);
BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", simulator.port(),
Duration.ofSeconds(1), Duration.ofSeconds(1));
DeviceAdapter adapter = new BtsDeviceAdapter("dev-sim", transport,
new BtsXmlCodec("dev-sim", ZoneId.of("Asia/Hong_Kong"))))
{
assertEquals(4, adapter.discoverChannels().size());
simulator.addCollector("127.0.1.8", 12);
assertEquals(4, adapter.discoverChannels().size());
assertEquals(2, simulator.snapshot().collectors().size());
}
}
@Test
void developmentSimulatorRestoresConfigurationNumberingAndStatus() throws Exception
{
InMemoryBtsSimulatorStateStore store = new InMemoryBtsSimulatorStateStore();
try (BtsDevelopmentSimulator first = new BtsDevelopmentSimulator(0, store))
{
first.updateDevice(1, "127.0.0.9", 2, false);
first.addDevice("127.0.0.10", 3);
first.updateCollector(1, "127.0.1.9", 7, false);
}
try (BtsDevelopmentSimulator restored = new BtsDevelopmentSimulator(0, store))
{
assertEquals(2, restored.snapshot().devices().size());
assertEquals("127.0.0.9", restored.snapshot().devices().get(0).ip());
assertEquals(false, restored.snapshot().devices().get(0).online());
assertEquals(7, restored.snapshot().collectors().get(0).channelCount());
assertEquals(3, restored.addDevice("127.0.0.11", 1));
}
}
@Test
void developmentSimulatorDoesNotExposeWriteThroughFailureInMemory() throws Exception
{
InMemoryBtsSimulatorStateStore delegate = new InMemoryBtsSimulatorStateStore();
BtsSimulatorStateStore failing = new BtsSimulatorStateStore()
{
@Override public SimulatorState loadOrInitialize(SimulatorState value)
{ return delegate.loadOrInitialize(value); }
@Override public void save(SimulatorState value) { throw new IllegalStateException("redis unavailable"); }
@Override public java.util.List loadRecentRequests()
{ return delegate.loadRecentRequests(); }
@Override public void saveRecentRequests(java.util.List requests)
{ delegate.saveRecentRequests(requests); }
@Override public void reset(SimulatorState value) { delegate.reset(value); }
};
try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0, failing))
{
assertThrows(IllegalStateException.class, () -> simulator.addDevice("127.0.0.3", 2));
assertEquals(1, simulator.snapshot().devices().size());
}
}
@Test
void collectorDeletionDoesNotReplaceMemoryWhenAtomicPersistenceFails() throws Exception
{
InMemoryBtsSimulatorStateStore delegate = new InMemoryBtsSimulatorStateStore();
BtsSimulatorStateStore failing = new BtsSimulatorStateStore()
{
@Override public SimulatorState loadOrInitialize(SimulatorState value)
{ return delegate.loadOrInitialize(value); }
@Override public void save(SimulatorState value) { throw new IllegalStateException("redis unavailable"); }
@Override public java.util.List loadRecentRequests()
{ return delegate.loadRecentRequests(); }
@Override public void saveRecentRequests(java.util.List requests)
{ delegate.saveRecentRequests(requests); }
@Override public void reset(SimulatorState value) { delegate.reset(value); }
};
try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0, failing))
{
assertThrows(IllegalStateException.class, () -> simulator.deleteCollector(1));
assertEquals(1, simulator.snapshot().collectors().size());
}
}
@Test
void developmentSimulatorRestoresUnfinishedTombstones() throws Exception
{
InMemoryBtsSimulatorStateStore store = new InMemoryBtsSimulatorStateStore();
try (BtsDevelopmentSimulator first = new BtsDevelopmentSimulator(0, store);
BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", first.port(),
Duration.ofSeconds(1), Duration.ofSeconds(1));
DeviceAdapter adapter = new BtsDeviceAdapter("dev-sim", transport,
new BtsXmlCodec("dev-sim", ZoneId.of("Asia/Hong_Kong"))))
{
first.deleteDevice(1);
assertEquals(4, adapter.discoverChannels().size());
}
try (BtsDevelopmentSimulator restored = new BtsDevelopmentSimulator(0, store);
BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", restored.port(),
Duration.ofSeconds(1), Duration.ofSeconds(1));
DeviceAdapter adapter = new BtsDeviceAdapter("dev-sim", transport,
new BtsXmlCodec("dev-sim", ZoneId.of("Asia/Hong_Kong"))))
{
assertEquals(4, adapter.discoverChannels().size());
assertEquals(4, adapter.discoverChannels().size());
assertEquals(0, adapter.discoverChannels().size());
}
}
@Test
void developmentSimulatorRejectsNonContiguousRestoredChannelTopology()
{
SimulatorState damaged = new SimulatorState(BtsSimulatorStateStore.CURRENT_VERSION,
java.util.List.of(new DeviceState(1, "127.0.0.2", true,
java.util.List.of(new ChannelState(2, true, DeviceWorkStatus.STOPPED)))),
java.util.List.of(), 2, 1, java.util.List.of());
assertThrows(IllegalStateException.class,
() -> new BtsDevelopmentSimulator(0, new InMemoryBtsSimulatorStateStore(damaged)));
}
}