Merge branch 'test/cleaning-coverage'
This commit is contained in:
@@ -85,12 +85,12 @@ public class SignalLossRuleProcessor {
|
||||
// 解析 deviceId 和 areaId
|
||||
// Key 格式:iot:clean:signal:loss:{deviceId}:{areaId}
|
||||
String[] parts = key.split(":");
|
||||
if (parts.length < 5) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Long deviceId = Long.parseLong(parts[3]);
|
||||
Long areaId = Long.parseLong(parts[4]);
|
||||
if (parts.length < 6) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Long deviceId = Long.parseLong(parts[4]);
|
||||
Long areaId = Long.parseLong(parts[5]);
|
||||
|
||||
// 检查超时
|
||||
checkTimeoutForDevice(deviceId, areaId);
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.viewsh.module.iot.service.rule.clean.detector;
|
||||
|
||||
import com.viewsh.module.iot.dal.dataobject.integration.clean.BeaconPresenceConfig;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* RSSI 滑动窗口检测器测试
|
||||
*/
|
||||
class RssiSlidingWindowDetectorTest {
|
||||
|
||||
private final RssiSlidingWindowDetector detector = new RssiSlidingWindowDetector();
|
||||
|
||||
@Test
|
||||
void testDetect_ArriveConfirmed() {
|
||||
// 准备配置:进入阈值 -70,窗口 3,命中 2
|
||||
BeaconPresenceConfig.EnterConfig enterConfig = new BeaconPresenceConfig.EnterConfig();
|
||||
enterConfig.setRssiThreshold(-70);
|
||||
enterConfig.setWindowSize(3);
|
||||
enterConfig.setHitCount(2);
|
||||
|
||||
// 退出配置(此场景不重要,设宽一点)
|
||||
BeaconPresenceConfig.ExitConfig exitConfig = new BeaconPresenceConfig.ExitConfig();
|
||||
exitConfig.setWeakRssiThreshold(-85);
|
||||
exitConfig.setHitCount(2);
|
||||
|
||||
// 场景1:3次采样,[-65, -68, -75] -> 2次 >= -70 -> 应该确认到达
|
||||
List<Integer> window = Arrays.asList(-65, -68, -75);
|
||||
RssiSlidingWindowDetector.DetectionResult result = detector.detect(
|
||||
window, enterConfig, exitConfig, RssiSlidingWindowDetector.AreaState.OUT_AREA);
|
||||
|
||||
assertEquals(RssiSlidingWindowDetector.DetectionResult.ARRIVE_CONFIRMED, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDetect_ArriveFailed_NotEnoughHits() {
|
||||
BeaconPresenceConfig.EnterConfig enterConfig = new BeaconPresenceConfig.EnterConfig();
|
||||
enterConfig.setRssiThreshold(-70);
|
||||
enterConfig.setWindowSize(3);
|
||||
enterConfig.setHitCount(2);
|
||||
|
||||
BeaconPresenceConfig.ExitConfig exitConfig = new BeaconPresenceConfig.ExitConfig();
|
||||
exitConfig.setWeakRssiThreshold(-85);
|
||||
exitConfig.setHitCount(2);
|
||||
|
||||
// 场景:3次采样,[-65, -75, -80] -> 只有1次 >= -70 -> 无变化
|
||||
List<Integer> window = Arrays.asList(-65, -75, -80);
|
||||
RssiSlidingWindowDetector.DetectionResult result = detector.detect(
|
||||
window, enterConfig, exitConfig, RssiSlidingWindowDetector.AreaState.OUT_AREA);
|
||||
|
||||
assertEquals(RssiSlidingWindowDetector.DetectionResult.NO_CHANGE, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDetect_LeaveConfirmed() {
|
||||
BeaconPresenceConfig.EnterConfig enterConfig = new BeaconPresenceConfig.EnterConfig();
|
||||
enterConfig.setRssiThreshold(-70);
|
||||
enterConfig.setHitCount(2);
|
||||
|
||||
// 退出配置:弱阈值 -85,窗口 3,命中 2
|
||||
BeaconPresenceConfig.ExitConfig exitConfig = new BeaconPresenceConfig.ExitConfig();
|
||||
exitConfig.setWeakRssiThreshold(-85);
|
||||
exitConfig.setWindowSize(3);
|
||||
exitConfig.setHitCount(2);
|
||||
|
||||
// 场景:[-90, -88, -80] -> 2次 < -85 -> 应该确认离开
|
||||
List<Integer> window = Arrays.asList(-90, -88, -80);
|
||||
RssiSlidingWindowDetector.DetectionResult result = detector.detect(
|
||||
window, enterConfig, exitConfig, RssiSlidingWindowDetector.AreaState.IN_AREA);
|
||||
|
||||
assertEquals(RssiSlidingWindowDetector.DetectionResult.LEAVE_CONFIRMED, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDetect_LeaveConfirmed_WithMissingSignal() {
|
||||
BeaconPresenceConfig.EnterConfig enterConfig = new BeaconPresenceConfig.EnterConfig();
|
||||
enterConfig.setRssiThreshold(-70);
|
||||
enterConfig.setHitCount(2);
|
||||
|
||||
BeaconPresenceConfig.ExitConfig exitConfig = new BeaconPresenceConfig.ExitConfig();
|
||||
exitConfig.setWeakRssiThreshold(-85);
|
||||
exitConfig.setWindowSize(3);
|
||||
exitConfig.setHitCount(2);
|
||||
|
||||
// 场景:[-999, -999, -80] -> -999表示丢失,满足退出条件
|
||||
List<Integer> window = Arrays.asList(-999, -999, -80);
|
||||
RssiSlidingWindowDetector.DetectionResult result = detector.detect(
|
||||
window, enterConfig, exitConfig, RssiSlidingWindowDetector.AreaState.IN_AREA);
|
||||
|
||||
assertEquals(RssiSlidingWindowDetector.DetectionResult.LEAVE_CONFIRMED, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDetect_NoChange_SignalFluctuation() {
|
||||
BeaconPresenceConfig.EnterConfig enterConfig = new BeaconPresenceConfig.EnterConfig();
|
||||
enterConfig.setRssiThreshold(-70);
|
||||
enterConfig.setHitCount(2);
|
||||
|
||||
BeaconPresenceConfig.ExitConfig exitConfig = new BeaconPresenceConfig.ExitConfig();
|
||||
exitConfig.setWeakRssiThreshold(-85);
|
||||
exitConfig.setHitCount(2);
|
||||
|
||||
// 场景:在区域内,信号变弱但未达退出阈值 [-80, -82, -84] -> 都在 -70 和 -85 之间 -> 无变化
|
||||
List<Integer> window = Arrays.asList(-80, -82, -84);
|
||||
RssiSlidingWindowDetector.DetectionResult result = detector.detect(
|
||||
window, enterConfig, exitConfig, RssiSlidingWindowDetector.AreaState.IN_AREA);
|
||||
|
||||
assertEquals(RssiSlidingWindowDetector.DetectionResult.NO_CHANGE, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testEmptyWindow() {
|
||||
BeaconPresenceConfig.EnterConfig enterConfig = new BeaconPresenceConfig.EnterConfig();
|
||||
BeaconPresenceConfig.ExitConfig exitConfig = new BeaconPresenceConfig.ExitConfig();
|
||||
|
||||
RssiSlidingWindowDetector.DetectionResult result = detector.detect(
|
||||
Collections.emptyList(), enterConfig, exitConfig, RssiSlidingWindowDetector.AreaState.OUT_AREA);
|
||||
|
||||
assertEquals(RssiSlidingWindowDetector.DetectionResult.NO_CHANGE, result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.viewsh.module.iot.service.rule.clean.processor;
|
||||
|
||||
import com.viewsh.module.iot.core.integration.constants.CleanOrderTopics;
|
||||
import com.viewsh.module.iot.dal.dataobject.integration.clean.BeaconPresenceConfig;
|
||||
import com.viewsh.module.iot.dal.dataobject.integration.clean.CleanOrderIntegrationConfig;
|
||||
import com.viewsh.module.iot.dal.redis.clean.BeaconArrivedTimeRedisDAO;
|
||||
import com.viewsh.module.iot.dal.redis.clean.BeaconRssiWindowRedisDAO;
|
||||
import com.viewsh.module.iot.dal.redis.clean.DeviceCurrentOrderRedisDAO;
|
||||
import com.viewsh.module.iot.dal.redis.clean.SignalLossRedisDAO;
|
||||
import com.viewsh.module.iot.service.integration.clean.CleanOrderIntegrationConfigService;
|
||||
import org.apache.rocketmq.spring.core.RocketMQTemplate;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.messaging.Message;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SignalLossRuleProcessorTest {
|
||||
|
||||
@InjectMocks
|
||||
private SignalLossRuleProcessor processor;
|
||||
|
||||
@Mock
|
||||
private SignalLossRedisDAO signalLossRedisDAO;
|
||||
@Mock
|
||||
private BeaconArrivedTimeRedisDAO arrivedTimeRedisDAO;
|
||||
@Mock
|
||||
private BeaconRssiWindowRedisDAO windowRedisDAO;
|
||||
@Mock
|
||||
private DeviceCurrentOrderRedisDAO deviceCurrentOrderRedisDAO;
|
||||
@Mock
|
||||
private CleanOrderIntegrationConfigService configService;
|
||||
@Mock
|
||||
private RocketMQTemplate rocketMQTemplate;
|
||||
@Mock
|
||||
private StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
private final Long DEVICE_ID = 1001L;
|
||||
private final Long AREA_ID = 2001L;
|
||||
private final String DEVICE_KEY = "badge-001";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// Mock Redis keys scan
|
||||
when(stringRedisTemplate.keys(anyString())).thenReturn(
|
||||
Set.of("iot:clean:signal:loss:" + DEVICE_ID + ":" + AREA_ID)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCheckLossTimeout_TriggerComplete() {
|
||||
// Setup times
|
||||
long now = System.currentTimeMillis();
|
||||
long firstLossTime = now - 6 * 60 * 1000; // 6 minutes ago
|
||||
long lastLossTime = now;
|
||||
long arrivedTime = now - 20 * 60 * 1000; // 20 minutes ago
|
||||
|
||||
// Mock DAOs
|
||||
when(signalLossRedisDAO.getFirstLossTime(DEVICE_ID, AREA_ID)).thenReturn(firstLossTime);
|
||||
when(signalLossRedisDAO.getLastLossTime(DEVICE_ID, AREA_ID)).thenReturn(lastLossTime);
|
||||
when(arrivedTimeRedisDAO.getArrivedTime(DEVICE_ID, AREA_ID)).thenReturn(arrivedTime);
|
||||
|
||||
// Mock Current Order
|
||||
DeviceCurrentOrderRedisDAO.OrderCacheInfo orderInfo = new DeviceCurrentOrderRedisDAO.OrderCacheInfo();
|
||||
orderInfo.setOrderId(500L);
|
||||
orderInfo.setAreaId(AREA_ID); // Same area, valid
|
||||
when(deviceCurrentOrderRedisDAO.getCurrentOrder(DEVICE_ID)).thenReturn(orderInfo);
|
||||
|
||||
// Mock Config
|
||||
BeaconPresenceConfig.ExitConfig exitConfig = new BeaconPresenceConfig.ExitConfig();
|
||||
exitConfig.setLossTimeoutMinutes(5);
|
||||
exitConfig.setMinValidWorkMinutes(10);
|
||||
|
||||
BeaconPresenceConfig bpConfig = new BeaconPresenceConfig();
|
||||
bpConfig.setExit(exitConfig);
|
||||
|
||||
CleanOrderIntegrationConfig mainConfig = new CleanOrderIntegrationConfig();
|
||||
mainConfig.setBeaconPresence(bpConfig);
|
||||
|
||||
CleanOrderIntegrationConfigService.AreaDeviceConfigWrapper wrapper =
|
||||
new CleanOrderIntegrationConfigService.AreaDeviceConfigWrapper();
|
||||
wrapper.setConfig(mainConfig);
|
||||
wrapper.setDeviceKey(DEVICE_KEY);
|
||||
|
||||
when(configService.getConfigWrapperByDeviceId(DEVICE_ID)).thenReturn(wrapper);
|
||||
|
||||
// Execute
|
||||
processor.checkLossTimeout();
|
||||
|
||||
// Verify
|
||||
// 1. Should send complete message
|
||||
verify(rocketMQTemplate).syncSend(eq(CleanOrderTopics.ORDER_COMPLETE), any(Message.class));
|
||||
|
||||
// 2. Should clear redis data
|
||||
verify(signalLossRedisDAO).clearLossRecord(DEVICE_ID, AREA_ID);
|
||||
verify(arrivedTimeRedisDAO).clearArrivedTime(DEVICE_ID, AREA_ID);
|
||||
verify(windowRedisDAO).clearWindow(DEVICE_ID, AREA_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCheckLossTimeout_Suppressed_InvalidDuration() {
|
||||
// Setup times
|
||||
long now = System.currentTimeMillis();
|
||||
long firstLossTime = now - 6 * 60 * 1000; // 6 minutes ago (timeout)
|
||||
long lastLossTime = now;
|
||||
long arrivedTime = now - 5 * 60 * 1000; // Only 5 minutes work (min is 10)
|
||||
|
||||
// Mock DAOs
|
||||
when(signalLossRedisDAO.getFirstLossTime(DEVICE_ID, AREA_ID)).thenReturn(firstLossTime);
|
||||
when(signalLossRedisDAO.getLastLossTime(DEVICE_ID, AREA_ID)).thenReturn(lastLossTime);
|
||||
when(arrivedTimeRedisDAO.getArrivedTime(DEVICE_ID, AREA_ID)).thenReturn(arrivedTime);
|
||||
|
||||
// Mock Current Order (Valid area)
|
||||
DeviceCurrentOrderRedisDAO.OrderCacheInfo orderInfo = new DeviceCurrentOrderRedisDAO.OrderCacheInfo();
|
||||
orderInfo.setOrderId(500L);
|
||||
orderInfo.setAreaId(AREA_ID);
|
||||
when(deviceCurrentOrderRedisDAO.getCurrentOrder(DEVICE_ID)).thenReturn(orderInfo);
|
||||
|
||||
// Mock Config
|
||||
BeaconPresenceConfig.ExitConfig exitConfig = new BeaconPresenceConfig.ExitConfig();
|
||||
exitConfig.setLossTimeoutMinutes(5);
|
||||
exitConfig.setMinValidWorkMinutes(10);
|
||||
|
||||
BeaconPresenceConfig bpConfig = new BeaconPresenceConfig();
|
||||
bpConfig.setExit(exitConfig);
|
||||
|
||||
CleanOrderIntegrationConfig mainConfig = new CleanOrderIntegrationConfig();
|
||||
mainConfig.setBeaconPresence(bpConfig);
|
||||
|
||||
CleanOrderIntegrationConfigService.AreaDeviceConfigWrapper wrapper =
|
||||
new CleanOrderIntegrationConfigService.AreaDeviceConfigWrapper();
|
||||
wrapper.setConfig(mainConfig);
|
||||
wrapper.setDeviceKey(DEVICE_KEY);
|
||||
|
||||
when(configService.getConfigWrapperByDeviceId(DEVICE_ID)).thenReturn(wrapper);
|
||||
|
||||
// Execute
|
||||
processor.checkLossTimeout();
|
||||
|
||||
// Verify
|
||||
// 1. Should NOT send complete message
|
||||
verify(rocketMQTemplate, never()).syncSend(eq(CleanOrderTopics.ORDER_COMPLETE), any(Message.class));
|
||||
|
||||
// 2. Should send Audit Event (TTS)
|
||||
verify(rocketMQTemplate, atLeastOnce()).syncSend(eq(CleanOrderTopics.ORDER_AUDIT), any(Message.class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package com.viewsh.module.ops.environment.service.cleanorder;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.viewsh.module.iot.api.device.IotDeviceControlApi;
|
||||
import com.viewsh.module.ops.core.dispatch.DispatchEngine;
|
||||
import com.viewsh.module.ops.core.dispatch.model.DispatchResult;
|
||||
import com.viewsh.module.ops.core.event.OrderEventPublisher;
|
||||
import com.viewsh.module.ops.core.lifecycle.OrderLifecycleManager;
|
||||
import com.viewsh.module.ops.core.lifecycle.model.OrderTransitionRequest;
|
||||
import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO;
|
||||
import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderMapper;
|
||||
import com.viewsh.module.ops.enums.PriorityEnum;
|
||||
import com.viewsh.module.ops.enums.WorkOrderStatusEnum;
|
||||
import com.viewsh.module.ops.environment.dal.dataobject.workorder.OpsOrderCleanExtDO;
|
||||
import com.viewsh.module.ops.environment.dal.mysql.workorder.OpsOrderCleanExtMapper;
|
||||
import com.viewsh.module.ops.environment.integration.consumer.*;
|
||||
import com.viewsh.framework.common.pojo.CommonResult;
|
||||
import com.viewsh.module.ops.environment.integration.listener.CleanOrderEventListener;
|
||||
import com.viewsh.module.ops.environment.service.cleanorder.CleanOrderServiceImpl;
|
||||
import com.viewsh.module.ops.environment.service.voice.VoiceBroadcastService;
|
||||
import com.viewsh.module.ops.infrastructure.code.OrderCodeGenerator;
|
||||
import com.viewsh.module.ops.infrastructure.id.OrderIdGenerator;
|
||||
import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecorder;
|
||||
import com.viewsh.module.ops.api.queue.OrderQueueService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Spy;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
public class CleanOrderEndToEndTest {
|
||||
|
||||
// 核心组件
|
||||
@InjectMocks
|
||||
private CleanOrderServiceImpl cleanOrderService;
|
||||
|
||||
@InjectMocks
|
||||
private CleanOrderCreateEventHandler createEventHandler;
|
||||
|
||||
@InjectMocks
|
||||
private CleanOrderArriveEventHandler arriveEventHandler;
|
||||
|
||||
@InjectMocks
|
||||
private CleanOrderCompleteEventHandler completeEventHandler;
|
||||
|
||||
@InjectMocks
|
||||
private CleanOrderConfirmEventHandler confirmEventHandler;
|
||||
|
||||
@Mock
|
||||
private CleanOrderEventListener cleanOrderEventListener;
|
||||
|
||||
// 依赖 Mocks
|
||||
@Mock
|
||||
private OpsOrderMapper opsOrderMapper;
|
||||
@Mock
|
||||
private OpsOrderCleanExtMapper cleanExtMapper;
|
||||
@Mock
|
||||
private OrderIdGenerator orderIdGenerator;
|
||||
@Mock
|
||||
private OrderCodeGenerator orderCodeGenerator;
|
||||
@Mock
|
||||
private OrderEventPublisher orderEventPublisher;
|
||||
@Mock
|
||||
private OrderQueueService orderQueueService;
|
||||
@Mock
|
||||
private DispatchEngine dispatchEngine;
|
||||
@Mock
|
||||
private OrderLifecycleManager orderLifecycleManager;
|
||||
@Mock
|
||||
private IotDeviceControlApi iotDeviceControlApi;
|
||||
@Mock
|
||||
private StringRedisTemplate stringRedisTemplate;
|
||||
@Mock
|
||||
private ValueOperations<String, String> valueOperations;
|
||||
@Mock
|
||||
private VoiceBroadcastService voiceBroadcastService;
|
||||
@Mock
|
||||
private EventLogRecorder eventLogRecorder;
|
||||
|
||||
@Spy
|
||||
private ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
// 模拟数据库
|
||||
private Map<Long, OpsOrderDO> orderDB = new HashMap<>();
|
||||
private Map<Long, OpsOrderCleanExtDO> cleanExtDB = new HashMap<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// 配置 Redis Mock
|
||||
lenient().when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||
lenient().when(valueOperations.setIfAbsent(anyString(), anyString(), anyLong(), any(TimeUnit.class)))
|
||||
.thenReturn(true);
|
||||
|
||||
// 配置 ID 生成器
|
||||
lenient().when(orderIdGenerator.generate()).thenAnswer(i -> System.currentTimeMillis());
|
||||
lenient().when(orderCodeGenerator.generate(anyString())).thenAnswer(i -> "WO" + System.currentTimeMillis());
|
||||
|
||||
// 配置 Mapper 模拟 (基础 CRUD)
|
||||
lenient().when(opsOrderMapper.insert(any(OpsOrderDO.class))).thenAnswer(i -> {
|
||||
OpsOrderDO order = i.getArgument(0);
|
||||
if (order.getId() == null) {
|
||||
order.setId(System.currentTimeMillis());
|
||||
}
|
||||
orderDB.put(order.getId(), order);
|
||||
return 1;
|
||||
});
|
||||
lenient().when(opsOrderMapper.selectById(anyLong())).thenAnswer(i -> orderDB.get(i.getArgument(0)));
|
||||
lenient().when(opsOrderMapper.updateById(any(OpsOrderDO.class))).thenAnswer(i -> {
|
||||
OpsOrderDO update = i.getArgument(0);
|
||||
OpsOrderDO existing = orderDB.get(update.getId());
|
||||
if (existing != null) {
|
||||
if (update.getStatus() != null) existing.setStatus(update.getStatus());
|
||||
if (update.getAssigneeId() != null) existing.setAssigneeId(update.getAssigneeId());
|
||||
if (update.getAssigneeDeviceId() != null) existing.setAssigneeDeviceId(update.getAssigneeDeviceId());
|
||||
if (update.getAssigneeDeviceKey() != null) existing.setAssigneeDeviceKey(update.getAssigneeDeviceKey());
|
||||
}
|
||||
return 1;
|
||||
});
|
||||
|
||||
lenient().when(cleanExtMapper.insert(any(OpsOrderCleanExtDO.class))).thenAnswer(i -> {
|
||||
OpsOrderCleanExtDO ext = i.getArgument(0);
|
||||
cleanExtDB.put(ext.getOpsOrderId(), ext);
|
||||
return 1;
|
||||
});
|
||||
|
||||
// 注入 CleanOrderEventListener
|
||||
injectField(cleanOrderService, "cleanOrderEventListener", cleanOrderEventListener);
|
||||
|
||||
// Stub IotDeviceControlApi for resetTrafficCounter
|
||||
lenient().when(iotDeviceControlApi.resetTrafficCounter(any()))
|
||||
.thenReturn(CommonResult.success(true));
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// TR-01 ~ TR-03: 工单自动触发与创建测试
|
||||
// ==========================================
|
||||
|
||||
@Test
|
||||
void testTR01_AutoCreateFromTraffic() throws Exception {
|
||||
injectField(createEventHandler, "cleanOrderService", cleanOrderService);
|
||||
injectField(cleanOrderService, "opsOrderMapper", opsOrderMapper);
|
||||
injectField(cleanOrderService, "cleanExtMapper", cleanExtMapper);
|
||||
injectField(cleanOrderService, "orderIdGenerator", orderIdGenerator);
|
||||
injectField(cleanOrderService, "orderCodeGenerator", orderCodeGenerator);
|
||||
injectField(cleanOrderService, "orderEventPublisher", orderEventPublisher);
|
||||
|
||||
// 准备数据
|
||||
String eventJson = "{" +
|
||||
"\"eventId\":\"evt-001\"," +
|
||||
"\"areaId\":101," +
|
||||
"\"triggerSource\":\"IOT_TRAFFIC\"," +
|
||||
"\"triggerDeviceId\":8001," +
|
||||
"\"triggerDeviceKey\":\"traffic-cam-01\"," +
|
||||
"\"triggerData\":{\"actualCount\":150,\"baseValue\":1000}" +
|
||||
"}";
|
||||
|
||||
// 执行
|
||||
createEventHandler.onMessage(eventJson);
|
||||
|
||||
// 验证
|
||||
// 1. 验证工单创建
|
||||
ArgumentCaptor<OpsOrderDO> orderCaptor = ArgumentCaptor.forClass(OpsOrderDO.class);
|
||||
verify(opsOrderMapper).insert((OpsOrderDO) orderCaptor.capture());
|
||||
OpsOrderDO order = orderCaptor.getValue();
|
||||
assertEquals(101L, order.getAreaId());
|
||||
assertEquals("TRAFFIC", order.getSourceType());
|
||||
assertEquals("IOT_TRAFFIC", order.getTriggerSource());
|
||||
|
||||
// 2. 验证扩展表创建
|
||||
verify(cleanExtMapper).insert(any(OpsOrderCleanExtDO.class));
|
||||
|
||||
// 3. 验证计数器重置调用
|
||||
verify(iotDeviceControlApi).resetTrafficCounter(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testTR02_Idempotency() throws Exception {
|
||||
injectField(createEventHandler, "cleanOrderService", cleanOrderService);
|
||||
|
||||
// 模拟 Redis 第一次返回 true,第二次返回 false
|
||||
when(valueOperations.setIfAbsent(anyString(), eq("1"), anyLong(), any(TimeUnit.class)))
|
||||
.thenReturn(true)
|
||||
.thenReturn(false);
|
||||
|
||||
String eventJson = "{\"eventId\":\"evt-duplicate\",\"areaId\":101,\"triggerSource\":\"IOT_TRAFFIC\"}";
|
||||
|
||||
// 第一次调用
|
||||
createEventHandler.onMessage(eventJson);
|
||||
// 第二次调用
|
||||
createEventHandler.onMessage(eventJson);
|
||||
|
||||
// 验证只创建了一次工单
|
||||
verify(opsOrderMapper, times(1)).insert(any(OpsOrderDO.class));
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// DP-01 ~ DP-03: 智能调度测试 (模拟)
|
||||
// ==========================================
|
||||
|
||||
@Test
|
||||
void testDP03_P0UrgentInterrupt() {
|
||||
injectField(cleanOrderService, "dispatchEngine", dispatchEngine);
|
||||
injectField(cleanOrderService, "opsOrderMapper", opsOrderMapper);
|
||||
|
||||
// 准备一个 P0 紧急工单
|
||||
Long orderId = 999L;
|
||||
Long cleanerId = 2001L;
|
||||
|
||||
OpsOrderDO order = OpsOrderDO.builder()
|
||||
.id(orderId)
|
||||
.priority(PriorityEnum.P0.getPriority())
|
||||
.build();
|
||||
orderDB.put(orderId, order);
|
||||
|
||||
// 模拟调度引擎返回成功
|
||||
when(dispatchEngine.urgentInterrupt(eq(orderId), eq(cleanerId)))
|
||||
.thenReturn(DispatchResult.success("P0插队成功", cleanerId));
|
||||
|
||||
// 执行派单
|
||||
cleanOrderService.enqueueAndDispatch(orderId, cleanerId, true);
|
||||
|
||||
// 验证
|
||||
verify(dispatchEngine).urgentInterrupt(eq(orderId), eq(cleanerId));
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// AV-01 ~ AV-03: 到岗校验测试
|
||||
// ==========================================
|
||||
|
||||
@Test
|
||||
void testAV01_BeaconAutoArrive() throws Exception {
|
||||
injectField(arriveEventHandler, "orderLifecycleManager", orderLifecycleManager);
|
||||
|
||||
// 准备工单:状态 DISPATCHED
|
||||
Long orderId = 1001L;
|
||||
OpsOrderDO order = OpsOrderDO.builder()
|
||||
.id(orderId)
|
||||
.status(WorkOrderStatusEnum.DISPATCHED.getStatus())
|
||||
.assigneeId(2001L)
|
||||
.build();
|
||||
orderDB.put(orderId, order);
|
||||
|
||||
String eventJson = "{" +
|
||||
"\"eventId\":\"evt-arrive-01\"," +
|
||||
"\"orderId\":1001," +
|
||||
"\"deviceId\":5001," +
|
||||
"\"deviceKey\":\"badge-01\"," +
|
||||
"\"areaId\":101," +
|
||||
"\"triggerSource\":\"IOT_BEACON\"," +
|
||||
"\"triggerData\":{\"beaconMac\":\"F0:C8:60:1D:10:BB\"}" +
|
||||
"}";
|
||||
|
||||
// 执行
|
||||
arriveEventHandler.onMessage(eventJson);
|
||||
|
||||
// 验证
|
||||
// 1. 验证设备ID更新
|
||||
ArgumentCaptor<OpsOrderDO> orderCaptor = ArgumentCaptor.forClass(OpsOrderDO.class);
|
||||
verify(opsOrderMapper).updateById((OpsOrderDO) orderCaptor.capture());
|
||||
OpsOrderDO updatedOrder = orderCaptor.getValue();
|
||||
assertEquals(orderId, updatedOrder.getId());
|
||||
assertEquals(5001L, updatedOrder.getAssigneeDeviceId());
|
||||
|
||||
// 2. 验证调用了生命周期管理器的 transition
|
||||
ArgumentCaptor<OrderTransitionRequest> reqCaptor = ArgumentCaptor.forClass(OrderTransitionRequest.class);
|
||||
verify(orderLifecycleManager).transition(reqCaptor.capture());
|
||||
OrderTransitionRequest req = reqCaptor.getValue();
|
||||
assertEquals(orderId, req.getOrderId());
|
||||
assertEquals(WorkOrderStatusEnum.ARRIVED, req.getTargetStatus());
|
||||
assertTrue(req.getReason().contains("自动到岗确认"));
|
||||
|
||||
// 3. 验证 Redis 缓存更新
|
||||
verify(valueOperations).set(contains("ops:clean:device:order:5001"), anyString(), anyLong(), any(TimeUnit.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAV02_StatusInterceptor() throws Exception {
|
||||
// 准备工单:状态 PENDING (不允许到岗)
|
||||
Long orderId = 1002L;
|
||||
OpsOrderDO order = OpsOrderDO.builder()
|
||||
.id(orderId)
|
||||
.status(WorkOrderStatusEnum.PENDING.getStatus())
|
||||
.build();
|
||||
orderDB.put(orderId, order);
|
||||
|
||||
String eventJson = "{\"eventId\":\"evt-arrive-02\",\"orderId\":1002,\"deviceId\":5001}";
|
||||
|
||||
// 执行
|
||||
arriveEventHandler.onMessage(eventJson);
|
||||
|
||||
// 验证:不应该调用 transition
|
||||
verify(orderLifecycleManager, never()).transition(any());
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// CP-01 ~ CP-03: 离岗与结单测试
|
||||
// ==========================================
|
||||
|
||||
@Test
|
||||
void testCP01_SignalLossAutoComplete() throws Exception {
|
||||
injectField(completeEventHandler, "orderLifecycleManager", orderLifecycleManager);
|
||||
injectField(completeEventHandler, "cleanOrderService", cleanOrderService);
|
||||
injectField(cleanOrderService, "dispatchEngine", dispatchEngine);
|
||||
|
||||
// 准备工单:状态 ARRIVED
|
||||
Long orderId = 1003L;
|
||||
OpsOrderDO order = OpsOrderDO.builder()
|
||||
.id(orderId)
|
||||
.status(WorkOrderStatusEnum.ARRIVED.getStatus())
|
||||
.assigneeId(2001L)
|
||||
.build();
|
||||
orderDB.put(orderId, order);
|
||||
|
||||
String eventJson = "{" +
|
||||
"\"eventId\":\"evt-complete-01\"," +
|
||||
"\"orderId\":1003," +
|
||||
"\"deviceId\":5001," +
|
||||
"\"triggerSource\":\"IOT_SIGNAL_LOSS\"," +
|
||||
"\"triggerData\":{\"durationMs\":1800000}" +
|
||||
"}";
|
||||
|
||||
// 模拟 autoDispatchNext 调用成功
|
||||
when(dispatchEngine.autoDispatchNext(eq(orderId), eq(2001L)))
|
||||
.thenReturn(DispatchResult.success("Success", 2001L));
|
||||
|
||||
// 执行
|
||||
completeEventHandler.onMessage(eventJson);
|
||||
|
||||
// 验证
|
||||
// 1. 验证调用了 completeOrder
|
||||
verify(orderLifecycleManager).completeOrder(eq(orderId), eq(null), contains("信号丢失超时"));
|
||||
|
||||
// 2. 验证清理了 Redis 缓存
|
||||
verify(stringRedisTemplate).delete(contains("ops:clean:device:order:5001"));
|
||||
|
||||
// 3. 验证触发了自动调度下一单
|
||||
verify(dispatchEngine).autoDispatchNext(eq(orderId), eq(2001L));
|
||||
}
|
||||
|
||||
// 简单的反射注入辅助方法
|
||||
private void injectField(Object target, String fieldName, Object value) {
|
||||
try {
|
||||
java.lang.reflect.Field field = getField(target.getClass(), fieldName);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to inject field: " + fieldName, e);
|
||||
}
|
||||
}
|
||||
|
||||
private java.lang.reflect.Field getField(Class<?> clazz, String fieldName) throws NoSuchFieldException {
|
||||
try {
|
||||
return clazz.getDeclaredField(fieldName);
|
||||
} catch (NoSuchFieldException e) {
|
||||
if (clazz.getSuperclass() != null) {
|
||||
return getField(clazz.getSuperclass(), fieldName);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.viewsh.module.ops.environment.service.dispatch;
|
||||
|
||||
import com.viewsh.module.ops.api.queue.OrderQueueDTO;
|
||||
import com.viewsh.module.ops.api.queue.OrderQueueService;
|
||||
import com.viewsh.module.ops.core.dispatch.model.AssigneeRecommendation;
|
||||
import com.viewsh.module.ops.core.dispatch.model.OrderDispatchContext;
|
||||
import com.viewsh.module.ops.enums.CleanerStatusEnum;
|
||||
import com.viewsh.module.ops.enums.PriorityEnum;
|
||||
import com.viewsh.module.ops.environment.dal.dataobject.cleaner.OpsCleanerStatusDO;
|
||||
import com.viewsh.module.ops.environment.service.cleaner.CleanerStatusService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CleanerAreaAssignStrategyTest {
|
||||
|
||||
@InjectMocks
|
||||
private CleanerAreaAssignStrategy strategy;
|
||||
|
||||
@Mock
|
||||
private CleanerStatusService cleanerStatusService;
|
||||
|
||||
@Mock
|
||||
private OrderQueueService orderQueueService;
|
||||
|
||||
@Mock
|
||||
private com.viewsh.module.ops.core.dispatch.DispatchEngine dispatchEngine;
|
||||
|
||||
@Test
|
||||
void testRecommend_SelectIdleCleaner() {
|
||||
// Setup
|
||||
OpsCleanerStatusDO c1 = new OpsCleanerStatusDO();
|
||||
c1.setUserId(1L);
|
||||
c1.setStatus(CleanerStatusEnum.BUSY.getCode());
|
||||
c1.setBatteryLevel(80);
|
||||
|
||||
OpsCleanerStatusDO c2 = new OpsCleanerStatusDO();
|
||||
c2.setUserId(2L);
|
||||
c2.setStatus(CleanerStatusEnum.IDLE.getCode());
|
||||
c2.setBatteryLevel(90);
|
||||
c2.setLastHeartbeatTime(LocalDateTime.now());
|
||||
|
||||
when(cleanerStatusService.listCleanersByArea(101L)).thenReturn(Arrays.asList(c1, c2));
|
||||
|
||||
OrderDispatchContext context = OrderDispatchContext.builder()
|
||||
.orderId(100L)
|
||||
.areaId(101L)
|
||||
.priority(PriorityEnum.P1)
|
||||
.build();
|
||||
|
||||
// Execute
|
||||
AssigneeRecommendation rec = strategy.recommend(context);
|
||||
|
||||
// Verify
|
||||
assertTrue(rec.hasRecommendation());
|
||||
assertEquals(2L, rec.getAssigneeId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRecommend_SelectLeastBusyCleaner_WhenAllBusy() {
|
||||
// Setup
|
||||
OpsCleanerStatusDO c1 = new OpsCleanerStatusDO();
|
||||
c1.setUserId(1L);
|
||||
c1.setStatus(CleanerStatusEnum.BUSY.getCode());
|
||||
|
||||
OpsCleanerStatusDO c2 = new OpsCleanerStatusDO();
|
||||
c2.setUserId(2L);
|
||||
c2.setStatus(CleanerStatusEnum.BUSY.getCode());
|
||||
|
||||
when(cleanerStatusService.listCleanersByArea(101L)).thenReturn(Arrays.asList(c1, c2));
|
||||
|
||||
// C1 has 5 tasks, C2 has 1 task
|
||||
when(orderQueueService.getWaitingTasksByUserId(1L)).thenReturn(Arrays.asList(new OrderQueueDTO(), new OrderQueueDTO()));
|
||||
when(orderQueueService.getWaitingTasksByUserId(2L)).thenReturn(Collections.singletonList(new OrderQueueDTO()));
|
||||
|
||||
OrderDispatchContext context = OrderDispatchContext.builder()
|
||||
.areaId(101L)
|
||||
.priority(PriorityEnum.P1)
|
||||
.build();
|
||||
|
||||
// Execute
|
||||
AssigneeRecommendation rec = strategy.recommend(context);
|
||||
|
||||
// Verify
|
||||
assertEquals(2L, rec.getAssigneeId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.viewsh.module.ops.environment.service.dispatch;
|
||||
|
||||
import com.viewsh.module.ops.api.queue.OrderQueueDTO;
|
||||
import com.viewsh.module.ops.api.queue.OrderQueueService;
|
||||
import com.viewsh.module.ops.core.dispatch.model.DispatchDecision;
|
||||
import com.viewsh.module.ops.core.dispatch.model.DispatchPath;
|
||||
import com.viewsh.module.ops.core.dispatch.model.OrderDispatchContext;
|
||||
import com.viewsh.module.ops.enums.CleanerStatusEnum;
|
||||
import com.viewsh.module.ops.enums.PriorityEnum;
|
||||
import com.viewsh.module.ops.environment.dal.dataobject.cleaner.OpsCleanerStatusDO;
|
||||
import com.viewsh.module.ops.environment.service.cleaner.CleanerStatusService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.Collections;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CleanerPriorityScheduleStrategyTest {
|
||||
|
||||
@InjectMocks
|
||||
private CleanerPriorityScheduleStrategy strategy;
|
||||
|
||||
@Mock
|
||||
private CleanerStatusService cleanerStatusService;
|
||||
|
||||
@Mock
|
||||
private OrderQueueService orderQueueService;
|
||||
|
||||
@Mock
|
||||
private com.viewsh.module.ops.core.dispatch.DispatchEngine dispatchEngine;
|
||||
|
||||
@Test
|
||||
void testDecide_P0_Interrupt() {
|
||||
// Setup
|
||||
OpsCleanerStatusDO c1 = new OpsCleanerStatusDO();
|
||||
c1.setUserId(1L);
|
||||
c1.setStatus(CleanerStatusEnum.BUSY.getCode());
|
||||
c1.setCurrentOpsOrderId(500L);
|
||||
|
||||
when(cleanerStatusService.getStatus(1L)).thenReturn(c1);
|
||||
|
||||
OrderDispatchContext context = OrderDispatchContext.builder()
|
||||
.priority(PriorityEnum.P0)
|
||||
.recommendedAssigneeId(1L)
|
||||
.build();
|
||||
|
||||
// Execute
|
||||
DispatchDecision decision = strategy.decide(context);
|
||||
|
||||
// Verify
|
||||
assertEquals(DispatchPath.INTERRUPT_AND_DISPATCH, decision.getPath());
|
||||
assertEquals(500L, decision.getInterruptedOrderId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDecide_Normal_EnqueueOnly() {
|
||||
OpsCleanerStatusDO c1 = new OpsCleanerStatusDO();
|
||||
c1.setUserId(1L);
|
||||
c1.setStatus(CleanerStatusEnum.BUSY.getCode());
|
||||
|
||||
when(cleanerStatusService.getStatus(1L)).thenReturn(c1);
|
||||
|
||||
OrderDispatchContext context = OrderDispatchContext.builder()
|
||||
.priority(PriorityEnum.P1)
|
||||
.recommendedAssigneeId(1L)
|
||||
.build();
|
||||
|
||||
DispatchDecision decision = strategy.decide(context);
|
||||
|
||||
assertEquals(DispatchPath.ENQUEUE_ONLY, decision.getPath());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user