|
|
@@ -0,0 +1,237 @@
|
|
|
+package com.etotem.cfc.unit;
|
|
|
+
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.controller.assessment.AssessmentResultController;
|
|
|
+import com.etotem.cfc.dto.AssessmentResultDTO;
|
|
|
+import com.etotem.cfc.entity.DanAssessmentResult;
|
|
|
+import com.etotem.cfc.mapper.DanAssessmentResultMapper;
|
|
|
+import com.etotem.cfc.service.CognitiveService;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+
|
|
|
+import java.lang.reflect.Field;
|
|
|
+import java.lang.reflect.Modifier;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.stream.Collectors;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+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.when;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 契约级单测:能力键 {@code assessment.result.view}(业务域 D,SC-P-39 家长查看测评结果)。
|
|
|
+ *
|
|
|
+ * <p><b>溯源</b>
|
|
|
+ * <ul>
|
|
|
+ * <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-P-39「查看测评结果(DAN 报告)」——
|
|
|
+ * 流程:结果页 → 八类报告展示;边界:<b>结果未录入不展示;量纲标注</b>;模块 {@code DanAssessmentResultDTO}。</li>
|
|
|
+ * <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.10 测评
|
|
|
+ * {@code POST /api/assessment/latest-result}(最新测评结果)、{@code /api/assessment/history}(测评历史);
|
|
|
+ * 与 {@code teacher.assessment.record}(S-cfc-184)区分:<b>家长看结果 vs 规划师记录测评</b>。</li>
|
|
|
+ * <li>量纲口径:{@code docs/superpowers/specs/2026-11-05-dimension-scoring-convention.md} §1——
|
|
|
+ * 所有维度得分统一落在 <b>0–100</b> 闭区间(0=最弱,100=最强)。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>被校验的契约边界</b>
|
|
|
+ * <ul>
|
|
|
+ * <li><b>边界一(已落地,作对照)</b>:结果未录入(getChildLatestResult 返回 null)→ 接口返回
|
|
|
+ * {@code Result.success(null)}(data 为 null,不展示),而非错误或空对象。</li>
|
|
|
+ * <li><b>边界二(缺失,预期红)</b>:八类报告的每类维度须携带 <b>量纲标注</b>(0–100 闭区间 + 中文标签),
|
|
|
+ * 供结果页直接渲染,而非前端散落硬编码。当前 {@link AssessmentResultDTO} 仅暴露裸 Integer 分数,
|
|
|
+ * 无 dimensionLabel / dimensionUnit / scaleMin / scaleMax 等量纲元信息。</li>
|
|
|
+ * <li><b>边界三(缺失,预期红)</b>:超出 0–100 量纲的分数在查看时须被<b>拒显</b>(拦截返回错误),
|
|
|
+ * 当前 {@code getLatestResult} 直接透传,无范围校验。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>测试范围</b>:纯 Mockito + 反射注入,无 Spring 上下文 / 无 DB / 无外部服务依赖,沿用已合入主链
|
|
|
+ * 同批 RED 契约测试风格(TeamProfitHookContractTest / TeamStarMultiplierContractTest /
|
|
|
+ * FamilyJoinValidationRulesContractTest)。
|
|
|
+ *
|
|
|
+ * <p><b>RED 预期</b>:边界二 / 边界三相关断言在实现补齐前均应失败(红);GREEN 阶段由实现方在
|
|
|
+ * {@code AssessmentResultDTO} 增加「量纲标注(标签 + 0–100 范围)」字段、在 {@code getLatestResult}
|
|
|
+ * 增加「0–100 范围拒显」校验后自动转绿。边界一(未录入不展示)当前已落地,作对照用例。
|
|
|
+ */
|
|
|
+class AssessmentResultViewContractTest {
|
|
|
+
|
|
|
+ /** SC-P-39 八类报告的英文维度字段名(camelCase) */
|
|
|
+ private static final String[] EIGHT_DIMENSION_FIELDS = {
|
|
|
+ "overallScore", "attentionScore", "focusScore", "memoryScore",
|
|
|
+ "logicScore", "perceptionScore", "spatialScore", "processingSpeedScore"
|
|
|
+ };
|
|
|
+
|
|
|
+ private AssessmentResultController controller;
|
|
|
+ private CognitiveService cognitiveService;
|
|
|
+ private DanAssessmentResultMapper danAssessmentResultMapper;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() throws Exception {
|
|
|
+ controller = new AssessmentResultController();
|
|
|
+ // 反射注入全部非 static / 非 final 字段为 mock,隔离 DB / 外部服务
|
|
|
+ injectAll(controller);
|
|
|
+ cognitiveService = (CognitiveService) readField(controller, "cognitiveService");
|
|
|
+ danAssessmentResultMapper = (DanAssessmentResultMapper) readField(controller, "danAssessmentResultMapper");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 边界一(对照):结果未录入不展示 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-P-39 边界(对照,当前已落地):结果未录入时应返回 success(null) 而非错误/空对象")
|
|
|
+ void view_noResultRecorded_shouldReturnSuccessWithNullData() {
|
|
|
+ // member 存在(memberId 非空),但尚未录入测评结果
|
|
|
+ when(cognitiveService.getChildLatestResult(any(Long.class))).thenReturn(null);
|
|
|
+
|
|
|
+ Map<String, Object> body = new HashMap<>();
|
|
|
+ body.put("memberId", 55L);
|
|
|
+
|
|
|
+ Result<AssessmentResultDTO> resp = controller.getLatestResult(body, null);
|
|
|
+
|
|
|
+ assertNotNull(resp, "Result 包装不应为 null");
|
|
|
+ assertEquals(200, resp.getCode(), "未录入不展示 → 应为成功码 200(而非错误码)");
|
|
|
+ assertNull(resp.getData(), "未录入不展示 → data 应为 null(前端据 null 渲染空态)");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 边界二(缺失,预期红):八类报告须携带 0–100 量纲标注 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-P-39 边界:AssessmentResultDTO 应暴露量纲标注(0–100 范围)供结果页直接渲染")
|
|
|
+ void dto_shouldExposeDimensionScaleAnnotation() {
|
|
|
+ List<String> fieldNames = dtoFieldNames();
|
|
|
+
|
|
|
+ // 量纲标注字段:0–100 闭区间的最小/最大值,用于结果页绘制刻度/雷达图半径
|
|
|
+ boolean hasScaleMin = fieldNames.contains("scaleMin");
|
|
|
+ boolean hasScaleMax = fieldNames.contains("scaleMax");
|
|
|
+ boolean hasScale = fieldNames.contains("scale");
|
|
|
+
|
|
|
+ assertTrue(hasScaleMin && hasScaleMax || hasScale,
|
|
|
+ "[assessment.result.view] AssessmentResultDTO 缺少量纲标注(0–100 范围)字段:"
|
|
|
+ + "期望 scaleMin+scaleMax 或 scale,用于结果页八类报告量纲刻度渲染。"
|
|
|
+ + "SC-P-39 边界「量纲标注」未落地。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-P-39 边界:八类报告每类维度应暴露中文标签,供结果页直接展示")
|
|
|
+ void dto_shouldExposeDimensionChineseLabel() {
|
|
|
+ List<String> fieldNames = dtoFieldNames();
|
|
|
+
|
|
|
+ // 每类维度的中文标签标注:整体/注意力/专注力/记忆力/逻辑/感知/空间/加工速度
|
|
|
+ String[] expectedLabelFields = {
|
|
|
+ "overallLabel", "attentionLabel", "focusLabel", "memoryLabel",
|
|
|
+ "logicLabel", "perceptionLabel", "spatialLabel", "processingSpeedLabel"
|
|
|
+ };
|
|
|
+
|
|
|
+ List<String> missingLabels = Arrays.stream(expectedLabelFields)
|
|
|
+ .filter(name -> !fieldNames.contains(name))
|
|
|
+ .collect(Collectors.toList());
|
|
|
+
|
|
|
+ assertTrue(missingLabels.isEmpty(),
|
|
|
+ "[assessment.result.view] AssessmentResultDTO 缺少八类报告维度中文标签字段:" + missingLabels
|
|
|
+ + "。SC-P-39「八类报告展示」要求每类维度携带中文标签供结果页直接渲染,"
|
|
|
+ + "当前标签散落前端硬编码,未纳入契约 DTO。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 边界三(缺失,预期红):超量纲分数应拒显 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-P-39 边界:分数超出 0–100 量纲时应拒显(返回错误而非透传脏数据)")
|
|
|
+ void view_outOfRangeScore_shouldRejectDisplay() {
|
|
|
+ // 构造一条分数越界(150 > 100)的结果 → 查看契约要求拒显,不透传脏数据
|
|
|
+ DanAssessmentResult result = new DanAssessmentResult();
|
|
|
+ result.setMemberId(55L);
|
|
|
+ result.setChildName("小李");
|
|
|
+ result.setOverallScore(150); // 超出 0–100 量纲
|
|
|
+ result.setAttentionScore(80);
|
|
|
+ result.setFocusScore(75);
|
|
|
+ result.setMemoryScore(85);
|
|
|
+ result.setLogicScore(90);
|
|
|
+ result.setPerceptionScore(70);
|
|
|
+ result.setSpatialScore(88);
|
|
|
+ result.setProcessingSpeedScore(78);
|
|
|
+ when(cognitiveService.getChildLatestResult(any(Long.class))).thenReturn(result);
|
|
|
+
|
|
|
+ Map<String, Object> body = new HashMap<>();
|
|
|
+ body.put("memberId", 55L);
|
|
|
+
|
|
|
+ Result<AssessmentResultDTO> resp = controller.getLatestResult(body, null);
|
|
|
+
|
|
|
+ // 契约:越界分数应被拦截(非 200),而非 success + 脏数据透传
|
|
|
+ boolean rejected = resp.getCode() != 200;
|
|
|
+ assertTrue(rejected,
|
|
|
+ "[assessment.result.view] 分数超出 0–100 量纲时应拒显(返回非 200 错误码),"
|
|
|
+ + "当前实现直接透传脏数据(code=" + resp.getCode() + ")。"
|
|
|
+ + "SC-P-39 量纲口径(0–100 闭区间)的查看侧拦截未落地。");
|
|
|
+ // 越界时不应当返回成功数据
|
|
|
+ if (rejected) {
|
|
|
+ assertNull(resp.getData(), "拒显时 data 不应携带脏数据");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-P-39 边界:负分数(< 0)同样应拒显")
|
|
|
+ void view_negativeScore_shouldRejectDisplay() {
|
|
|
+ DanAssessmentResult result = new DanAssessmentResult();
|
|
|
+ result.setMemberId(55L);
|
|
|
+ result.setChildName("小李");
|
|
|
+ result.setOverallScore(80);
|
|
|
+ result.setAttentionScore(-5); // 负数越界
|
|
|
+ result.setFocusScore(75);
|
|
|
+ result.setMemoryScore(85);
|
|
|
+ result.setLogicScore(90);
|
|
|
+ result.setPerceptionScore(70);
|
|
|
+ result.setSpatialScore(88);
|
|
|
+ result.setProcessingSpeedScore(78);
|
|
|
+ when(cognitiveService.getChildLatestResult(any(Long.class))).thenReturn(result);
|
|
|
+
|
|
|
+ Map<String, Object> body = new HashMap<>();
|
|
|
+ body.put("memberId", 55L);
|
|
|
+
|
|
|
+ Result<AssessmentResultDTO> resp = controller.getLatestResult(body, null);
|
|
|
+
|
|
|
+ assertTrue(resp.getCode() != 200,
|
|
|
+ "[assessment.result.view] 负分数(<0)超出 0–100 量纲应拒显,"
|
|
|
+ + "当前实现直接透传(code=" + resp.getCode() + ")。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 结构锚点:契约载体存在(防假 RED 对照) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("结构锚点:AssessmentResultController + /latest-result 契约载体应存在")
|
|
|
+ void contractCarrier_shouldExist() throws Exception {
|
|
|
+ assertNotNull(controller, "[assessment.result.view] AssessmentResultController 实例应可创建");
|
|
|
+ java.lang.reflect.Method m = AssessmentResultController.class.getMethod(
|
|
|
+ "getLatestResult", Map.class, Long.class);
|
|
|
+ assertNotNull(m, "[assessment.result.view] getLatestResult(Map, Long) 方法应存在(端点 /latest-result 宿主)");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 辅助构造 / 反射 ====================
|
|
|
+
|
|
|
+ private List<String> dtoFieldNames() {
|
|
|
+ return Arrays.stream(AssessmentResultDTO.class.getDeclaredFields())
|
|
|
+ .map(Field::getName)
|
|
|
+ .collect(Collectors.toList());
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 反射注入全部非 static / 非 final 字段为对应类型 mock */
|
|
|
+ private static void injectAll(Object target) throws Exception {
|
|
|
+ for (Field f : target.getClass().getDeclaredFields()) {
|
|
|
+ if (Modifier.isStatic(f.getModifiers()) || Modifier.isFinal(f.getModifiers())) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ f.setAccessible(true);
|
|
|
+ f.set(target, mock(f.getType()));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Object readField(Object target, String name) throws Exception {
|
|
|
+ Field f = target.getClass().getDeclaredField(name);
|
|
|
+ f.setAccessible(true);
|
|
|
+ return f.get(target);
|
|
|
+ }
|
|
|
+}
|