|
|
@@ -0,0 +1,359 @@
|
|
|
+package com.emoon.openplatform.acceptance;
|
|
|
+
|
|
|
+import com.emoon.ai.agent.api.AgentChatCommand;
|
|
|
+import com.emoon.ai.agent.application.*;
|
|
|
+import com.emoon.ai.agent.domain.AiTaskInstanceDO;
|
|
|
+import com.emoon.ai.agent.domain.RouteDecision;
|
|
|
+import com.emoon.ai.card.application.CardInstanceService;
|
|
|
+import com.emoon.ai.device.api.*;
|
|
|
+import com.emoon.ai.device.application.DeviceCommandService;
|
|
|
+import com.emoon.ai.mcp.application.McpToolService;
|
|
|
+import com.emoon.ai.meter.application.MeterEventProducer;
|
|
|
+import com.emoon.mcp.domain.AiCardInstance;
|
|
|
+import com.emoon.mcp.domain.AiConversation;
|
|
|
+import com.emoon.openplatform.service.impl.AgentChatApplicationServiceImpl;
|
|
|
+import org.junit.jupiter.api.*;
|
|
|
+import org.junit.jupiter.api.extension.ExtendWith;
|
|
|
+import org.mockito.ArgumentCaptor;
|
|
|
+import org.mockito.Mock;
|
|
|
+import org.mockito.junit.jupiter.MockitoExtension;
|
|
|
+import org.mockito.junit.jupiter.MockitoSettings;
|
|
|
+import org.mockito.quality.Strictness;
|
|
|
+import org.springframework.core.env.Environment;
|
|
|
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
|
|
+
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+import static org.assertj.core.api.Assertions.assertThat;
|
|
|
+import static org.mockito.ArgumentMatchers.*;
|
|
|
+import static org.mockito.Mockito.*;
|
|
|
+
|
|
|
+/**
|
|
|
+ * End-to-end MVP acceptance tests.
|
|
|
+ *
|
|
|
+ * <p>Validates: device → scene → chat → route → task → card → trace.
|
|
|
+ * All external dependencies mocked. Acceptance criteria per
|
|
|
+ * {@code docs/superpowers/plans/2026-05-31-terminal-client-mvp.md} Task 17.
|
|
|
+ */
|
|
|
+@ExtendWith(MockitoExtension.class)
|
|
|
+@MockitoSettings(strictness = Strictness.LENIENT)
|
|
|
+@DisplayName("Terminal Client MVP E2E Acceptance")
|
|
|
+class TerminalMvpAcceptanceTest {
|
|
|
+
|
|
|
+ @Mock private ConversationTerminalService conversationService;
|
|
|
+ @Mock private AgentRouterService routerService;
|
|
|
+ @Mock private TerminalReplyTemplateService replyTemplates;
|
|
|
+ @Mock private TaskStateService taskStateService;
|
|
|
+ @Mock private DeviceRegistryFacade deviceRegistryFacade;
|
|
|
+ @Mock private CardInstanceService cardInstanceService;
|
|
|
+ @Mock private McpToolService mcpToolService;
|
|
|
+ @Mock private DeviceCommandService deviceCommandService;
|
|
|
+ @Mock private MeterEventProducer meterProducer;
|
|
|
+ @Mock private Environment env;
|
|
|
+
|
|
|
+ private AgentChatApplicationServiceImpl chatService;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ chatService = new AgentChatApplicationServiceImpl(
|
|
|
+ conversationService, routerService, replyTemplates,
|
|
|
+ taskStateService, cardInstanceService, deviceRegistryFacade,
|
|
|
+ deviceCommandService, mcpToolService, meterProducer, env);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ═══════════════════════════════════════════════════════════════
|
|
|
+ // 1. DEVICE STARTUP ACCEPTANCE
|
|
|
+ // ═══════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("1a. Register → activated device can call core flow")
|
|
|
+ void activatedDeviceCanRegister() {
|
|
|
+ DeviceRegistryFacade facade = mock(DeviceRegistryFacade.class);
|
|
|
+ when(facade.register(any())).thenReturn(
|
|
|
+ new DeviceRegisterResult("KIOSK-001", "sk-xxx", "activated", "A", null));
|
|
|
+
|
|
|
+ DeviceRegisterResult result = facade.register(
|
|
|
+ new DeviceRegisterCommand("1", "H001", "KIOSK-001", "self_service_kiosk",
|
|
|
+ null, null, "0.1.0", Map.of(), Map.of()));
|
|
|
+
|
|
|
+ assertThat(result.activateStatus()).isEqualTo("activated");
|
|
|
+ assertThat(result.admissionLevel()).isEqualTo("A");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("1b. Scene profile returns allowedAgents and allowedCards")
|
|
|
+ void sceneProfileHasAgentsAndCards() {
|
|
|
+ SceneProfileResult scene = new SceneProfileResult(
|
|
|
+ "KIOSK-001", "self_service_kiosk", "outpatient_kiosk",
|
|
|
+ "kiosk_home_v1", "opd-guide-agent",
|
|
|
+ List.of("opd-guide-agent", "opd-registration-agent", "tongue-diagnosis-agent"),
|
|
|
+ List.of(),
|
|
|
+ List.of("touch", "camera"),
|
|
|
+ List.of("department-selection", "doctor-selection", "tongue-capture"));
|
|
|
+
|
|
|
+ assertThat(scene.allowedAgents()).contains("opd-registration-agent");
|
|
|
+ assertThat(scene.allowedCards()).contains("department-selection", "tongue-capture");
|
|
|
+ assertThat(scene.defaultAgent()).isEqualTo("opd-guide-agent");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ═══════════════════════════════════════════════════════════════
|
|
|
+ // 2. CHAT ACCEPTANCE — Kiosk Registration
|
|
|
+ // ═══════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("2a. Kiosk '我头疼想挂号' → REGISTRATION task + department-selection card")
|
|
|
+ void kioskRegistrationCreatesTaskAndCard() throws Exception {
|
|
|
+ stubKioskScene("KIOSK-001");
|
|
|
+ when(routerService.route(any()))
|
|
|
+ .thenReturn(RouteDecision.direct("REGISTRATION", "opd-registration-agent",
|
|
|
+ "REGISTRATION", "OUTPATIENT_REGISTRATION", "CREATE_NEW_TASK"));
|
|
|
+ when(replyTemplates.reply("REGISTRATION")).thenReturn("好的,我来帮您完成挂号。");
|
|
|
+ when(taskStateService.createTask(any(), any(), eq("REGISTRATION"), any(), any(), any(), any(), any(), any()))
|
|
|
+ .thenReturn(taskStub("task_001", "REGISTRATION", "COLLECT_SYMPTOM"));
|
|
|
+ when(cardInstanceService.createCard(any(), any(), eq("department-selection"), any(), any(), any()))
|
|
|
+ .thenReturn(cardStub("card_001", "department-selection"));
|
|
|
+
|
|
|
+ chatService.chatStream(kioskCmd("KIOSK-001", "我头疼三天,想挂号"), new SseEmitter(5000L));
|
|
|
+
|
|
|
+ verify(taskStateService).createTask(any(), any(), eq("REGISTRATION"), any(), any(), any(), any(), any(), any());
|
|
|
+ verify(cardInstanceService).createCard(any(), any(), eq("department-selection"), any(), any(), any());
|
|
|
+ verify(meterProducer).produce(eq("AGENT_CHAT_COMPLETED"), any(), any(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ═══════════════════════════════════════════════════════════════
|
|
|
+ // 3. CHAT ACCEPTANCE — Guide Screen Privacy
|
|
|
+ // ═══════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("3a. Guide screen blocks tongue diagnosis (privacy)")
|
|
|
+ void guideScreenBlocksTongueDiagnosis() throws Exception {
|
|
|
+ stubGuideScreenScene("GUIDE-001");
|
|
|
+ when(routerService.route(any()))
|
|
|
+ .thenReturn(RouteDecision.blocked("导诊大屏不支持舌象采集,请到自助机办理。"));
|
|
|
+
|
|
|
+ chatService.chatStream(guideScreenCmd("GUIDE-001", "我要舌诊"), new SseEmitter(5000L));
|
|
|
+
|
|
|
+ // Blocked: no task, no card, no MCP call
|
|
|
+ verify(taskStateService, never()).createTask(any(), any(), any(), any(), any(), any(), any(), any(), any());
|
|
|
+ verify(cardInstanceService, never()).createCard(any(), any(), any(), any(), any(), any());
|
|
|
+ verify(mcpToolService, never()).createAppointment(any(), any(), any(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("3b. Guide screen does NOT get registration or tongue cards")
|
|
|
+ void guideScreenRestrictsSensitiveCards() {
|
|
|
+ SceneProfileResult guideScene = new SceneProfileResult(
|
|
|
+ "GUIDE-001", "guide_screen", "outpatient_guide",
|
|
|
+ "guide_home_v1", "opd-guide-agent",
|
|
|
+ List.of("opd-guide-agent"),
|
|
|
+ List.of("TONGUE_DIAGNOSIS", "REGISTRATION"), // blocked intents
|
|
|
+ List.of("touch"),
|
|
|
+ List.of()); // no medical cards
|
|
|
+
|
|
|
+ assertThat(guideScene.blockedIntents()).contains("TONGUE_DIAGNOSIS", "REGISTRATION");
|
|
|
+ assertThat(guideScene.allowedCards()).isEmpty();
|
|
|
+ }
|
|
|
+
|
|
|
+ // ═══════════════════════════════════════════════════════════════
|
|
|
+ // 4. CHAT ACCEPTANCE — Robot Navigation
|
|
|
+ // ═══════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("4a. Robot '带我去检验科' → route-card created")
|
|
|
+ void robotNavigationCreatesRouteCard() throws Exception {
|
|
|
+ stubRobotScene("ROBOT-001");
|
|
|
+ when(routerService.route(any()))
|
|
|
+ .thenReturn(RouteDecision.direct("ROUTE_NAVIGATION", "opd-guide-agent",
|
|
|
+ "GUIDE", "OUTPATIENT_GUIDE", "CREATE_NEW_TASK"));
|
|
|
+ when(replyTemplates.reply("ROUTE_NAVIGATION")).thenReturn("好的,我来帮您规划路线。");
|
|
|
+ when(taskStateService.createTask(any(), any(), eq("GUIDE"), any(), any(), any(), any(), any(), any()))
|
|
|
+ .thenReturn(taskStub("task_r_001", "GUIDE", "UNDERSTAND_DESTINATION"));
|
|
|
+ when(cardInstanceService.createCard(any(), any(), eq("route-card"), any(), any(), any()))
|
|
|
+ .thenReturn(cardStub("card_r_001", "route-card"));
|
|
|
+
|
|
|
+ chatService.chatStream(robotCmd("ROBOT-001", "带我去检验科"), new SseEmitter(5000L));
|
|
|
+
|
|
|
+ verify(cardInstanceService).createCard(any(), any(), eq("route-card"), any(), any(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ═══════════════════════════════════════════════════════════════
|
|
|
+ // 5. SAFETY ACCEPTANCE
|
|
|
+ // ═══════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("5a. agentId is optional (can be null)")
|
|
|
+ void agentIdIsOptional() {
|
|
|
+ AgentChatCommand cmd = new AgentChatCommand(
|
|
|
+ "1", null, null, "m001", null, null,
|
|
|
+ "KIOSK-001", "self_service_kiosk", "你好", null);
|
|
|
+ assertThat(cmd.agentId()).isNull();
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("5b. Blocked route does NOT create tasks or invoke MCP")
|
|
|
+ void blockedRouteCreatesNothing() throws Exception {
|
|
|
+ stubKioskScene("KIOSK-001");
|
|
|
+ when(routerService.route(any()))
|
|
|
+ .thenReturn(RouteDecision.blocked("该设备不支持此操作"));
|
|
|
+
|
|
|
+ chatService.chatStream(kioskCmd("KIOSK-001", "我要缴费"), new SseEmitter(5000L));
|
|
|
+
|
|
|
+ verify(taskStateService, never()).createTask(any(), any(), any(), any(), any(), any(), any(), any(), any());
|
|
|
+ verify(cardInstanceService, never()).createCard(any(), any(), any(), any(), any(), any());
|
|
|
+ verify(mcpToolService, never()).createAppointment(any(), any(), any(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("5c. Template replies never contain medical conclusions")
|
|
|
+ void templatesNeverContainDiagnosis() {
|
|
|
+ TerminalReplyTemplateService svc = new TerminalReplyTemplateService();
|
|
|
+ for (String code : List.of("GUIDE_FAQ", "REGISTRATION", "TRIAGE",
|
|
|
+ "TONGUE_DIAGNOSIS", "ROUTE_NAVIGATION", "CANCEL_TASK", "UNKNOWN")) {
|
|
|
+ String reply = svc.reply(code);
|
|
|
+ assertThat(reply).doesNotContain("确诊", "诊断", "处方", "用药", "治疗", "挂号成功", "预约成功", "支付", "医保");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ═══════════════════════════════════════════════════════════════
|
|
|
+ // 6. TRACE ACCEPTANCE
|
|
|
+ // ═══════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("6a. traceId flows through conversation → task → card → meter")
|
|
|
+ void traceIdPropagatesThroughFullChain() throws Exception {
|
|
|
+ stubKioskScene("KIOSK-001");
|
|
|
+
|
|
|
+ when(routerService.route(any()))
|
|
|
+ .thenReturn(RouteDecision.direct("REGISTRATION", "opd-registration-agent",
|
|
|
+ "REGISTRATION", "OUTPATIENT_REGISTRATION", "CREATE_NEW_TASK"));
|
|
|
+ when(replyTemplates.reply(any())).thenReturn("好的。");
|
|
|
+ when(taskStateService.createTask(any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
|
|
+ .thenReturn(taskStub("task_trace", "REGISTRATION", "COLLECT_SYMPTOM"));
|
|
|
+ when(cardInstanceService.createCard(any(), any(), any(), any(), any(), any()))
|
|
|
+ .thenReturn(cardStub("card_trace", "department-selection"));
|
|
|
+
|
|
|
+ chatService.chatStream(kioskCmd("KIOSK-001", "挂号"), new SseEmitter(5000L));
|
|
|
+
|
|
|
+ // Capture traceIds from all three layers
|
|
|
+ ArgumentCaptor<String> traceCaptor = ArgumentCaptor.forClass(String.class);
|
|
|
+
|
|
|
+ verify(taskStateService).createTask(any(), any(), any(), any(), any(), any(), any(), any(), traceCaptor.capture());
|
|
|
+ String taskTraceId = traceCaptor.getValue();
|
|
|
+ assertThat(taskTraceId).isNotNull().isNotBlank();
|
|
|
+
|
|
|
+ verify(cardInstanceService).createCard(any(), any(), any(), any(), any(), traceCaptor.capture());
|
|
|
+ String cardTraceId = traceCaptor.getValue();
|
|
|
+
|
|
|
+ verify(meterProducer).produce(eq("AGENT_CHAT_COMPLETED"), any(), traceCaptor.capture(), any());
|
|
|
+ String meterTraceId = traceCaptor.getValue();
|
|
|
+
|
|
|
+ // Core acceptance: one traceId must link conversation → task → card → meter
|
|
|
+ assertThat(taskTraceId).isEqualTo(cardTraceId)
|
|
|
+ .as("traceId must be consistent between task and card creation");
|
|
|
+ assertThat(cardTraceId).isEqualTo(meterTraceId)
|
|
|
+ .as("traceId must be consistent between card and meter event");
|
|
|
+ assertThat(taskTraceId).startsWith("trace_")
|
|
|
+ .as("traceId must follow the generated format");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("6b. Robot navigation produces AGENT_CHAT_COMPLETED meter event")
|
|
|
+ void robotNavigationProducesMeterEvent() throws Exception {
|
|
|
+ stubRobotScene("ROBOT-001");
|
|
|
+ when(routerService.route(any()))
|
|
|
+ .thenReturn(RouteDecision.direct("ROUTE_NAVIGATION", "opd-guide-agent",
|
|
|
+ "GUIDE", "OUTPATIENT_GUIDE", "CREATE_NEW_TASK"));
|
|
|
+ when(replyTemplates.reply("ROUTE_NAVIGATION")).thenReturn("好的。");
|
|
|
+ when(taskStateService.createTask(any(), any(), eq("GUIDE"), any(), any(), any(), any(), any(), any()))
|
|
|
+ .thenReturn(taskStub("task_g", "GUIDE", "UNDERSTAND_DESTINATION"));
|
|
|
+ when(cardInstanceService.createCard(any(), any(), eq("route-card"), any(), any(), any()))
|
|
|
+ .thenReturn(cardStub("card_r", "route-card"));
|
|
|
+
|
|
|
+ chatService.chatStream(robotCmd("ROBOT-001", "带我去检验科"), new SseEmitter(5000L));
|
|
|
+
|
|
|
+ // Every successful chat produces AGENT_CHAT_COMPLETED meter event
|
|
|
+ verify(meterProducer).produce(eq("AGENT_CHAT_COMPLETED"), any(), any(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ═══════════════════════════════════════════════════════════════
|
|
|
+ // Helpers
|
|
|
+ // ═══════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+ private void stubKioskScene(String deviceId) {
|
|
|
+ when(deviceRegistryFacade.scene(eq("1"), eq(deviceId)))
|
|
|
+ .thenReturn(new SceneProfileResult(deviceId, "self_service_kiosk",
|
|
|
+ "outpatient_kiosk", "kiosk_home_v1", "opd-guide-agent",
|
|
|
+ List.of("opd-guide-agent", "opd-registration-agent", "tongue-diagnosis-agent"),
|
|
|
+ List.of(), List.of("touch", "camera"),
|
|
|
+ List.of("department-selection", "doctor-selection")));
|
|
|
+ when(conversationService.createOrLoad(anyInt(), any(), any(), any(), any(), any()))
|
|
|
+ .thenReturn(convStub());
|
|
|
+ when(env.matchesProfiles("dev", "local")).thenReturn(true);
|
|
|
+ }
|
|
|
+
|
|
|
+ private void stubGuideScreenScene(String deviceId) {
|
|
|
+ when(deviceRegistryFacade.scene(eq("1"), eq(deviceId)))
|
|
|
+ .thenReturn(new SceneProfileResult(deviceId, "guide_screen",
|
|
|
+ "outpatient_guide", "guide_home_v1", "opd-guide-agent",
|
|
|
+ List.of("opd-guide-agent"),
|
|
|
+ List.of("TONGUE_DIAGNOSIS", "REGISTRATION"),
|
|
|
+ List.of("touch"), List.of()));
|
|
|
+ when(conversationService.createOrLoad(anyInt(), any(), any(), any(), any(), any()))
|
|
|
+ .thenReturn(convStub());
|
|
|
+ when(env.matchesProfiles("dev", "local")).thenReturn(true);
|
|
|
+ }
|
|
|
+
|
|
|
+ private void stubRobotScene(String deviceId) {
|
|
|
+ when(deviceRegistryFacade.scene(eq("1"), eq(deviceId)))
|
|
|
+ .thenReturn(new SceneProfileResult(deviceId, "robot",
|
|
|
+ "outpatient_robot", "robot_home_v1", "opd-guide-agent",
|
|
|
+ List.of("opd-guide-agent"), List.of(),
|
|
|
+ List.of("voice", "navigation"), List.of("route-card")));
|
|
|
+ when(conversationService.createOrLoad(anyInt(), any(), any(), any(), any(), any()))
|
|
|
+ .thenReturn(convStub());
|
|
|
+ when(env.matchesProfiles("dev", "local")).thenReturn(true);
|
|
|
+ }
|
|
|
+
|
|
|
+ private AiConversation convStub() {
|
|
|
+ AiConversation conv = new AiConversation();
|
|
|
+ conv.setId(1L);
|
|
|
+ conv.setConversationId("conv_001");
|
|
|
+ conv.setProjectId(1);
|
|
|
+ conv.setStatus("active");
|
|
|
+ return conv;
|
|
|
+ }
|
|
|
+
|
|
|
+ private AgentChatCommand kioskCmd(String deviceId, String text) {
|
|
|
+ return new AgentChatCommand("1", null, null, "m001", null, null,
|
|
|
+ deviceId, "self_service_kiosk", text, null);
|
|
|
+ }
|
|
|
+
|
|
|
+ private AgentChatCommand guideScreenCmd(String deviceId, String text) {
|
|
|
+ return new AgentChatCommand("1", null, null, "m002", null, null,
|
|
|
+ deviceId, "guide_screen", text, null);
|
|
|
+ }
|
|
|
+
|
|
|
+ private AgentChatCommand robotCmd(String deviceId, String text) {
|
|
|
+ return new AgentChatCommand("1", null, null, "m003", null, null,
|
|
|
+ deviceId, "robot", text, null);
|
|
|
+ }
|
|
|
+
|
|
|
+ private AiTaskInstanceDO taskStub(String taskId, String taskType, String currentStep) {
|
|
|
+ AiTaskInstanceDO task = new AiTaskInstanceDO();
|
|
|
+ task.setTaskId(taskId);
|
|
|
+ task.setTaskType(taskType);
|
|
|
+ task.setCurrentStep(currentStep);
|
|
|
+ task.setStatus("ACTIVE");
|
|
|
+ task.setConversationId("conv_001");
|
|
|
+ return task;
|
|
|
+ }
|
|
|
+
|
|
|
+ private AiCardInstance cardStub(String instanceId, String cardKey) {
|
|
|
+ AiCardInstance inst = new AiCardInstance();
|
|
|
+ inst.setInstanceId(instanceId);
|
|
|
+ inst.setCardKey(cardKey);
|
|
|
+ inst.setStatus("active");
|
|
|
+ return inst;
|
|
|
+ }
|
|
|
+}
|