From 20b346ef9d4fcbbbe05abfe08e4f4f0303b0812e Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期日, 30 八月 2026 20:37:45 +0800
Subject: [PATCH] 实现 Ticket12 人工结束与顺序接入

---
 ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImplTest.java |  155 ++++++++++++++++++++++++++++++++++++++++++++++++---
 1 files changed, 146 insertions(+), 9 deletions(-)

diff --git a/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImplTest.java b/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImplTest.java
index 2be39cd..4f1d265 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImplTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImplTest.java
@@ -1,13 +1,18 @@
 package com.ruoyi.system.service.impl;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
+import java.io.InputStream;
 import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
 import java.time.Instant;
 import java.util.List;
 import java.util.Map;
@@ -18,11 +23,15 @@
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.context.annotation.AnnotationConfigApplicationContext;
 import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.device.api.DeviceAdapter;
 import com.ruoyi.device.api.DeviceAdapterException;
+import com.ruoyi.device.discovery.InMemoryDeviceDiscoveryCandidateStore;
+import com.ruoyi.device.discovery.DeviceDiscoveryCandidateStore;
 import com.ruoyi.device.model.ChannelIdentity;
 import com.ruoyi.device.model.DetectedChannel;
+import com.ruoyi.device.model.DetectedDeviceMetadata;
 import com.ruoyi.device.model.DeviceWorkStatus;
 import com.ruoyi.device.model.RealtimeSnapshot;
 import com.ruoyi.device.registry.DeviceAdapterRegistry;
@@ -30,6 +39,7 @@
 import com.ruoyi.system.domain.TDetectionChannel;
 import com.ruoyi.system.domain.TDeviceAdapter;
 import com.ruoyi.system.mapper.TDeviceAdapterMapper;
+import com.ruoyi.system.mapper.TDetectionCabinetMapper;
 import com.ruoyi.system.service.DeviceAdapterErrorCodes;
 
 @ExtendWith(MockitoExtension.class)
@@ -44,13 +54,19 @@
     @Mock
     private DeviceAdapter deviceAdapter;
 
+    @Mock
+    private TDetectionCabinetMapper detectionCabinetMapper;
+
     private DeviceAdapterServiceImpl service;
     private TDeviceAdapter adapter;
+    private InMemoryDeviceDiscoveryCandidateStore candidateStore;
 
     @BeforeEach
     void setUp()
     {
-        service = new DeviceAdapterServiceImpl(adapterMapper, registry);
+        candidateStore = new InMemoryDeviceDiscoveryCandidateStore();
+        service = new DeviceAdapterServiceImpl(adapterMapper, registry,
+                new DeviceDiscoveryPersistenceService(candidateStore, detectionCabinetMapper), candidateStore);
         adapter = new TDeviceAdapter();
         adapter.setAdapterId(7L);
         adapter.setAdapterCode("lab-bts");
@@ -61,6 +77,7 @@
         adapter.setPollIntervalMs(5000);
         adapter.setTimezoneId("Asia/Hong_Kong");
         adapter.setReadOnly(true);
+        adapter.setCapabilities("{\"discovery\":true,\"realtimeObservation\":true,\"historyDownload\":true,\"control\":false}");
         adapter.setStatus("0");
     }
 
@@ -78,6 +95,25 @@
     }
 
     @Test
+    void springSelectsProductionConstructor()
+    {
+        try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext())
+        {
+            context.registerBean(TDeviceAdapterMapper.class, () -> org.mockito.Mockito.mock(TDeviceAdapterMapper.class));
+            context.registerBean(DeviceAdapterRegistry.class, () -> org.mockito.Mockito.mock(DeviceAdapterRegistry.class));
+            context.registerBean(DeviceDiscoveryPersistenceService.class,
+                    () -> org.mockito.Mockito.mock(DeviceDiscoveryPersistenceService.class));
+            context.registerBean(DeviceDiscoveryCandidateStore.class,
+                    () -> org.mockito.Mockito.mock(DeviceDiscoveryCandidateStore.class));
+            context.register(DeviceAdapterServiceImpl.class);
+
+            context.refresh();
+
+            assertNotNull(context.getBean(DeviceAdapterServiceImpl.class));
+        }
+    }
+
+    @Test
     void repeatedDiscoveryUpsertsStableChannelIdentityWithoutDeletingMissingChannels()
     {
         when(adapterMapper.selectDeviceAdapterById(7L)).thenReturn(adapter);
@@ -85,14 +121,32 @@
                 new DetectedChannel(new ChannelIdentity("lab-bts", 22, 1, 2, 3), "192.168.1.35", true),
                 new DetectedChannel(new ChannelIdentity("lab-bts", 22, 1, 2, 4), "192.168.1.35", false)));
         stubRegistryExecution();
