From 9750088ca6b198e867de024158d94d78aa572d11 Mon Sep 17 00:00:00 2001 From: lzh Date: Thu, 22 Jan 2026 23:55:05 +0800 Subject: [PATCH 1/2] test: Add comprehensive end-to-end tests for cleaning work order module --- .../cleanorder/CleanOrderEndToEndTest.java | 376 ++++++++++++++++++ 1 file changed, 376 insertions(+) create mode 100644 viewsh-module-ops/viewsh-module-environment-biz/src/test/java/com/viewsh/module/ops/environment/service/cleanorder/CleanOrderEndToEndTest.java diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/test/java/com/viewsh/module/ops/environment/service/cleanorder/CleanOrderEndToEndTest.java b/viewsh-module-ops/viewsh-module-environment-biz/src/test/java/com/viewsh/module/ops/environment/service/cleanorder/CleanOrderEndToEndTest.java new file mode 100644 index 0000000..b0f151b --- /dev/null +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/test/java/com/viewsh/module/ops/environment/service/cleanorder/CleanOrderEndToEndTest.java @@ -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 valueOperations; + @Mock + private VoiceBroadcastService voiceBroadcastService; + @Mock + private EventLogRecorder eventLogRecorder; + + @Spy + private ObjectMapper objectMapper = new ObjectMapper(); + + // 模拟数据库 + private Map orderDB = new HashMap<>(); + private Map 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 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 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 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; + } + } +} From 5bb3ff6979a64a3fa43ff092f96035e537bfc533 Mon Sep 17 00:00:00 2001 From: lzh Date: Fri, 23 Jan 2026 11:44:49 +0800 Subject: [PATCH 2/2] test: Add comprehensive tests for dispatch strategies and IoT signal processing, fix SignalLossRuleProcessor bug --- .../processor/SignalLossRuleProcessor.java | 12 +- .../RssiSlidingWindowDetectorTest.java | 126 ++++++++++++++ .../SignalLossRuleProcessorTest.java | 157 ++++++++++++++++++ .../CleanerAreaAssignStrategyTest.java | 99 +++++++++++ .../CleanerPriorityScheduleStrategyTest.java | 78 +++++++++ 5 files changed, 466 insertions(+), 6 deletions(-) create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/test/java/com/viewsh/module/iot/service/rule/clean/detector/RssiSlidingWindowDetectorTest.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/test/java/com/viewsh/module/iot/service/rule/clean/processor/SignalLossRuleProcessorTest.java create mode 100644 viewsh-module-ops/viewsh-module-environment-biz/src/test/java/com/viewsh/module/ops/environment/service/dispatch/CleanerAreaAssignStrategyTest.java create mode 100644 viewsh-module-ops/viewsh-module-environment-biz/src/test/java/com/viewsh/module/ops/environment/service/dispatch/CleanerPriorityScheduleStrategyTest.java diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/processor/SignalLossRuleProcessor.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/processor/SignalLossRuleProcessor.java index 2d1f0fd..f6bef01 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/processor/SignalLossRuleProcessor.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/processor/SignalLossRuleProcessor.java @@ -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); diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/test/java/com/viewsh/module/iot/service/rule/clean/detector/RssiSlidingWindowDetectorTest.java b/viewsh-module-iot/viewsh-module-iot-server/src/test/java/com/viewsh/module/iot/service/rule/clean/detector/RssiSlidingWindowDetectorTest.java new file mode 100644 index 0000000..dea7d56 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/test/java/com/viewsh/module/iot/service/rule/clean/detector/RssiSlidingWindowDetectorTest.java @@ -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 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 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 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 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 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); + } +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/test/java/com/viewsh/module/iot/service/rule/clean/processor/SignalLossRuleProcessorTest.java b/viewsh-module-iot/viewsh-module-iot-server/src/test/java/com/viewsh/module/iot/service/rule/clean/processor/SignalLossRuleProcessorTest.java new file mode 100644 index 0000000..c07dc56 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/test/java/com/viewsh/module/iot/service/rule/clean/processor/SignalLossRuleProcessorTest.java @@ -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)); + } +} diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/test/java/com/viewsh/module/ops/environment/service/dispatch/CleanerAreaAssignStrategyTest.java b/viewsh-module-ops/viewsh-module-environment-biz/src/test/java/com/viewsh/module/ops/environment/service/dispatch/CleanerAreaAssignStrategyTest.java new file mode 100644 index 0000000..c66639e --- /dev/null +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/test/java/com/viewsh/module/ops/environment/service/dispatch/CleanerAreaAssignStrategyTest.java @@ -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()); + } +} diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/test/java/com/viewsh/module/ops/environment/service/dispatch/CleanerPriorityScheduleStrategyTest.java b/viewsh-module-ops/viewsh-module-environment-biz/src/test/java/com/viewsh/module/ops/environment/service/dispatch/CleanerPriorityScheduleStrategyTest.java new file mode 100644 index 0000000..9ebb1d2 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/test/java/com/viewsh/module/ops/environment/service/dispatch/CleanerPriorityScheduleStrategyTest.java @@ -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()); + } +}