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()); + } +}