-        when(adapterMapper.selectCabinetIdByIdentity(7L, 22, 1)).thenReturn(10L);
-
         assertEquals(2, service.discover(7L).channelCount());
         assertEquals(2, service.discover(7L).channelCount());
 
-        verify(adapterMapper, times(4)).upsertDetectedChannel(any(TDetectionChannel.class));
+        var snapshot = candidateStore.snapshot(Instant.now());
+        assertEquals(1, snapshot.cabinets().size());
+        assertEquals(2, snapshot.cabinets().get(0).channels().size());
         verify(adapterMapper, times(2)).updateDiscoverySuccess(eq(7L), any());
-        verify(adapterMapper, times(2)).updateDetectedCabinet(any(TDetectionCabinet.class));
+    }
+
+    @Test
+    void discoveryPersistsOptionalDeviceMetadataFromAdapterBoundary()
+    {
+        ChannelIdentity identity = new ChannelIdentity("lab-bts", 22, 8, 1, 1);
+        when(adapterMapper.selectDeviceAdapterById(7L)).thenReturn(adapter);
+        when(deviceAdapter.discoverChannels()).thenReturn(List.of(new DetectedChannel(identity, "127.0.0.8", true,
+                new DetectedDeviceMetadata("开发设备 8", "开发模拟器", "BTS Virtual Cabinet",
+                        "02:44:00:16:00:08"))));
+        stubRegistryExecution();
+
+        service.discover(7L);
+
+        var cabinet = candidateStore.snapshot(Instant.now()).cabinets().get(0);
+        assertEquals("开发设备 8", cabinet.name());
+        assertEquals("开发模拟器", cabinet.manufacturer());
+        assertEquals("BTS Virtual Cabinet", cabinet.model());
+        assertEquals("02:44:00:16:00:08", cabinet.mac());
     }
 
     @Test
@@ -106,14 +160,54 @@
                 new RealtimeSnapshot(identity, DeviceWorkStatus.WORKING, 1, 2, "cc", null,
                         BigDecimal.ONE, BigDecimal.TEN, null, null, 3L, 2L, null,
                         Instant.parse("2026-08-26T01:02:03Z"), Map.of())));
-        when(adapterMapper.selectCabinetIdByIdentity(7L, 22, 1)).thenReturn(10L);
         stubRegistryExecution();
 
         service.discover(7L);
 
