package com.ruoyi.config; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ListOperations; import org.springframework.data.redis.core.SessionCallback; import org.springframework.data.redis.core.SetOperations; import org.springframework.data.redis.core.ValueOperations; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import com.ruoyi.device.model.DeviceWorkStatus; import com.ruoyi.device.bts.BtsDevelopmentSimulator; import com.ruoyi.device.simulator.BtsSimulatorStateStore.ChannelState; import com.ruoyi.device.simulator.BtsSimulatorStateStore.ChannelTombstone; 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.discovery.DeviceDiscoveryCandidateStore.CabinetCandidate; import com.ruoyi.device.discovery.DeviceDiscoveryCandidateStore.ChannelCandidate; import com.ruoyi.device.discovery.DeviceDiscoveryCandidateStore.CollectorCandidate; import com.ruoyi.device.model.ChannelIdentity; import com.ruoyi.framework.config.FastJson2JsonRedisSerializer; class RedisStateStoreTest { @Test void initializesOnlyWhenStateIsAbsentAndRestoresSavedState() { AtomicReference stored = new AtomicReference<>(); RedisBtsSimulatorStateStore store = store(stored); SimulatorState defaults = state(1, "127.0.0.2"); assertEquals(defaults, store.loadOrInitialize(defaults)); SimulatorState changed = state(7, "127.0.0.9"); store.save(changed); assertEquals(changed, store.loadOrInitialize(defaults)); } @Test void restoresNestedSimulatorStateFromRedisSerializer() { SimulatorState expected = new SimulatorState(1, List.of(new DeviceState(2, "127.0.0.3", false, List.of(new ChannelState(1, true, DeviceWorkStatus.WORKING)))), List.of(new CollectorState(3, "dev-sim-collector-3", "127.0.1.3", "02:43:00:00:00:03", "模拟采集器", "开发模拟器", "Virtual Collector", 3, true)), 3, 4, List.of(new ChannelTombstone(1, "127.0.0.2", 4, 2))); FastJson2JsonRedisSerializer serializer = new FastJson2JsonRedisSerializer<>(Object.class); AtomicReference stored = new AtomicReference<>( serializer.deserialize(serializer.serialize(JSON.toJSONString(expected)))); SimulatorState restored = store(stored).loadOrInitialize(state(1, "127.0.0.2")); assertEquals(expected, restored); assertEquals(DeviceWorkStatus.WORKING, restored.devices().get(0).channels().get(0).workStatus()); } @Test void upgradesLegacyCollectorDiscoveryStateWithSamplingDefaults() { AtomicReference stored = new AtomicReference<>("{\"version\":1,\"devices\":[]," + "\"collectors\":[{\"collectorId\":1,\"deviceCode\":\"dev-sim-collector-1\"," + "\"ip\":\"127.0.1.2\",\"mac\":\"02:43:00:00:00:01\"," + "\"name\":\"模拟采集器\",\"manufacturer\":\"开发模拟器\"," + "\"model\":\"Virtual Collector\",\"channelCount\":2,\"online\":true}]," + "\"nextDeviceId\":1,\"nextCollectorId\":2,\"tombstones\":[]}"); SimulatorState restored = store(stored).loadOrInitialize(state(1, "127.0.0.2")); CollectorState collector = restored.collectors().get(0); assertEquals(1000, collector.sampleIntervalMs()); assertEquals(1L, collector.nextSequence()); assertEquals(List.of("S-01", "S-02"), collector.channels().stream().map(channel -> channel.channelCode()).toList()); assertEquals(0L, collector.historyRecordCount()); } @Test void migratesEmbeddedCollectorHistoryToAppendOnlyRedisList() { AtomicReference stored = new AtomicReference<>("{\"version\":1,\"devices\":[]," + "\"collectors\":[{\"collectorId\":1,\"deviceCode\":\"dev-sim-collector-1\"," + "\"ip\":\"127.0.1.2\",\"mac\":\"02:43:00:00:00:01\"," + "\"name\":\"模拟采集器\",\"manufacturer\":\"开发模拟器\"," + "\"model\":\"Virtual Collector\",\"channelCount\":1,\"online\":true," + "\"sampleIntervalMs\":1000,\"nextSequence\":2," + "\"lastSampledAt\":\"2026-08-29T05:00:00Z\"," + "\"channels\":[{\"channelCode\":\"S-01\",\"metrics\":[{" + "\"metricCode\":\"temperature\",\"value\":25.1,\"unit\":\"℃\"}]}]," + "\"history\":[{\"adapterCode\":\"dev-sim\"," + "\"collectorCode\":\"dev-sim-collector-1\",\"channelCode\":\"S-01\"," + "\"sequenceId\":1,\"deviceTime\":\"2026-08-29T05:00:00Z\"," + "\"deviceTimeRaw\":\"2026-08-29 13:00:00.000\"," + "\"serverReceivedAt\":\"2026-08-29T05:00:00Z\"," + "\"metricCode\":\"temperature\",\"value\":25.1,\"unit\":\"℃\"}]," + "\"historyFaults\":{\"missingSequences\":[],\"outOfOrder\":false}}]," + "\"nextDeviceId\":1,\"nextCollectorId\":2,\"tombstones\":[]}"); RedisBtsSimulatorStateStore store = store(stored); SimulatorState restored = store.loadOrInitialize(state(1, "127.0.0.2")); assertEquals(1L, restored.collectors().get(0).historyRecordCount()); assertEquals(1, store.loadCollectorHistory("dev-sim-collector-1").size()); assertEquals(true, stored.get().toString().contains("\"history\":[{")); store.completeValidatedRestore(restored); assertEquals(false, stored.get().toString().contains("\"history\":[{")); } @Test void doesNotOverwriteDamagedEmbeddedHistoryBeforeValidation() { AtomicReference stored = new AtomicReference<>("{\"version\":1,\"devices\":[]," + "\"collectors\":[{\"collectorId\":1,\"deviceCode\":\"dev-sim-collector-1\"," + "\"ip\":\"127.0.1.2\",\"mac\":\"02:43:00:00:00:01\"," + "\"name\":\"模拟采集器\",\"manufacturer\":\"开发模拟器\"," + "\"model\":\"Virtual Collector\",\"channelCount\":1,\"online\":true," + "\"sampleIntervalMs\":1000,\"nextSequence\":2," + "\"lastSampledAt\":\"2026-08-29T05:00:00Z\"," + "\"channels\":[{\"channelCode\":\"S-01\",\"metrics\":[{" + "\"metricCode\":\"temperature\",\"value\":25.1,\"unit\":\"℃\"}]}]," + "\"history\":[{\"adapterCode\":\"dev-sim\"," + "\"collectorCode\":\"wrong-collector\",\"channelCode\":\"S-01\"," + "\"sequenceId\":1,\"deviceTime\":\"2026-08-29T05:00:00Z\"," + "\"deviceTimeRaw\":\"2026-08-29 13:00:00.000\"," + "\"serverReceivedAt\":\"2026-08-29T05:00:00Z\"," + "\"metricCode\":\"temperature\",\"value\":25.1,\"unit\":\"℃\"}]," + "\"historyFaults\":{\"missingSequences\":[],\"outOfOrder\":false}}]," + "\"nextDeviceId\":1,\"nextCollectorId\":2,\"tombstones\":[]}"); assertThrows(IllegalStateException.class, () -> new BtsDevelopmentSimulator(0, store(stored))); assertEquals(true, stored.get().toString().contains("\"history\":[{")); } @Test void rejectsIncompatibleVersionWithoutOverwritingIt() { AtomicReference stored = new AtomicReference<>("{\"version\":2,\"devices\":[]," + "\"collectors\":[],\"nextDeviceId\":1,\"nextCollectorId\":1,\"tombstones\":[]}"); RedisBtsSimulatorStateStore store = store(stored); IllegalStateException exception = assertThrows(IllegalStateException.class, () -> store.loadOrInitialize(state(1, "127.0.0.2"))); assertEquals("Unsupported BTS simulator state version: 2", exception.getMessage()); assertEquals(true, stored.get().toString().contains("\"version\":2")); } @Test void atomicallySavesCollectorDeletionAndRemovesIndependentHistory() { AtomicReference stored = new AtomicReference<>(); RedisBtsSimulatorStateStore store = store(stored); SimulatorState current = state(1, "127.0.0.2"); store.loadOrInitialize(current); var record = new com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorMeasurementRecord( "dev-sim", "dev-sim-collector-1", "S-01", 1L, java.time.Instant.parse("2026-08-29T05:00:00Z"), "2026-08-29 13:00:00.000", java.time.Instant.parse("2026-08-29T05:00:00Z"), "temperature", new java.math.BigDecimal("25.1"), "℃"); store.save(current, List.of( new com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorHistoryAppend( "dev-sim-collector-1", List.of(record)))); SimulatorState next = state(7, "127.0.0.9"); store.saveAndDeleteCollectorHistory(next, "dev-sim-collector-1"); assertEquals(next, store.loadOrInitialize(current)); assertEquals(List.of(), store.loadCollectorHistory("dev-sim-collector-1")); } @Test void restoresNestedDiscoveryCandidatesFromJson() { AtomicReference stored = new AtomicReference<>(); RedisDeviceDiscoveryCandidateStore first = new RedisDeviceDiscoveryCandidateStore(redis(stored)); java.time.Instant observedAt = java.time.Instant.parse("2026-08-27T00:00:00Z"); CabinetCandidate candidate = new CabinetCandidate("dev-sim", null, "dev-sim-22-1", 22, 1, "127.0.0.2", null, "模拟柜", "开发模拟器", "BTS Virtual Cabinet", List.of(new ChannelCandidate(new ChannelIdentity("dev-sim", 22, 1, 1, 1), true, DeviceWorkStatus.WORKING, observedAt)), observedAt, observedAt.plusSeconds(30)); first.recordCabinets(List.of(candidate)); RedisDeviceDiscoveryCandidateStore restored = new RedisDeviceDiscoveryCandidateStore(redis(stored)); CabinetCandidate actual = restored.snapshot(observedAt.plusSeconds(1)).cabinets().get(0); assertEquals("dev-sim-22-1", actual.deviceCode()); assertEquals(DeviceWorkStatus.WORKING, actual.channels().get(0).workStatus()); assertEquals(observedAt, actual.channels().get(0).observedAt()); } @Test void removesDevelopmentCandidatesFromRedisState() { AtomicReference stored = new AtomicReference<>(); RedisDeviceDiscoveryCandidateStore store = new RedisDeviceDiscoveryCandidateStore(redis(stored)); java.time.Instant observedAt = java.time.Instant.parse("2026-08-27T00:00:00Z"); store.recordCabinets(List.of(new CabinetCandidate("dev-sim", null, "dev-sim-22-1", 22, 1, "127.0.0.2", null, "模拟柜", null, null, List.of(new ChannelCandidate(new ChannelIdentity("dev-sim", 22, 1, 1, 1), true, DeviceWorkStatus.STOPPED, observedAt)), observedAt, observedAt.plusSeconds(30)))); store.removeAdapter("dev-sim"); assertEquals(List.of(), store.snapshot(observedAt).cabinets()); } @Test void restoresDiscoveryCandidatesFromLegacyAutoTypeJson() { AtomicReference stored = new AtomicReference<>(); RedisDeviceDiscoveryCandidateStore first = new RedisDeviceDiscoveryCandidateStore(redis(stored)); java.time.Instant observedAt = java.time.Instant.parse("2026-08-27T00:00:00Z"); first.recordCabinets(List.of(new CabinetCandidate("dev-sim", null, "dev-sim-22-1", 22, 1, "127.0.0.2", null, "模拟柜", null, null, List.of(new ChannelCandidate(new ChannelIdentity("dev-sim", 22, 1, 1, 1), true, DeviceWorkStatus.STOPPED, observedAt)), observedAt, observedAt.plusSeconds(30)))); first.replaceCollectors("dev-sim", List.of(new CollectorCandidate("dev-sim", "dev-sim-collector-1", "127.0.1.2", null, "模拟采集器", null, null, 8, true, observedAt, observedAt.plusSeconds(30)))); JSONObject legacy = JSON.parseObject(stored.get().toString()); legacy.put("@type", "com.ruoyi.config.RedisDeviceDiscoveryCandidateStore$StoredState"); JSONObject cabinet = legacy.getJSONArray("cabinets").getJSONObject(0); cabinet.put("@type", JSONObject.class.getName()); cabinet.getJSONArray("channels").getJSONObject(0) .put("@type", JSONObject.class.getName()); legacy.getJSONArray("collectors").getJSONObject(0) .put("@type", JSONObject.class.getName()); FastJson2JsonRedisSerializer serializer = new FastJson2JsonRedisSerializer<>(Object.class); stored.set(serializer.deserialize(serializer.serialize(legacy))); var snapshot = new RedisDeviceDiscoveryCandidateStore(redis(stored)).snapshot(observedAt.plusSeconds(1)); CabinetCandidate restored = snapshot.cabinets().get(0); assertEquals("dev-sim-22-1", restored.deviceCode()); assertEquals(DeviceWorkStatus.STOPPED, restored.channels().get(0).workStatus()); assertEquals("dev-sim-collector-1", snapshot.collectors().get(0).deviceCode()); } @SuppressWarnings({ "unchecked", "rawtypes" }) private RedisBtsSimulatorStateStore store(AtomicReference stored) { return new RedisBtsSimulatorStateStore(redis(stored)); } @SuppressWarnings({ "unchecked", "rawtypes" }) private RedisTemplate redis(AtomicReference stored) { RedisTemplate redis = mock(RedisTemplate.class); ValueOperations values = mock(ValueOperations.class); ListOperations lists = mock(ListOperations.class); SetOperations sets = mock(SetOperations.class); Map> listValues = new LinkedHashMap<>(); when(redis.opsForValue()).thenReturn(values); when(redis.opsForList()).thenReturn(lists); when(redis.opsForSet()).thenReturn(sets); when(sets.add(any(), any())).thenReturn(1L); when(sets.members(any())).thenReturn(Set.of()); when(redis.exec()).thenReturn(List.of(Boolean.TRUE)); when(redis.execute(any(SessionCallback.class))).thenAnswer(invocation -> { SessionCallback callback = invocation.getArgument(0); return callback.execute(redis); }); when(lists.rightPushAll(any(), org.mockito.ArgumentMatchers.anyCollection())) .thenAnswer(invocation -> { Object key = invocation.getArgument(0); List records = new ArrayList<>(listValues.getOrDefault(key, List.of())); FastJson2JsonRedisSerializer serializer = new FastJson2JsonRedisSerializer<>(Object.class); List appended = invocation.getArgument(1); appended.forEach(item -> records.add( serializer.deserialize(serializer.serialize(item)))); listValues.put(key, records); return (long) records.size(); }); when(lists.range(any(), anyLong(), anyLong())).thenAnswer(invocation -> List.copyOf(listValues.getOrDefault(invocation.getArgument(0), List.of()))); when(lists.size(any())).thenAnswer(invocation -> (long) listValues.getOrDefault(invocation.getArgument(0), List.of()).size()); when(redis.delete(org.mockito.ArgumentMatchers.any())) .thenAnswer(invocation -> listValues.remove(invocation.getArgument(0)) != null); when(values.get(any())).thenAnswer(invocation -> stored.get()); when(values.setIfAbsent(any(), any())).thenAnswer(invocation -> { if (stored.get() != null) return false; stored.set(invocation.getArgument(1)); return true; }); doAnswer(invocation -> { stored.set(invocation.getArgument(1)); return null; }) .when(values).set(any(), any()); return redis; } private SimulatorState state(int deviceId, String ip) { return new SimulatorState(1, List.of(new DeviceState(deviceId, ip, true, List.of(new ChannelState(1, true, DeviceWorkStatus.STOPPED)))), List.of(), deviceId + 1, 1, List.of()); } }