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.clearInvocations;
|
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.verify;
|
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.battery.device.model.DeviceWorkStatus;
|
import com.battery.device.simulator.BtsDevelopmentSimulator;
|
import com.battery.device.simulator.BtsSimulatorStateStore.ChannelState;
|
import com.battery.device.simulator.BtsSimulatorStateStore.ChannelTombstone;
|
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.discovery.DeviceDiscoveryCandidateStore.CabinetCandidate;
|
import com.battery.device.discovery.DeviceDiscoveryCandidateStore.ChannelCandidate;
|
import com.battery.device.discovery.DeviceDiscoveryCandidateStore.CollectorCandidate;
|
import com.battery.device.model.ChannelIdentity;
|
import com.ruoyi.framework.config.FastJson2JsonRedisSerializer;
|
|
class RedisStateStoreTest
|
{
|
@Test
|
void initializesOnlyWhenStateIsAbsentAndRestoresSavedState()
|
{
|
AtomicReference<Object> 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<Object> serializer = new FastJson2JsonRedisSerializer<>(Object.class);
|
AtomicReference<Object> 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<Object> 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<Object> 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.loadCollectorHistoryPage("dev-sim-collector-1", 0, 1000, false)
|
.records().size());
|
assertEquals(true, stored.get().toString().contains("\"history\":[{"));
|
store.completeValidatedRestore(restored);
|
assertEquals(false, stored.get().toString().contains("\"history\":[{"));
|
}
|
|
@Test
|
void clearsLegacyEmbeddedHistoryForDemoStartup() throws Exception
|
{
|
AtomicReference<Object> 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\":[]}");
|
|
try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0, store(stored)))
|
{
|
assertEquals(0L, simulator.snapshot().collectors().get(0).historyRecordCount());
|
}
|
assertEquals(false, stored.get().toString().contains("\"history\":[{"));
|
}
|
|
@Test
|
void rejectsIncompatibleVersionWithoutOverwritingIt()
|
{
|
AtomicReference<Object> 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<Object> stored = new AtomicReference<>();
|
RedisBtsSimulatorStateStore store = store(stored);
|
SimulatorState current = state(1, "127.0.0.2");
|
store.loadOrInitialize(current);
|
var record = new com.battery.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.battery.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.loadCollectorHistoryPage("dev-sim-collector-1", 0, 1000, false)
|
.records());
|
}
|
|
@Test
|
void readsOnlyRequestedCollectorHistoryRange()
|
{
|
AtomicReference<Object> stored = new AtomicReference<>();
|
RedisTemplate<Object, Object> redis = redis(stored);
|
RedisBtsSimulatorStateStore store = new RedisBtsSimulatorStateStore(redis);
|
SimulatorState current = state(1, "127.0.0.2");
|
store.loadOrInitialize(current);
|
var record = new com.battery.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.battery.device.simulator.BtsSimulatorStateStore.CollectorHistoryAppend(
|
"dev-sim-collector-1", java.util.Collections.nCopies(1010, record))));
|
ListOperations<Object, Object> lists = redis.opsForList();
|
clearInvocations(lists);
|
|
var page = store.loadCollectorHistoryPage("dev-sim-collector-1", 1000, 10, false);
|
|
assertEquals(1010, page.total());
|
assertEquals(10, page.records().size());
|
assertEquals(false, page.hasMore());
|
verify(lists).range(RedisBtsSimulatorStateStore.HISTORY_KEY_PREFIX + "dev-sim-collector-1",
|
1000L, 1009L);
|
}
|
|
@Test
|
void restoresNestedDiscoveryCandidatesFromJson()
|
{
|
AtomicReference<Object> 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<Object> 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<Object> 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<Object> 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<Object> stored)
|
{
|
return new RedisBtsSimulatorStateStore(redis(stored));
|
}
|
|
@SuppressWarnings({ "unchecked", "rawtypes" })
|
private RedisTemplate<Object, Object> redis(AtomicReference<Object> stored)
|
{
|
RedisTemplate<Object, Object> redis = mock(RedisTemplate.class);
|
ValueOperations<Object, Object> values = mock(ValueOperations.class);
|
ListOperations<Object, Object> lists = mock(ListOperations.class);
|
SetOperations<Object, Object> sets = mock(SetOperations.class);
|
Map<Object, List<Object>> 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.<Object>anyCollection()))
|
.thenAnswer(invocation -> {
|
Object key = invocation.getArgument(0);
|
List<Object> records = new ArrayList<>(listValues.getOrDefault(key, List.of()));
|
FastJson2JsonRedisSerializer<Object> serializer =
|
new FastJson2JsonRedisSerializer<>(Object.class);
|
List<Object> 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<Object> records = listValues.getOrDefault(invocation.getArgument(0), List.of());
|
long requestedStart = invocation.getArgument(1);
|
long requestedEnd = invocation.getArgument(2);
|
int start = (int) Math.min(requestedStart, records.size());
|
int end = (int) Math.min(requestedEnd + 1, records.size());
|
return start >= end ? List.of() : List.copyOf(records.subList(start, end));
|
});
|
when(lists.size(any())).thenAnswer(invocation ->
|
(long) listValues.getOrDefault(invocation.getArgument(0), List.of()).size());
|
when(redis.delete(org.mockito.ArgumentMatchers.<Object>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());
|
}
|
}
|