yeg
2026-09-05 f522c9ffa487e3710d691e8b98df8c4f0cfafd43
battery-device-simulator/src/test/java/com/battery/device/contract/SimulatorPublicContractTest.java
@@ -19,15 +19,11 @@
import com.battery.device.bts.BtsXmlCodec;
import com.battery.device.bts.BtsTcpTransport;
import com.battery.device.simulator.ScriptedBtsTcpSimulator;
import com.battery.device.model.DetectedChannel;
import com.battery.device.model.DeviceWorkStatus;
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.CollectorHistoryAppend;
import com.battery.device.simulator.BtsSimulatorStateStore.CollectorHistoryBatch;
import com.battery.device.simulator.BtsSimulatorStateStore.CollectorHistoryFaults;
import com.battery.device.simulator.BtsSimulatorStateStore.CollectorMeasurementRecord;
import com.battery.device.simulator.BtsSimulatorStateStore.CollectorMetricState;
import com.battery.device.simulator.BtsSimulatorStateStore.CollectorState;
import com.battery.device.simulator.BtsSimulatorStateStore.DeviceState;
@@ -38,182 +34,20 @@
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<com.battery.device.simulator.BtsSimulatorStateStore.CollectorMeasurementRecord> 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);
            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()
@@ -221,13 +55,11 @@
                            || 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());
            assertEquals(4, restored.collectorRealtime(1).measurements().size());
        }
    }
@@ -291,7 +123,7 @@
            assertEquals(1, firstDiscovery.stream().map(channel -> channel.identity().devid()).distinct().count());
            assertEquals(4, firstDiscovery.stream().filter(channel -> channel.available()).count());
            Set<com.battery.device.model.ChannelIdentity> channels = firstDiscovery.stream()
            Set<com.battery.device.api.model.ChannelIdentity> channels = firstDiscovery.stream()
                    .map(channel -> channel.identity())
                    .collect(java.util.stream.Collectors.toSet());
            var firstObservation = adapter.inquire(channels);
@@ -304,7 +136,7 @@
    }
    @Test
    void developmentSimulatorSupportsNewareProfilesAndDynamicObservation() throws Exception
    void developmentSimulatorSupportsNewareProfilesAndFixedObservation() throws Exception
    {
        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0);
             BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", simulator.port(),
@@ -330,14 +162,41 @@
                    .filter(device -> device.deviceId() == tDevice || device.deviceId() == tnDevice)
                    .mapToInt(device -> device.channels().size()).sum() / 2);
            Set<com.battery.device.model.ChannelIdentity> channel = tChannels.stream()
            Set<com.battery.device.api.model.ChannelIdentity> 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());
            assertEquals(first.currentA(), second.currentA());
            assertTrue(first.voltageV().doubleValue() >= 0.0 && first.voltageV().doubleValue() <= 5.0);
        }
    }
    @Test
    void configuredChannelValuesReachBtsInquireWithoutClampingOrRounding() throws Exception
    {
        try (var simulator = new BtsDevelopmentSimulator(0);
             var 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.configureChannel(1, 1, new com.battery.device.simulator.BtsSimulatorStateStore.ChannelConfiguration(
                    new java.math.BigDecimal("-2.123456"), new java.math.BigDecimal("8.125"),
                    new java.math.BigDecimal("7.2"), new java.math.BigDecimal("29.7"), 2, 2, 5, 120, 30,
                    List.of(new com.battery.device.simulator.BtsSimulatorStateStore.SimulatorPlanStep("充电", "cc", 60, 4.2),
                            new com.battery.device.simulator.BtsSimulatorStateStore.SimulatorPlanStep("放电 <验证>", "dc", 90, 3.0))));
            var channel = adapter.discoverChannels().stream().filter(item -> item.identity().chlid() == 1)
                    .findFirst().orElseThrow().identity();
            var observation = adapter.inquire(Set.of(channel)).get(channel);
            assertEquals(-2.123456, observation.currentA().doubleValue());
            assertEquals(8.125, observation.voltageV().doubleValue());
            assertEquals(7.2, observation.capacityAh().doubleValue());
            assertEquals(29.7, observation.energyWh().doubleValue());
            assertEquals(2, observation.cycleNo());
            assertEquals(2, observation.stepNo());
            assertEquals("dc", observation.stepType());
            assertEquals(120L, observation.totalTimeSeconds());
            assertEquals(30L, observation.relativeTimeSeconds());
        }
    }
@@ -377,7 +236,7 @@
            simulator.updateChannel(1, 2, false, DeviceWorkStatus.PAUSED);
            var discovered = adapter.discoverChannels();
            Set<com.battery.device.model.ChannelIdentity> available = discovered.stream()
            Set<com.battery.device.api.model.ChannelIdentity> available = discovered.stream()
                    .filter(channel -> channel.available())
                    .map(channel -> channel.identity())
                    .collect(java.util.stream.Collectors.toSet());
@@ -496,9 +355,7 @@
        {
            @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 void save(SimulatorState value) { throw new IllegalStateException("redis unavailable"); }
            @Override public java.util.List<RecentRequest> loadRecentRequests()
            { return delegate.loadRecentRequests(); }
            @Override public void saveRecentRequests(java.util.List<RecentRequest> requests)
@@ -551,141 +408,4 @@
                () -> new BtsDevelopmentSimulator(0, new InMemoryBtsSimulatorStateStore(damaged)));
    }
    @Test
    void developmentSimulatorRejectsDamagedCollectorHistoryIdentity()
    {
        Instant observedAt = Instant.parse("2026-08-29T05:00:00Z");
        List<CollectorChannelState> 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<CollectorChannelState> 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<CollectorChannelState> channels = List.of(new CollectorChannelState("S-01", List.of(
                new CollectorMetricState("temperature", new BigDecimal("25.0"), "℃"))));
        List<CollectorMeasurementRecord> 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<CollectorHistoryAppend> 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<RecentRequest> loadRecentRequests()
        {
            return delegate.loadRecentRequests();
        }
        @Override
        public void saveRecentRequests(List<RecentRequest> requests)
        {
            delegate.saveRecentRequests(requests);
        }
        @Override
        public void reset(SimulatorState defaultState)
        {
            delegate.reset(defaultState);
        }
        private void resetReadStats()
        {
            historyReads = 0;
            maximumRequestedLimit = 0;
            recordsRead = 0;
        }
    }
}