package com.ruoyi.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.ruoyi.device.api.DeviceAdapter; import com.ruoyi.device.bts.BtsDevelopmentSimulator; import com.ruoyi.device.bts.BtsDeviceAdapter; import com.ruoyi.device.bts.BtsXmlCodec; import com.ruoyi.device.bts.BtsTcpTransport; import com.ruoyi.device.bts.ScriptedBtsTcpSimulator; import com.ruoyi.device.model.DetectedChannel; import com.ruoyi.device.model.DeviceWorkStatus; import com.ruoyi.device.simulator.BtsSimulatorStateStore; import com.ruoyi.device.simulator.BtsSimulatorStateStore.ChannelState; import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorChannelState; import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorHistoryAppend; import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorHistoryBatch; import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorHistoryFaults; import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorMeasurementRecord; import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorMetricState; import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorState; import com.ruoyi.device.simulator.BtsSimulatorStateStore.DeviceState; import com.ruoyi.device.simulator.BtsSimulatorStateStore.SimulatorState; import com.ruoyi.device.simulator.BtsSimulatorStateStore.RecentRequest; import com.ruoyi.device.simulator.InMemoryBtsSimulatorStateStore; class SimulatorPublicContractTest { @Test void collectorGeneratesIndependentRealtimeAndHistoricalMeasurements() throws Exception { Instant startedAt = Instant.parse("2026-08-29T01:00:00Z"); try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0)) { simulator.configureCollectorSampling(1, 200, CollectorHistoryFaults.none()); simulator.configureCollectorChannel(1, "S-01", List.of( new CollectorMetricState("temperature", new BigDecimal("26.5"), "℃"), new CollectorMetricState("positive_potential", new BigDecimal("3.801"), "V"), new CollectorMetricState("negative_potential", new BigDecimal("-0.120"), "V"), new CollectorMetricState("pressure", new BigDecimal("101.2"), "kPa"))); simulator.advanceCollectors(startedAt); simulator.advanceCollectors(startedAt.plusMillis(400)); var realtime = simulator.snapshot().collectors().get(0).latestMeasurements(); var history = simulator.collectorHistory(1, 0, 1000); assertEquals(13, realtime.size()); assertTrue(realtime.stream().allMatch(item -> item.sequenceId() == 3)); assertTrue(realtime.stream().anyMatch(item -> item.channelCode().equals("S-01") && item.metricCode().equals("pressure") && item.unit().equals("kPa"))); assertEquals(39, history.total()); assertEquals("dev-sim", history.records().get(0).adapterCode()); assertEquals("dev-sim-collector-1", history.records().get(0).collectorCode()); assertEquals(startedAt, history.records().get(0).deviceTime()); assertTrue(history.records().get(0).deviceTimeRaw().startsWith("2026-08-29 09:00:00")); assertEquals(startedAt, history.records().get(0).serverReceivedAt()); } } @Test void collectorHistorySupportsPagingAndRepeatableFaultScenarios() throws Exception { Instant startedAt = Instant.parse("2026-08-29T02:00:00Z"); try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0)) { simulator.configureCollectorSampling(1, 100, new CollectorHistoryFaults(List.of(2L), 1L, 3L, true)); simulator.advanceCollectors(startedAt); simulator.advanceCollectors(startedAt.plusMillis(200)); var first = simulator.collectorHistory(1, 0, 17); var second = simulator.collectorHistory(1, first.nextOffset(), 1000); List records = new java.util.ArrayList<>(first.records()); records.addAll(second.records()); assertEquals(48, first.total()); assertTrue(first.hasMore()); assertFalse(second.hasMore()); assertTrue(records.stream().noneMatch(item -> item.sequenceId() == 2)); assertEquals(24, records.stream().filter(item -> item.sequenceId() == 1).count()); assertEquals(2, records.stream().filter(item -> item.sequenceId() == 3 && item.channelCode().equals("S-01") && item.metricCode().equals("temperature")).count()); assertTrue(records.get(0).sequenceId() >= records.get(records.size() - 1).sequenceId()); } } @Test void collectorStopsWhileOfflineAndResumesFromPersistedSequence() throws Exception { InMemoryBtsSimulatorStateStore store = new InMemoryBtsSimulatorStateStore(); Instant now = Instant.now(); try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0, store)) { simulator.advanceCollectors(now); simulator.updateCollector(1, "127.0.1.2", 4, false); simulator.advanceCollectors(now.plusSeconds(10)); assertEquals(1, simulator.snapshot().collectors().get(0).latestMeasurements().get(0).sequenceId()); } try (BtsDevelopmentSimulator restored = new BtsDevelopmentSimulator(0, store)) { restored.updateCollector(1, "127.0.1.2", 4, true); restored.advanceCollectors(Instant.now().plusSeconds(2)); assertTrue(restored.snapshot().collectors().get(0).latestMeasurements().get(0).sequenceId() >= 2); assertEquals(List.of("S-01", "S-02", "S-03", "S-04"), restored.snapshot().collectors().get(0).channels().stream() .map(channel -> channel.channelCode()).toList()); } } @Test void simulatorPowerSwitchStopsSamplingAndRestoresWithoutDeletingHistory() throws Exception { InMemoryBtsSimulatorStateStore store = new InMemoryBtsSimulatorStateStore(); long historyCount; try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0, store)) { simulator.advanceCollectors(Instant.now()); historyCount = simulator.snapshot().collectors().get(0).historyRecordCount(); simulator.setAllDevicesPoweredOn(false); simulator.advanceCollectors(Instant.now().plusSeconds(10)); var poweredOff = simulator.snapshot(); assertTrue(poweredOff.devices().stream().noneMatch(device -> device.online() || device.channels().stream().anyMatch(channel -> channel.online() || channel.workStatus() != DeviceWorkStatus.STOPPED))); assertTrue(poweredOff.collectors().stream().noneMatch(collector -> collector.online())); assertEquals(historyCount, poweredOff.collectors().get(0).historyRecordCount()); } try (BtsDevelopmentSimulator restored = new BtsDevelopmentSimulator(0, store)) { assertFalse(restored.snapshot().collectors().get(0).online()); assertEquals(historyCount, restored.collectorHistory(1, 0, 1000).total()); restored.setAllDevicesPoweredOn(true); var poweredOn = restored.snapshot(); assertTrue(poweredOn.devices().stream().allMatch(device -> device.online() && device.channels().stream().allMatch(channel -> channel.online() && channel.workStatus() == DeviceWorkStatus.STOPPED))); assertTrue(poweredOn.collectors().stream().allMatch(collector -> collector.online())); restored.advanceCollectors(Instant.now().plusSeconds(2)); assertTrue(restored.snapshot().collectors().get(0).historyRecordCount() > historyCount); } } @Test void collectorKeepsOnlyBoundedDemoHistory() throws Exception { Instant startedAt = Instant.parse("2026-08-29T03:00:00Z"); TrackingHistoryStore store = new TrackingHistoryStore(); try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0, store)) { simulator.configureCollectorSampling(1, 100, CollectorHistoryFaults.none()); simulator.advanceCollectors(startedAt); for (int batch = 1; batch <= 5; batch++) simulator.advanceCollectors(startedAt.plusMillis(batch * 100_000L)); store.resetReadStats(); var page = simulator.collectorHistory(1, 0, 1); assertEquals(BtsSimulatorStateStore.MAX_HISTORY_RECORDS, page.total()); assertTrue(page.records().get(0).sequenceId() > 1); assertEquals(1, store.historyReads); assertEquals(1, store.recordsRead); } store.resetReadStats(); try (BtsDevelopmentSimulator ignored = new BtsDevelopmentSimulator(0, store)) { assertTrue(store.historyReads > 1); assertEquals(1000, store.maximumRequestedLimit); assertEquals(BtsSimulatorStateStore.MAX_HISTORY_RECORDS, store.recordsRead); } } @Test void collectorRawHistoryIsNotEmbeddedInMainSimulatorState() throws Exception { InMemoryBtsSimulatorStateStore store = new InMemoryBtsSimulatorStateStore(); try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0, store)) { simulator.advanceCollectors(Instant.parse("2026-08-29T03:30:00Z")); String mainState = JSON.toJSONString(store.loadOrInitialize(null)); assertFalse(mainState.contains("\"history\":[{")); assertEquals(12, simulator.collectorHistory(1, 0, 100).total()); } } @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.advanceCollectors(startedAt); 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); 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"))); assertEquals(12, simulator.collectorHistory(1, 0, 100).total()); } try (BtsDevelopmentSimulator restored = new BtsDevelopmentSimulator(0, store)) { assertEquals(4, restored.snapshot().collectors().get(0).latestMeasurements().size()); assertEquals(12, restored.collectorHistory(1, 0, 100).total()); } } @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) { delegate.save(value); } @Override public void saveAndDeleteCollectorHistory(SimulatorState value, String collectorCode) { 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))); } @Test void developmentSimulatorRejectsDamagedCollectorHistoryIdentity() { Instant observedAt = Instant.parse("2026-08-29T05:00:00Z"); List channels = List.of(new CollectorChannelState("S-01", List.of( new CollectorMetricState("temperature", new BigDecimal("25.0"), "℃")))); CollectorMeasurementRecord record = new CollectorMeasurementRecord( "dev-sim", "wrong-collector", "S-01", 1L, observedAt, "2026-08-29 13:00:00.000", observedAt, "temperature", new BigDecimal("25.0"), "℃"); CollectorState collector = new CollectorState(1, "dev-sim-collector-1", "127.0.1.2", "02:43:00:00:00:01", "模拟采集器", "开发模拟器", "Virtual Collector", 1, true, 1000, 2L, observedAt, channels, List.of(), 1L, CollectorHistoryFaults.none()); SimulatorState damaged = new SimulatorState(BtsSimulatorStateStore.CURRENT_VERSION, List.of(), List.of(collector), 1, 2, List.of()); InMemoryBtsSimulatorStateStore store = new InMemoryBtsSimulatorStateStore(damaged); store.save(damaged, List.of(new CollectorHistoryAppend(collector.deviceCode(), List.of(record)))); assertThrows(IllegalStateException.class, () -> new BtsDevelopmentSimulator(0, store)); } @Test void developmentSimulatorRejectsCollectorSequenceBehindPersistedHistory() { Instant observedAt = Instant.parse("2026-08-29T06:00:00Z"); List channels = List.of(new CollectorChannelState("S-01", List.of( new CollectorMetricState("temperature", new BigDecimal("25.0"), "℃")))); CollectorMeasurementRecord record = new CollectorMeasurementRecord( "dev-sim", "dev-sim-collector-1", "S-01", 1L, observedAt, "2026-08-29 14:00:00.000", observedAt, "temperature", new BigDecimal("25.0"), "℃"); CollectorState collector = new CollectorState(1, "dev-sim-collector-1", "127.0.1.2", "02:43:00:00:00:01", "模拟采集器", "开发模拟器", "Virtual Collector", 1, true, 1000, 1L, observedAt, channels, List.of(), 1L, CollectorHistoryFaults.none()); SimulatorState damaged = new SimulatorState(BtsSimulatorStateStore.CURRENT_VERSION, List.of(), List.of(collector), 1, 2, List.of()); InMemoryBtsSimulatorStateStore store = new InMemoryBtsSimulatorStateStore(damaged); store.save(damaged, List.of(new CollectorHistoryAppend(collector.deviceCode(), List.of(record)))); assertThrows(IllegalStateException.class, () -> new BtsDevelopmentSimulator(0, store)); } @Test void developmentSimulatorRejectsHistorySequenceRegressionAcrossPages() { Instant observedAt = Instant.parse("2026-08-29T06:30:00Z"); List channels = List.of(new CollectorChannelState("S-01", List.of( new CollectorMetricState("temperature", new BigDecimal("25.0"), "℃")))); List records = new java.util.ArrayList<>(); for (long sequence = 1; sequence <= 1000; sequence++) records.add(new CollectorMeasurementRecord("dev-sim", "dev-sim-collector-1", "S-01", sequence, observedAt, "2026-08-29 14:30:00.000", observedAt, "temperature", new BigDecimal("25.0"), "℃")); records.add(new CollectorMeasurementRecord("dev-sim", "dev-sim-collector-1", "S-01", 999, observedAt, "2026-08-29 14:30:00.000", observedAt, "temperature", new BigDecimal("25.0"), "℃")); CollectorState collector = new CollectorState(1, "dev-sim-collector-1", "127.0.1.2", "02:43:00:00:00:01", "模拟采集器", "开发模拟器", "Virtual Collector", 1, true, 1000, 1001L, observedAt, channels, List.of(), records.size(), CollectorHistoryFaults.none()); SimulatorState damaged = new SimulatorState(BtsSimulatorStateStore.CURRENT_VERSION, List.of(), List.of(collector), 1, 2, List.of()); InMemoryBtsSimulatorStateStore store = new InMemoryBtsSimulatorStateStore(damaged); store.save(damaged, List.of(new CollectorHistoryAppend(collector.deviceCode(), records))); assertThrows(IllegalStateException.class, () -> new BtsDevelopmentSimulator(0, store)); } private static final class TrackingHistoryStore implements BtsSimulatorStateStore { private final InMemoryBtsSimulatorStateStore delegate = new InMemoryBtsSimulatorStateStore(); private int historyReads; private int maximumRequestedLimit; private long recordsRead; @Override public SimulatorState loadOrInitialize(SimulatorState defaultState) { return delegate.loadOrInitialize(defaultState); } @Override public void save(SimulatorState state) { delegate.save(state); } @Override public void save(SimulatorState state, List historyAppends) { delegate.save(state, historyAppends); } @Override public CollectorHistoryBatch loadCollectorHistoryPage(String collectorCode, long offset, int limit, boolean reverse) { CollectorHistoryBatch batch = delegate.loadCollectorHistoryPage(collectorCode, offset, limit, reverse); historyReads++; maximumRequestedLimit = Math.max(maximumRequestedLimit, limit); recordsRead += batch.records().size(); return batch; } @Override public void saveAndDeleteCollectorHistory(SimulatorState state, String collectorCode) { delegate.saveAndDeleteCollectorHistory(state, collectorCode); } @Override public List loadRecentRequests() { return delegate.loadRecentRequests(); } @Override public void saveRecentRequests(List requests) { delegate.saveRecentRequests(requests); } @Override public void reset(SimulatorState defaultState) { delegate.reset(defaultState); } private void resetReadStats() { historyReads = 0; maximumRequestedLimit = 0; recordsRead = 0; } } }