|
|
@@ -8,51 +8,352 @@ import com.emoon.ai.device.domain.DeviceRegistryDO;
|
|
|
import com.emoon.ai.device.domain.DeviceSceneBindingDO;
|
|
|
import com.emoon.ai.device.mapper.DeviceRegistryMapper;
|
|
|
import com.emoon.ai.device.mapper.DeviceSceneBindingMapper;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
+import org.junit.jupiter.api.Nested;
|
|
|
import org.junit.jupiter.api.Test;
|
|
|
|
|
|
+import java.util.List;
|
|
|
import java.util.Map;
|
|
|
|
|
|
-import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
-import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
-import static org.mockito.Mockito.mock;
|
|
|
-import static org.mockito.Mockito.when;
|
|
|
+import static org.assertj.core.api.Assertions.assertThat;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.Mockito.*;
|
|
|
|
|
|
+@DisplayName("DeviceRegistryFacadeImpl — main flow coverage")
|
|
|
class DeviceRegistryFacadeImplTest {
|
|
|
|
|
|
- private final DeviceRegistryMapper deviceMapper = mock(DeviceRegistryMapper.class);
|
|
|
- private final DeviceSceneBindingMapper sceneMapper = mock(DeviceSceneBindingMapper.class);
|
|
|
- private final DeviceRegistryFacadeImpl facade = new DeviceRegistryFacadeImpl(deviceMapper, sceneMapper);
|
|
|
+ private DeviceRegistryMapper deviceMapper;
|
|
|
+ private DeviceSceneBindingMapper sceneMapper;
|
|
|
+ private DeviceRegistryFacadeImpl facade;
|
|
|
|
|
|
- @Test
|
|
|
- void unknownDeviceRegistersAsPendingEvenWhenTypeIsKnown() {
|
|
|
- when(deviceMapper.selectByDeviceId("NEW-KIOSK")).thenReturn(null);
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ deviceMapper = mock(DeviceRegistryMapper.class);
|
|
|
+ sceneMapper = mock(DeviceSceneBindingMapper.class);
|
|
|
+ facade = new DeviceRegistryFacadeImpl(deviceMapper, sceneMapper);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ═══════════════════════════════════════════════════════════════
|
|
|
+ // REGISTER
|
|
|
+ // ═══════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+ @Nested
|
|
|
+ @DisplayName("register()")
|
|
|
+ class Register {
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("unknown device → pending with B level")
|
|
|
+ void unknownDeviceRegistersAsPending() {
|
|
|
+ when(deviceMapper.selectByDeviceId("NEW-KIOSK")).thenReturn(null);
|
|
|
+
|
|
|
+ DeviceRegisterResult result = facade.register(new DeviceRegisterCommand(
|
|
|
+ "1", "H001", "NEW-KIOSK", "self_service_kiosk",
|
|
|
+ "vendor", "model", "0.1.0", Map.of("touch", true), Map.of()));
|
|
|
+
|
|
|
+ assertThat(result.activateStatus()).isEqualTo("pending");
|
|
|
+ assertThat(result.admissionLevel()).isEqualTo("B");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("existing activated device → returns activated")
|
|
|
+ void existingActivatedReturnsActivated() {
|
|
|
+ DeviceRegistryDO existing = deviceStub("KIOSK-001", "activated", "A");
|
|
|
+ when(deviceMapper.selectByDeviceId("KIOSK-001")).thenReturn(existing);
|
|
|
+
|
|
|
+ DeviceRegisterResult result = facade.register(new DeviceRegisterCommand(
|
|
|
+ "1", "H001", "KIOSK-001", "self_service_kiosk",
|
|
|
+ null, null, "0.2.0", Map.of(), Map.of()));
|
|
|
+
|
|
|
+ assertThat(result.activateStatus()).isEqualTo("activated");
|
|
|
+ assertThat(result.admissionLevel()).isEqualTo("A");
|
|
|
+ assertThat(result.reason()).isNull();
|
|
|
+ verify(deviceMapper, never()).insert(any(DeviceRegistryDO.class));
|
|
|
+ }
|
|
|
|
|
|
- DeviceRegisterResult result = facade.register(new DeviceRegisterCommand(
|
|
|
- "1", "H001", "NEW-KIOSK", "self_service_kiosk",
|
|
|
- "vendor", "model", "0.1.0", Map.of("touch", true), Map.of()));
|
|
|
+ @Test
|
|
|
+ @DisplayName("existing online device → returns activated")
|
|
|
+ void existingOnlineReturnsActivated() {
|
|
|
+ DeviceRegistryDO existing = deviceStub("KIOSK-002", "online", "A");
|
|
|
+ when(deviceMapper.selectByDeviceId("KIOSK-002")).thenReturn(existing);
|
|
|
|
|
|
- assertEquals("pending", result.activateStatus());
|
|
|
- assertEquals("B", result.admissionLevel());
|
|
|
+ DeviceRegisterResult result = facade.register(new DeviceRegisterCommand(
|
|
|
+ "1", "H001", "KIOSK-002", "self_service_kiosk",
|
|
|
+ null, null, "0.2.0", Map.of(), Map.of()));
|
|
|
+
|
|
|
+ assertThat(result.activateStatus()).isEqualTo("activated");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("existing rejected device → returns rejected with reason")
|
|
|
+ void existingRejectedReturnsRejected() {
|
|
|
+ DeviceRegistryDO existing = deviceStub("KIOSK-003", "rejected", "C");
|
|
|
+ when(deviceMapper.selectByDeviceId("KIOSK-003")).thenReturn(existing);
|
|
|
+
|
|
|
+ DeviceRegisterResult result = facade.register(new DeviceRegisterCommand(
|
|
|
+ "1", "H001", "KIOSK-003", "self_service_kiosk",
|
|
|
+ null, null, "0.1.0", Map.of(), Map.of()));
|
|
|
+
|
|
|
+ assertThat(result.activateStatus()).isEqualTo("rejected");
|
|
|
+ assertThat(result.admissionLevel()).isEqualTo("C");
|
|
|
+ assertThat(result.reason()).contains("拒绝");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("existing pending device → returns pending with reason")
|
|
|
+ void existingPendingReturnsPending() {
|
|
|
+ DeviceRegistryDO existing = deviceStub("KIOSK-004", "pending", "B");
|
|
|
+ when(deviceMapper.selectByDeviceId("KIOSK-004")).thenReturn(existing);
|
|
|
+
|
|
|
+ DeviceRegisterResult result = facade.register(new DeviceRegisterCommand(
|
|
|
+ "1", "H001", "KIOSK-004", "self_service_kiosk",
|
|
|
+ null, null, "0.1.0", Map.of(), Map.of()));
|
|
|
+
|
|
|
+ assertThat(result.activateStatus()).isEqualTo("pending");
|
|
|
+ assertThat(result.admissionLevel()).isEqualTo("B");
|
|
|
+ assertThat(result.reason()).contains("审核");
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- @Test
|
|
|
- void sceneSupportsCapabilitiesStoredAsJsonArray() {
|
|
|
- DeviceRegistryDO device = new DeviceRegistryDO();
|
|
|
- device.setDeviceId("EMOON-KIOSK-001");
|
|
|
- device.setDeviceType("self_service_kiosk");
|
|
|
- device.setCapabilitiesJson("[\"touch\",\"camera\"]");
|
|
|
- when(deviceMapper.selectByDeviceIdAndProjectId("EMOON-KIOSK-001", "1")).thenReturn(device);
|
|
|
-
|
|
|
- DeviceSceneBindingDO binding = new DeviceSceneBindingDO();
|
|
|
- binding.setSceneCode("outpatient_kiosk");
|
|
|
- binding.setUiTemplate("kiosk_home_v1");
|
|
|
- binding.setAgentBindingsJson("{\"defaultAgent\":\"opd-guide-agent\",\"allowedAgents\":[\"opd-guide-agent\"]}");
|
|
|
- binding.setCardScopesJson("[\"department-selection\"]");
|
|
|
- when(sceneMapper.selectByDeviceId("EMOON-KIOSK-001")).thenReturn(binding);
|
|
|
-
|
|
|
- SceneProfileResult result = facade.scene("1", "EMOON-KIOSK-001");
|
|
|
-
|
|
|
- assertTrue(result.capabilities().contains("touch"));
|
|
|
- assertTrue(result.capabilities().contains("camera"));
|
|
|
+ // ═══════════════════════════════════════════════════════════════
|
|
|
+ // HEARTBEAT
|
|
|
+ // ═══════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+ @Nested
|
|
|
+ @DisplayName("heartbeat()")
|
|
|
+ class Heartbeat {
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("unknown device → returns false")
|
|
|
+ void unknownDeviceReturnsFalse() {
|
|
|
+ when(deviceMapper.selectByDeviceIdAndProjectId("UNKNOWN", "1")).thenReturn(null);
|
|
|
+
|
|
|
+ boolean ok = facade.heartbeat("1", "UNKNOWN", "0.1.0", null, "online", null);
|
|
|
+
|
|
|
+ assertThat(ok).isFalse();
|
|
|
+ verify(deviceMapper, never()).updateById(any(DeviceRegistryDO.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("rejected device → returns false, no update")
|
|
|
+ void rejectedDeviceReturnsFalse() {
|
|
|
+ DeviceRegistryDO device = deviceStub("REJ-001", "rejected", "D");
|
|
|
+ when(deviceMapper.selectByDeviceIdAndProjectId("REJ-001", "1")).thenReturn(device);
|
|
|
+
|
|
|
+ boolean ok = facade.heartbeat("1", "REJ-001", "0.1.0", null, "online", null);
|
|
|
+
|
|
|
+ assertThat(ok).isFalse();
|
|
|
+ verify(deviceMapper, never()).updateById(any(DeviceRegistryDO.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("pending device → heartbeat returns false, status unchanged")
|
|
|
+ void pendingDeviceHeartbeatReturnsFalse() {
|
|
|
+ DeviceRegistryDO device = new DeviceRegistryDO();
|
|
|
+ device.setDeviceId("PEND-HB");
|
|
|
+ device.setProjectId("1");
|
|
|
+ device.setStatus("pending");
|
|
|
+ device.setAdmissionLevel("B");
|
|
|
+ device.setClientVersion("0.1.0");
|
|
|
+ when(deviceMapper.selectByDeviceIdAndProjectId(eq("PEND-HB"), eq("1"))).thenReturn(device);
|
|
|
+
|
|
|
+ boolean ok = facade.heartbeat("1", "PEND-HB", "0.1.0", null, "online", null);
|
|
|
+
|
|
|
+ assertThat(ok).isFalse();
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("activated device → updates status and timestamps, returns true")
|
|
|
+ void activatedDeviceUpdatesStatus() {
|
|
|
+ DeviceRegistryDO device = deviceStub("ACT-001", "activated", "A");
|
|
|
+ when(deviceMapper.selectByDeviceIdAndProjectId("ACT-001", "1")).thenReturn(device);
|
|
|
+
|
|
|
+ boolean ok = facade.heartbeat("1", "ACT-001", "0.2.0", "10.0.0.1", "online", "outpatient_kiosk");
|
|
|
+
|
|
|
+ assertThat(ok).isTrue();
|
|
|
+ verify(deviceMapper).updateById(org.mockito.Mockito.<DeviceRegistryDO>argThat(d ->
|
|
|
+ "online".equals(d.getStatus())
|
|
|
+ && "0.2.0".equals(d.getClientVersion())
|
|
|
+ && d.getLastHeartbeat() != null
|
|
|
+ ));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("online device → preserves online status, returns true")
|
|
|
+ void onlineDevicePreservesStatus() {
|
|
|
+ DeviceRegistryDO device = deviceStub("ONL-001", "online", "A");
|
|
|
+ when(deviceMapper.selectByDeviceIdAndProjectId("ONL-001", "1")).thenReturn(device);
|
|
|
+
|
|
|
+ boolean ok = facade.heartbeat("1", "ONL-001", "0.3.0", null, null, null);
|
|
|
+
|
|
|
+ assertThat(ok).isTrue();
|
|
|
+ // When client doesn't send status, defaults to "online" — fine for already-active device
|
|
|
+ verify(deviceMapper).updateById(org.mockito.Mockito.<DeviceRegistryDO>argThat(d -> "online".equals(d.getStatus())));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("activated device with offline status → status changes to offline")
|
|
|
+ void activatedDeviceGoesOffline() {
|
|
|
+ DeviceRegistryDO device = deviceStub("ACT-002", "activated", "A");
|
|
|
+ when(deviceMapper.selectByDeviceIdAndProjectId("ACT-002", "1")).thenReturn(device);
|
|
|
+
|
|
|
+ boolean ok = facade.heartbeat("1", "ACT-002", "0.1.0", null, "offline", null);
|
|
|
+
|
|
|
+ assertThat(ok).isTrue();
|
|
|
+ verify(deviceMapper).updateById(org.mockito.Mockito.<DeviceRegistryDO>argThat(d -> "offline".equals(d.getStatus())));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ═══════════════════════════════════════════════════════════════
|
|
|
+ // SCENE
|
|
|
+ // ═══════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+ @Nested
|
|
|
+ @DisplayName("scene()")
|
|
|
+ class Scene {
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("unknown device → returns null")
|
|
|
+ void unknownDeviceReturnsNull() {
|
|
|
+ when(deviceMapper.selectByDeviceIdAndProjectId("UNKNOWN", "1")).thenReturn(null);
|
|
|
+
|
|
|
+ SceneProfileResult result = facade.scene("1", "UNKNOWN");
|
|
|
+
|
|
|
+ assertThat(result).isNull();
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("pending device → returns null (blocked from core flow)")
|
|
|
+ void pendingDeviceReturnsNull() {
|
|
|
+ DeviceRegistryDO device = new DeviceRegistryDO();
|
|
|
+ device.setDeviceId("PEND-SCENE");
|
|
|
+ device.setProjectId("1");
|
|
|
+ device.setStatus("pending");
|
|
|
+ device.setAdmissionLevel("B");
|
|
|
+ when(deviceMapper.selectByDeviceIdAndProjectId(eq("PEND-SCENE"), eq("1"))).thenReturn(device);
|
|
|
+
|
|
|
+ SceneProfileResult result = facade.scene("1", "PEND-SCENE");
|
|
|
+
|
|
|
+ assertThat(result).isNull();
|
|
|
+ verify(sceneMapper, never()).selectByDeviceId(anyString());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("rejected device → returns null (blocked from core flow)")
|
|
|
+ void rejectedDeviceReturnsNull() {
|
|
|
+ DeviceRegistryDO device = new DeviceRegistryDO();
|
|
|
+ device.setDeviceId("REJ-SCENE");
|
|
|
+ device.setProjectId("1");
|
|
|
+ device.setStatus("rejected");
|
|
|
+ device.setAdmissionLevel("D");
|
|
|
+ when(deviceMapper.selectByDeviceIdAndProjectId(eq("REJ-SCENE"), eq("1"))).thenReturn(device);
|
|
|
+
|
|
|
+ SceneProfileResult result = facade.scene("1", "REJ-SCENE");
|
|
|
+
|
|
|
+ assertThat(result).isNull();
|
|
|
+ verify(sceneMapper, never()).selectByDeviceId(anyString());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("activated device with scene → returns full profile")
|
|
|
+ void activatedDeviceWithSceneReturnsFullProfile() {
|
|
|
+ DeviceRegistryDO device = deviceStub("KIOSK-001", "activated", "A");
|
|
|
+ device.setDeviceType("self_service_kiosk");
|
|
|
+ device.setCapabilitiesJson("[\"touch\",\"camera\",\"id_card_ocr\"]");
|
|
|
+ when(deviceMapper.selectByDeviceIdAndProjectId("KIOSK-001", "1")).thenReturn(device);
|
|
|
+
|
|
|
+ DeviceSceneBindingDO binding = new DeviceSceneBindingDO();
|
|
|
+ binding.setSceneCode("outpatient_kiosk");
|
|
|
+ binding.setUiTemplate("kiosk_home_v1");
|
|
|
+ binding.setAgentBindingsJson("{\"defaultAgent\":\"opd-guide-agent\",\"allowedAgents\":[\"opd-guide-agent\",\"opd-registration-agent\"]}");
|
|
|
+ binding.setCardScopesJson("[\"department-selection\",\"doctor-selection\"]");
|
|
|
+ when(sceneMapper.selectByDeviceId("KIOSK-001")).thenReturn(binding);
|
|
|
+
|
|
|
+ SceneProfileResult result = facade.scene("1", "KIOSK-001");
|
|
|
+
|
|
|
+ assertThat(result).isNotNull();
|
|
|
+ assertThat(result.deviceId()).isEqualTo("KIOSK-001");
|
|
|
+ assertThat(result.deviceType()).isEqualTo("self_service_kiosk");
|
|
|
+ assertThat(result.sceneCode()).isEqualTo("outpatient_kiosk");
|
|
|
+ assertThat(result.homeTemplate()).isEqualTo("kiosk_home_v1");
|
|
|
+ assertThat(result.defaultAgent()).isEqualTo("opd-guide-agent");
|
|
|
+ assertThat(result.allowedAgents()).containsExactly("opd-guide-agent", "opd-registration-agent");
|
|
|
+ assertThat(result.allowedCards()).containsExactly("department-selection", "doctor-selection");
|
|
|
+ assertThat(result.capabilities()).contains("touch", "camera", "id_card_ocr");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("online device with scene → returns full profile")
|
|
|
+ void onlineDeviceWithSceneReturnsFullProfile() {
|
|
|
+ DeviceRegistryDO device = deviceStub("KIOSK-002", "online", "A");
|
|
|
+ device.setDeviceType("robot");
|
|
|
+ device.setCapabilitiesJson("[\"voice\",\"navigation\"]");
|
|
|
+ when(deviceMapper.selectByDeviceIdAndProjectId("KIOSK-002", "1")).thenReturn(device);
|
|
|
+
|
|
|
+ DeviceSceneBindingDO binding = new DeviceSceneBindingDO();
|
|
|
+ binding.setSceneCode("outpatient_robot");
|
|
|
+ binding.setUiTemplate("robot_home_v1");
|
|
|
+ binding.setAgentBindingsJson("{\"defaultAgent\":\"opd-guide-agent\",\"allowedAgents\":[\"opd-guide-agent\"]}");
|
|
|
+ binding.setCardScopesJson("[\"route-card\"]");
|
|
|
+ when(sceneMapper.selectByDeviceId("KIOSK-002")).thenReturn(binding);
|
|
|
+
|
|
|
+ SceneProfileResult result = facade.scene("1", "KIOSK-002");
|
|
|
+
|
|
|
+ assertThat(result).isNotNull();
|
|
|
+ assertThat(result.deviceType()).isEqualTo("robot");
|
|
|
+ assertThat(result.allowedAgents()).containsExactly("opd-guide-agent");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("activated device without scene binding → returns minimal profile")
|
|
|
+ void activatedDeviceWithoutSceneBindingReturnsMinimalProfile() {
|
|
|
+ DeviceRegistryDO device = deviceStub("KIOSK-003", "activated", "A");
|
|
|
+ device.setDeviceType("self_service_kiosk");
|
|
|
+ device.setCapabilitiesJson(null);
|
|
|
+ when(deviceMapper.selectByDeviceIdAndProjectId("KIOSK-003", "1")).thenReturn(device);
|
|
|
+ when(sceneMapper.selectByDeviceId("KIOSK-003")).thenReturn(null);
|
|
|
+
|
|
|
+ SceneProfileResult result = facade.scene("1", "KIOSK-003");
|
|
|
+
|
|
|
+ assertThat(result).isNotNull();
|
|
|
+ assertThat(result.deviceId()).isEqualTo("KIOSK-003");
|
|
|
+ assertThat(result.sceneCode()).isNull();
|
|
|
+ assertThat(result.homeTemplate()).isEqualTo("default");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("JSON object capabilities → parsed as key=true entries")
|
|
|
+ void jsonObjectCapabilitiesParsedCorrectly() {
|
|
|
+ DeviceRegistryDO device = deviceStub("KIOSK-004", "activated", "A");
|
|
|
+ device.setCapabilitiesJson("{\"touch\":true,\"camera\":true,\"printer\":false}");
|
|
|
+ when(deviceMapper.selectByDeviceIdAndProjectId("KIOSK-004", "1")).thenReturn(device);
|
|
|
+
|
|
|
+ DeviceSceneBindingDO binding = new DeviceSceneBindingDO();
|
|
|
+ binding.setAgentBindingsJson("{\"defaultAgent\":\"d\",\"allowedAgents\":[]}");
|
|
|
+ binding.setCardScopesJson("[]");
|
|
|
+ when(sceneMapper.selectByDeviceId("KIOSK-004")).thenReturn(binding);
|
|
|
+
|
|
|
+ SceneProfileResult result = facade.scene("1", "KIOSK-004");
|
|
|
+
|
|
|
+ assertThat(result.capabilities()).contains("touch", "camera");
|
|
|
+ assertThat(result.capabilities()).doesNotContain("printer"); // false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ═══════════════════════════════════════════════════════════════
|
|
|
+ // Helpers
|
|
|
+ // ═══════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+ private DeviceRegistryDO deviceStub(String deviceId, String status, String admissionLevel) {
|
|
|
+ DeviceRegistryDO d = new DeviceRegistryDO();
|
|
|
+ d.setId((long) deviceId.hashCode());
|
|
|
+ d.setDeviceId(deviceId);
|
|
|
+ d.setProjectId("1");
|
|
|
+ d.setHospitalId("H001");
|
|
|
+ d.setDeviceType("self_service_kiosk");
|
|
|
+ d.setStatus(status);
|
|
|
+ d.setAdmissionLevel(admissionLevel);
|
|
|
+ d.setClientVersion("0.1.0");
|
|
|
+ return d;
|
|
|
}
|
|
|
-}
|
|
|
+}
|