|
@@ -0,0 +1,239 @@
|
|
|
|
|
+package com.zksy.web.websocket;
|
|
|
|
|
+
|
|
|
|
|
+import com.alibaba.fastjson.JSONObject;
|
|
|
|
|
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
|
|
|
|
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
|
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
|
|
+import com.zksy.base.domain.EquipmentBase;
|
|
|
|
|
+import com.zksy.base.mapper.EquipmentBaseMapper;
|
|
|
|
|
+import com.zksy.ops.domain.WorkOrder;
|
|
|
|
|
+import com.zksy.ops.service.WorkOrderService;
|
|
|
|
|
+import com.zksy.framework.web.service.TokenService;
|
|
|
|
|
+import com.zksy.base.service.EquipmentStatusService;
|
|
|
|
|
+import org.apache.ibatis.builder.MapperBuilderAssistant;
|
|
|
|
|
+import org.junit.jupiter.api.AfterEach;
|
|
|
|
|
+import org.junit.jupiter.api.BeforeAll;
|
|
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
|
|
+import org.mockito.ArgumentCaptor;
|
|
|
|
|
+import org.springframework.test.util.ReflectionTestUtils;
|
|
|
|
|
+import org.springframework.web.socket.CloseStatus;
|
|
|
|
|
+import org.springframework.web.socket.TextMessage;
|
|
|
|
|
+import org.springframework.web.socket.WebSocketSession;
|
|
|
|
|
+
|
|
|
|
|
+import java.util.Arrays;
|
|
|
|
|
+import java.util.Collections;
|
|
|
|
|
+import java.util.HashMap;
|
|
|
|
|
+import java.util.List;
|
|
|
|
|
+
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNull;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
|
|
+import static org.mockito.Mockito.mock;
|
|
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
|
|
+import static org.mockito.Mockito.verifyNoInteractions;
|
|
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 驾驶舱工单状态增量契约(执行票 13)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>锁定四条外部行为:首扫建基线不推送(REST 才是初始快照);只有 orderStatus
|
|
|
|
|
+ * 真实变化才推(工单状态流转不写 update_time,扫描必须逐单比对状态);帧形状
|
|
|
|
|
+ * 携带 scanIntervalMs/serverUpdatedAt 诚实标注延迟;无全量订阅会话时不查库、
|
|
|
|
|
+ * 失效会话被清理(降级与重连语义)。</p>
|
|
|
|
|
+ */
|
|
|
|
|
+class CockpitWorkOrderDeltaPushTaskTest {
|
|
|
|
|
+
|
|
|
|
|
+ private static final String SESSION_ID = "wo-delta-session-1";
|
|
|
|
|
+
|
|
|
|
|
+ private DeviceStatusWebSocketHandler handler;
|
|
|
|
|
+ private WebSocketSession session;
|
|
|
|
|
+ private SubscriptionManager subscriptionManager;
|
|
|
|
|
+ private WorkOrderService workOrderService;
|
|
|
|
|
+ private EquipmentBaseMapper equipmentBaseMapper;
|
|
|
|
|
+ private CockpitWorkOrderDeltaPushTask task;
|
|
|
|
|
+
|
|
|
|
|
+ @BeforeAll
|
|
|
|
|
+ static void initLambdaCache() {
|
|
|
|
|
+ MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
|
|
|
|
|
+ TableInfoHelper.initTableInfo(assistant, WorkOrder.class);
|
|
|
|
|
+ TableInfoHelper.initTableInfo(assistant, EquipmentBase.class);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @BeforeEach
|
|
|
|
|
+ void setUp() throws Exception {
|
|
|
|
|
+ handler = new DeviceStatusWebSocketHandler();
|
|
|
|
|
+ session = mock(WebSocketSession.class);
|
|
|
|
|
+ HashMap<String, Object> attributes = new HashMap<>();
|
|
|
|
|
+ org.mockito.Mockito.doReturn(SESSION_ID).when(session).getId();
|
|
|
|
|
+ org.mockito.Mockito.doReturn(true).when(session).isOpen();
|
|
|
|
|
+ org.mockito.Mockito.doReturn(attributes).when(session).getAttributes();
|
|
|
|
|
+ ReflectionTestUtils.setField(handler, "equipmentStatusService", mock(EquipmentStatusService.class));
|
|
|
|
|
+ subscriptionManager = new SubscriptionManager();
|
|
|
|
|
+ ReflectionTestUtils.setField(handler, "subscriptionManager", subscriptionManager);
|
|
|
|
|
+ ReflectionTestUtils.setField(handler, "tokenService", mock(TokenService.class));
|
|
|
|
|
+ ReflectionTestUtils.setField(handler, "businessWorkOrderService", mock(WorkOrderService.class));
|
|
|
|
|
+
|
|
|
|
|
+ workOrderService = mock(WorkOrderService.class);
|
|
|
|
|
+ equipmentBaseMapper = mock(EquipmentBaseMapper.class);
|
|
|
|
|
+ task = new CockpitWorkOrderDeltaPushTask();
|
|
|
|
|
+ ReflectionTestUtils.setField(task, "workOrderService", workOrderService);
|
|
|
|
|
+ ReflectionTestUtils.setField(task, "equipmentBaseMapper", equipmentBaseMapper);
|
|
|
|
|
+ ReflectionTestUtils.setField(task, "subscriptionManager", subscriptionManager);
|
|
|
|
|
+ ReflectionTestUtils.setField(task, "webSocketHandler", handler);
|
|
|
|
|
+ ReflectionTestUtils.setField(task, "scanIntervalMs", 60000L);
|
|
|
|
|
+
|
|
|
|
|
+ // 驾驶舱接入:连接 + 全量订阅(免鉴权,ADR 0005)
|
|
|
|
|
+ handler.afterConnectionEstablished(session);
|
|
|
|
|
+ handler.handleTextMessage(session, new TextMessage(
|
|
|
|
|
+ "{\"type\":\"cockpitSubscribe\",\"scope\":\"all\"}"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @AfterEach
|
|
|
|
|
+ void tearDown() {
|
|
|
|
|
+ handler.afterConnectionClosed(session, CloseStatus.NORMAL);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void firstScanEstablishesBaselineWithoutPush() throws Exception {
|
|
|
|
|
+ when(workOrderService.list(any(LambdaQueryWrapper.class))).thenReturn(Arrays.asList(
|
|
|
|
|
+ order(279L, "WO-279", "EQ-1", 5),
|
|
|
|
|
+ order(280L, "WO-280", "EQ-1", 1)));
|
|
|
|
|
+
|
|
|
|
|
+ task.pushWorkOrderStateDelta();
|
|
|
|
|
+
|
|
|
|
|
+ // 只有订阅回执一帧,没有任何增量(REST 才是初始快照)
|
|
|
|
|
+ assertEquals(0, deltaFrames().size());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void stateChangePushesDeltaWithDictionaryStateNames() throws Exception {
|
|
|
|
|
+ when(workOrderService.list(any(LambdaQueryWrapper.class))).thenReturn(Arrays.asList(
|
|
|
|
|
+ order(279L, "WO-279", "EQ-1", 5),
|
|
|
|
|
+ order(280L, "WO-280", "EQ-1", 1)));
|
|
|
|
|
+ task.pushWorkOrderStateDelta(); // 基线
|
|
|
|
|
+
|
|
|
|
|
+ when(workOrderService.list(any(LambdaQueryWrapper.class))).thenReturn(Arrays.asList(
|
|
|
|
|
+ order(279L, "WO-279", "EQ-1", 5),
|
|
|
|
|
+ order(280L, "WO-280", "EQ-1", 5)));
|
|
|
|
|
+ when(equipmentBaseMapper.selectBatchIds(any())).thenReturn(Collections.singletonList(
|
|
|
|
|
+ equipment("EQ-1", "TC-PS-001")));
|
|
|
|
|
+
|
|
|
|
|
+ task.pushWorkOrderStateDelta();
|
|
|
|
|
+
|
|
|
|
|
+ List<JSONObject> frames = deltaFrames();
|
|
|
|
|
+ assertEquals(1, frames.size());
|
|
|
|
|
+ JSONObject frame = frames.get(0);
|
|
|
|
|
+ assertEquals("cockpitWorkOrderStateDelta", frame.getString("type"));
|
|
|
|
|
+ assertEquals(60000L, frame.getLongValue("scanIntervalMs"), "帧必须诚实标注更新延迟上限");
|
|
|
|
|
+ assertNotNull(frame.getString("serverUpdatedAt"));
|
|
|
|
|
+ JSONObject data = frame.getJSONObject("data");
|
|
|
|
|
+ assertEquals(1, data.getIntValue("total"));
|
|
|
|
|
+ JSONObject item = data.getJSONArray("items").getJSONObject(0);
|
|
|
|
|
+ assertEquals("WO-280", item.getString("workOrderCode"));
|
|
|
|
|
+ assertFalse(item.containsKey("orderId"), "增量帧不泄漏数据库主键(REST 读模型同口径)");
|
|
|
|
|
+ assertEquals("TC-PS-001", item.getString("deviceCode"));
|
|
|
|
|
+ assertEquals(5, item.getIntValue("stateCode"));
|
|
|
|
|
+ assertEquals("已结案", item.getString("stateName"), "状态名出自字典单源(票 11)");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void newlyAppearingOrderIsPushedAsDelta() throws Exception {
|
|
|
|
|
+ when(workOrderService.list(any(LambdaQueryWrapper.class))).thenReturn(Collections.singletonList(
|
|
|
|
|
+ order(279L, "WO-279", "EQ-1", 5)));
|
|
|
|
|
+ task.pushWorkOrderStateDelta(); // 基线
|
|
|
|
|
+
|
|
|
|
|
+ when(workOrderService.list(any(LambdaQueryWrapper.class))).thenReturn(Arrays.asList(
|
|
|
|
|
+ order(279L, "WO-279", "EQ-1", 5),
|
|
|
|
|
+ order(281L, "WO-281", null, 0)));
|
|
|
|
|
+ task.pushWorkOrderStateDelta();
|
|
|
|
|
+
|
|
|
|
|
+ JSONObject item = deltaFrames().get(0).getJSONObject("data").getJSONArray("items").getJSONObject(0);
|
|
|
|
|
+ assertEquals("WO-281", item.getString("workOrderCode"));
|
|
|
|
|
+ assertEquals("待派单", item.getString("stateName"));
|
|
|
|
|
+ assertNull(item.getString("deviceCode"), "无设备关联时编码显式 null,不静默丢");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void unchangedOrdersProduceNoSecondPush() throws Exception {
|
|
|
|
|
+ when(workOrderService.list(any(LambdaQueryWrapper.class))).thenReturn(Collections.singletonList(
|
|
|
|
|
+ order(279L, "WO-279", "EQ-1", 5)));
|
|
|
|
|
+ task.pushWorkOrderStateDelta();
|
|
|
|
|
+ task.pushWorkOrderStateDelta();
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(0, deltaFrames().size(), "状态未变不得重复推送");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void unknownStateCodeKeepsRawCodeWithExplicitNullName() throws Exception {
|
|
|
|
|
+ when(workOrderService.list(any(LambdaQueryWrapper.class))).thenReturn(Collections.singletonList(
|
|
|
|
|
+ order(279L, "WO-279", "EQ-1", 5)));
|
|
|
|
|
+ task.pushWorkOrderStateDelta();
|
|
|
|
|
+
|
|
|
|
|
+ when(workOrderService.list(any(LambdaQueryWrapper.class))).thenReturn(Collections.singletonList(
|
|
|
|
|
+ order(279L, "WO-279", "EQ-1", 99)));
|
|
|
|
|
+ task.pushWorkOrderStateDelta();
|
|
|
|
|
+
|
|
|
|
|
+ JSONObject item = deltaFrames().get(0).getJSONObject("data").getJSONArray("items").getJSONObject(0);
|
|
|
|
|
+ assertEquals(99, item.getIntValue("stateCode"), "脏状态码原样上报");
|
|
|
|
|
+ assertNull(item.getString("stateName"), "字典外的码不编造名称");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void noCockpitSessionMeansNoDatabaseScan() {
|
|
|
|
|
+ subscriptionManager.removeSession(SESSION_ID);
|
|
|
|
|
+
|
|
|
|
|
+ task.pushWorkOrderStateDelta();
|
|
|
|
|
+
|
|
|
|
|
+ verifyNoInteractions(workOrderService, equipmentBaseMapper);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void staleAllScopeSessionIsPrunedWhenSendFails() throws Exception {
|
|
|
|
|
+ when(workOrderService.list(any(LambdaQueryWrapper.class))).thenReturn(Collections.singletonList(
|
|
|
|
|
+ order(279L, "WO-279", "EQ-1", 5)));
|
|
|
|
|
+ task.pushWorkOrderStateDelta(); // 基线
|
|
|
|
|
+
|
|
|
|
|
+ // 模拟断线后会话未清理:订阅集合里有一个已无活连接的会话
|
|
|
|
|
+ subscriptionManager.subscribeAll("dead-session");
|
|
|
|
|
+ when(workOrderService.list(any(LambdaQueryWrapper.class))).thenReturn(Collections.singletonList(
|
|
|
|
|
+ order(279L, "WO-279", "EQ-1", 1)));
|
|
|
|
|
+
|
|
|
|
|
+ task.pushWorkOrderStateDelta();
|
|
|
|
|
+
|
|
|
|
|
+ assertFalse(subscriptionManager.getAllScopeSessions().contains("dead-session"),
|
|
|
|
|
+ "发送失败的失效会话必须被清出订阅集合");
|
|
|
|
|
+ assertTrue(subscriptionManager.getAllScopeSessions().contains(SESSION_ID));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // --- 断言与夹具 ---
|
|
|
|
|
+
|
|
|
|
|
+ /** 从会话已发消息中筛出工单状态增量帧(订阅回执不算)。 */
|
|
|
|
|
+ private List<JSONObject> deltaFrames() throws Exception {
|
|
|
|
|
+ ArgumentCaptor<TextMessage> messages = ArgumentCaptor.forClass(TextMessage.class);
|
|
|
|
|
+ verify(session, org.mockito.Mockito.atLeast(0)).sendMessage(messages.capture());
|
|
|
|
|
+ return messages.getAllValues().stream()
|
|
|
|
|
+ .map(item -> JSONObject.parseObject(item.getPayload()))
|
|
|
|
|
+ .filter(item -> "cockpitWorkOrderStateDelta".equals(item.getString("type")))
|
|
|
|
|
+ .collect(java.util.stream.Collectors.toList());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static WorkOrder order(Long orderId, String orderNo, String deviceId, int status) {
|
|
|
|
|
+ WorkOrder order = new WorkOrder();
|
|
|
|
|
+ order.setOrderId(orderId);
|
|
|
|
|
+ order.setOrderNo(orderNo);
|
|
|
|
|
+ order.setDeviceId(deviceId);
|
|
|
|
|
+ order.setOrderStatus(status);
|
|
|
|
|
+ return order;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static EquipmentBase equipment(String equipmentId, String equipmentCode) {
|
|
|
|
|
+ EquipmentBase equipment = new EquipmentBase();
|
|
|
|
|
+ equipment.setEquipmentId(equipmentId);
|
|
|
|
|
+ equipment.setEquipmentCode(equipmentCode);
|
|
|
|
|
+ return equipment;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|