Browse Source

test(cfc-backend): RED 契约测试 assessment.result.view (S-cfc-278)

为能力键 assessment.result.view 写契约级单测(预期红),溯源 SC-P-39
(查看测评结果:八类报告展示、结果未录入不展示、量纲标注)与
API_REFERENCE.md §4.10(POST /api/assessment/latest-result、/history)。

纯 Mockito + 反射,无 Spring/DB/外部服务,沿用同批 RED 测试风格
(TeamProfitHookContractTest / FamilyJoinValidationRulesContractTest)。

- 边界一(对照,已落地):结果未录入返回 success(null) 不展示
- 边界二(红):AssessmentResultDTO 缺量纲标注(0-100 范围)字段
- 边界二(红):八类报告维度缺中文标签字段
- 边界三(红):分数超出 0-100 量纲未拒显(直接透传 code=200)
- 结构锚点:AssessmentResultController.getLatestResult 存在

红测结果:Tests run 6, Failures 4, Errors 0(4 项契约缺口断言按预期失败)
E2E Test Bot 1 day ago
parent
commit
9d331b7e97

+ 237 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/AssessmentResultViewContractTest.java

@@ -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);
+    }
+}