|
|
@@ -0,0 +1,153 @@
|
|
|
+package com.etotem.cfc.unit;
|
|
|
+
|
|
|
+import com.etotem.cfc.entity.DanAssessmentResult;
|
|
|
+import com.etotem.cfc.mapper.DanAssessmentResultMapper;
|
|
|
+import com.etotem.cfc.service.AssessmentService;
|
|
|
+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 static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.Mockito.atLeastOnce;
|
|
|
+import static org.mockito.Mockito.mock;
|
|
|
+import static org.mockito.Mockito.never;
|
|
|
+import static org.mockito.Mockito.times;
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 契约级单测:能力键 {@code teacher.assessment.record}(成长规划师域 EVD-10,SC-T-07 边界条款)。
|
|
|
+ *
|
|
|
+ * <p><b>需求 / 设计锚点</b></p>
|
|
|
+ * <ul>
|
|
|
+ * <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-T-07「录入测评结果触发方案 ⭐」——
|
|
|
+ * 边界:<b>必填缺失拦截;重复录入覆盖非新增</b>。</li>
|
|
|
+ * <li>设计:{@code docs/superpowers/specs/2026-11-07-planner-zone-contract.md} §2 SC-T-07 /
|
|
|
+ * §3 建议能力键映射 {@code teacher.assessment.record}(绑定 SC-T-07 / T-11 / T-12);
|
|
|
+ * {@code docs/superpowers/api/API_REFERENCE.md} §4.57 SC-T-07 端点载体
|
|
|
+ * {@code DanAssessmentController /api/dan-assessment/result/record}。</li>
|
|
|
+ * <li>实现载体:{@code AssessmentService#recordResult(DanAssessmentResult)}
|
|
|
+ * ({@code cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentService.java})。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>被校验的契约边界(SC-T-07)</b></p>
|
|
|
+ * <ul>
|
|
|
+ * <li><b>必填缺失拦截</b>:录入测评结果时,{@code memberId}/{@code familyMemberId} 与
|
|
|
+ * {@code dimension} 为必填;任一缺失应拦截并<b>不落库</b>(不调用 insert)。</li>
|
|
|
+ * <li><b>重复录入覆盖非新增</b>:对同一成员(member)+ 同一维度(dimension)重复录入时,应
|
|
|
+ * <b>覆盖(updateById)</b>既有记录,而非再新增一行(第二次录入不得走 insert)。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>测试范围</b>:纯 Mockito + 反射注入,无 Spring 上下文 / 无 DB / 无外部服务依赖,
|
|
|
+ * 沿用已合入主链同批 RED 契约测试风格(TeamProfitHookContractTest / FamilyJoinValidationRulesContractTest)。
|
|
|
+ *
|
|
|
+ * <p><b>RED 预期</b>:当前 {@code AssessmentService.recordResult} 既无必填校验、也无重复覆盖逻辑
|
|
|
+ * (对任何入参一律 {@code danAssessmentResultMapper.insert(result)})。以下边界断言在实现补齐前
|
|
|
+ * 均应失败(红);GREEN 阶段由实现方在 {@code recordResult} 补上「必填校验拦截」与
|
|
|
+ * 「同成员+同维度重复录入覆盖(updateById 而非 insert)」后自动转绿。</p>
|
|
|
+ */
|
|
|
+class TeacherAssessmentRecordContractTest {
|
|
|
+
|
|
|
+ private AssessmentService service;
|
|
|
+
|
|
|
+ private DanAssessmentResultMapper resultMapper;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() throws Exception {
|
|
|
+ service = new AssessmentService();
|
|
|
+ // 反射注入全部非 static / 非 final 字段为 mock,隔离 DB / 外部服务;
|
|
|
+ // recordResult 主链仅触及 resultMapper / reportParseService / growthRecordService /
|
|
|
+ // dimensionScoreService / planGenerator / userMapper(其余仅在 parent_upload 分支触及)。
|
|
|
+ injectAll(service);
|
|
|
+ resultMapper = (DanAssessmentResultMapper) readField(service, "danAssessmentResultMapper");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== SC-T-07 边界一:必填缺失拦截 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-T-07 边界:memberId/familyMemberId 缺失应拦截且不落库(必填缺失拦截)")
|
|
|
+ void recordResult_missingMember_应拦截不落库() {
|
|
|
+ // 无 member 归属(memberId 与 familyMemberId 均为 null)→ 契约要求拦截,不得 insert
|
|
|
+ DanAssessmentResult result = plannerEntryResult();
|
|
|
+ result.setMemberId(null);
|
|
|
+ result.setFamilyMemberId(null);
|
|
|
+
|
|
|
+ service.recordResult(result);
|
|
|
+
|
|
|
+ verify(resultMapper, never()).insert(any(DanAssessmentResult.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-T-07 边界:dimension 缺失应拦截且不落库(必填缺失拦截)")
|
|
|
+ void recordResult_missingDimension_应拦截不落库() {
|
|
|
+ // dimension 为必填(八类报告归属维度);缺失应拦截,不得 insert
|
|
|
+ DanAssessmentResult result = plannerEntryResult();
|
|
|
+ result.setDimension(null);
|
|
|
+
|
|
|
+ service.recordResult(result);
|
|
|
+
|
|
|
+ verify(resultMapper, never()).insert(any(DanAssessmentResult.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== SC-T-07 边界二:重复录入覆盖非新增 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-T-07 边界:同成员+同维度重复录入应覆盖(updateById)而非新增第二行 insert")
|
|
|
+ void recordResult_duplicateMemberDimension_应覆盖而非新增() {
|
|
|
+ // 同一成员 + 同一维度两次录入 → 第二次应走 updateById 覆盖,insert 仅允许一次(首次)
|
|
|
+ DanAssessmentResult first = plannerEntryResult(); // memberId=55, dimension=mind
|
|
|
+ DanAssessmentResult second = plannerEntryResult(); // 同 memberId=55, dimension=mind
|
|
|
+
|
|
|
+ service.recordResult(first);
|
|
|
+ service.recordResult(second);
|
|
|
+
|
|
|
+ // 首次录入允许 insert;重复录入不得再新增(覆盖语义)
|
|
|
+ verify(resultMapper, times(1)).insert(any(DanAssessmentResult.class));
|
|
|
+ // 覆盖语义:第二次录入必须触发 updateById
|
|
|
+ verify(resultMapper, atLeastOnce()).updateById(any(DanAssessmentResult.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 结构锚点:契约载体存在(防假 RED 对照) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("结构锚点:AssessmentService.recordResult 契约载体应存在")
|
|
|
+ void recordResult_契约载体存在() throws Exception {
|
|
|
+ assertNotNull(service, "[teacher.assessment.record] AssessmentService 实例应可创建");
|
|
|
+ // recordResult(DanAssessmentResult) 方法应存在于 AssessmentService(端点 /result/record 的服务宿主)
|
|
|
+ java.lang.reflect.Method m = AssessmentService.class.getMethod("recordResult", DanAssessmentResult.class);
|
|
|
+ assertNotNull(m, "[teacher.assessment.record] AssessmentService#recordResult 方法应存在");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 辅助构造 / 反射 ====================
|
|
|
+
|
|
|
+ /** 构造一条规划师录入(SOURCE_PLANNER_ENTRY)结果:memberId=55 / dimension=mind */
|
|
|
+ private DanAssessmentResult plannerEntryResult() {
|
|
|
+ DanAssessmentResult r = new DanAssessmentResult();
|
|
|
+ r.setMemberId(55L);
|
|
|
+ r.setFamilyMemberId(55L);
|
|
|
+ r.setDimension("mind");
|
|
|
+ r.setFocusScore(80);
|
|
|
+ r.setSource(DanAssessmentResult.SOURCE_PLANNER_ENTRY);
|
|
|
+ return r;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 反射注入全部非 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);
|
|
|
+ }
|
|
|
+}
|