-        var channel = org.mockito.ArgumentCaptor.forClass(TDetectionChannel.class);
-        verify(adapterMapper).upsertDetectedChannel(channel.capture());
-        assertEquals("WORKING", channel.getValue().getWorkStatus());
+        var channel = candidateStore.snapshot(Instant.now()).cabinets().get(0).channels().get(0);
+        assertEquals(DeviceWorkStatus.WORKING, channel.workStatus());
+        verify(detectionCabinetMapper).updateRegisteredChannelObservation(eq("lab-bts"), eq(22), eq(1), eq(2),
+                eq(3), eq("ONLINE"), eq("WORKING"), any(), eq(true));
+        verify(detectionCabinetMapper).updateRegisteredCabinetObservation(eq("lab-bts"), eq(22), eq(1),
+                eq("ONLINE"), any(), eq(true));
+    }
+
+    @Test
+    void unavailableDevicePersistsExplicitOfflineFactsWithoutRealtimeInquiry()
+    {
+        when(adapterMapper.selectDeviceAdapterById(7L)).thenReturn(adapter);
+        when(deviceAdapter.discoverChannels()).thenReturn(List.of(
+                new DetectedChannel(new ChannelIdentity("lab-bts", 22, 1, 1, 1), "127.0.0.1", false),
+                new DetectedChannel(new ChannelIdentity("lab-bts", 22, 1, 1, 2), "127.0.0.1", false)));
+        stubRegistryExecution();
+
+        service.discover(7L);
+
+        var cabinet = candidateStore.snapshot(Instant.now()).cabinets().get(0);
+        verify(deviceAdapter, never()).inquire(any());
+        assertEquals(false, cabinet.online());
+        assertTrue(cabinet.channels().stream().noneMatch(channel -> channel.online()));
+        verify(detectionCabinetMapper).updateRegisteredChannelObservation(eq("lab-bts"), eq(22), eq(1), eq(1),
+                eq(1), eq("OFFLINE"), eq("UNKNOWN"), any(), eq(false));
+        verify(detectionCabinetMapper).updateRegisteredCabinetObservation(eq("lab-bts"), eq(22), eq(1),
+                eq("OFFLINE"), any(), eq(false));
+    }
+
+    @Test
+    void availableChannelWithoutSuccessfulSnapshotCannotAdvanceLastSeenBoundary()
+    {
+        ChannelIdentity identity = new ChannelIdentity("lab-bts", 22, 1, 1, 1);
+        when(adapterMapper.selectDeviceAdapterById(7L)).thenReturn(adapter);
+        when(deviceAdapter.discoverChannels()).thenReturn(List.of(new DetectedChannel(identity, "127.0.0.1", true)));
+        when(deviceAdapter.inquire(Set.of(identity))).thenReturn(Map.of());
+        stubRegistryExecution();
+
+        service.discover(7L);
+
+        verify(detectionCabinetMapper).updateRegisteredChannelObservation(eq("lab-bts"), eq(22), eq(1), eq(1),
+                eq(1), eq("ONLINE"), eq("UNKNOWN"), any(), eq(false));
+        verify(detectionCabinetMapper).updateRegisteredCabinetObservation(eq("lab-bts"), eq(22), eq(1),
+                eq("ONLINE"), any(), eq(false));
     }
 
     @Test
@@ -133,6 +227,49 @@
         verify(adapterMapper).updateDiscoverySuccess(eq(7L), any());
     }
 
+    @Test
+    void ensuresManagedAdapterFromApplicationProvidedConfiguration()
+    {
+        when(adapterMapper.selectDeviceAdapterByCode("lab-bts")).thenReturn(adapter);
+
+        TDeviceAdapter registered = service.ensureManagedAdapter(adapter);
+
+        verify(adapterMapper).upsertManagedDeviceAdapter(adapter);
+        assertEquals(adapter, registered);
+    }
+
+    @Test
+    void rejectsReservedDevelopmentSimulatorCodeFromDatabaseConfiguration()
+    {
+        adapter.setAdapterCode("dev-sim");
+
+        assertThrows(ServiceException.class, () -> service.insert(adapter));
+        assertThrows(ServiceException.class, () -> service.ensureManagedAdapter(adapter));
+        verify(adapterMapper, never()).insertDeviceAdapter(any());
+        verify(adapterMapper, never()).upsertManagedDeviceAdapter(any());
+    }
+
+    @Test
+    void developmentSimulatorProvisioningIsIdempotentAndReadOnly()
+    {
+        String mapperXml;
+        try (InputStream input = getClass().getResourceAsStream("/mapper/system/TDeviceAdapterMapper.xml"))
+        {
+            if (input == null) throw new AssertionError("缺少设备适配器 Mapper");
+            mapperXml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
+        }
+        catch (Exception exception)
+        {
+            throw new AssertionError("无法读取设备适配器 Mapper", exception);
+        }
+
+        assertTrue(mapperXml.contains("upsertManagedDeviceAdapter"));
+        assertTrue(mapperXml.contains("#{adapterCode}"));
+        assertTrue(mapperXml.contains("#{capabilities}"));
+        assertTrue(mapperXml.contains("on duplicate key update"));
+        assertTrue(mapperXml.contains("del_flag=0"));
+    }
+
     @SuppressWarnings("unchecked")
     private void stubRegistryExecution()
     {

--
Gitblit v1.7.1