Просмотр исходного кода

test(tdd): S-cfc-148 dan.assessment.resultRecord 契约级单测(TDD-RED:SC-T-07 方案生成链 / SC-T-30 五维维度变化两钩子未落地,预期2红1绿)

Sisyphus Agent 1 день назад
Родитель
Сommit
0b7ba4ecb0

+ 185 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/DanAssessmentResultRecordContractTest.java

@@ -0,0 +1,185 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.entity.DanAssessmentResult;
+import com.etotem.cfc.mapper.DanAssessmentResultMapper;
+import com.etotem.cfc.service.AssessmentService;
+import com.etotem.cfc.service.DimensionScoreService;
+import com.etotem.cfc.service.GrowthRecordService;
+import com.etotem.cfc.service.ReportParseService;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:DAN 测评结果录入(能力键 {@code dan.assessment.resultRecord},单元 dan.assessment)。
+ *
+ * <p><b>需求/设计锚点</b></p>
+ * <ul>
+ *   <li>需求:{@code docs/tdd/SRS.md} 三、未覆盖控制器 —— {@code DanAssessmentController}(29 方法,0 覆盖);
+ *       {@code docs/superpowers/specs/2026-11-07-planner-zone-contract.md} §2 SC-T-07(⭐)/ SC-T-30</li>
+ *   <li>设计:{@code DanAssessmentController#recordResult} —— {@code @PostMapping("/result/record")}
+ *       (源文件 {@code cfc-backend/src/main/java/com/etotem/cfc/controller/DanAssessmentController.java})
+ *       → {@code AssessmentService#recordResult(DanAssessmentResult)};契约以
+ *       {@code docs/superpowers/api/API_REFERENCE.md} 为准(SC-T-07「录入测评结果触发方案」、
+ *       SC-T-30「测评录入触发五维维度变化」)</li>
+ * </ul>
+ *
+ * <p><b>被校验的契约行为</b></p>
+ * <ul>
+ *   <li><b>正常流(已落地)</b>:录入结果后落库,默认 {@code status=completed}、写入
+ *       {@code createdAt}/{@code updatedAt},{@code assessmentDate} 为空时默认当前时间;</li>
+ *   <li><b>SC-T-30(未落地 → RED)</b>:录入测评结果后必须触发<b>五维维度变化</b>——调用
+ *       {@code DimensionScoreService#refreshFromDanAssessment(memberId, resultId, dimension)};
+ *       当前 {@code AssessmentService} 未注入 {@code DimensionScoreService}(Controller 注入
+ *       {@code dimensionScoreService} 但 {@code recordResult} 链路从未使用),契约行为缺失;</li>
+ *   <li><b>SC-T-07(未落地 → RED)</b>:录入测评结果后必须触发<b>方案生成链</b>(参照
+ *       {@code AssessmentReportController#confirmUpload} 的 {@code planGenerator.generate(...)}
+ *       先例);当前 {@code AssessmentService.recordResult} 无方案生成触发钩子,契约行为缺失。</li>
+ * </ul>
+ *
+ * <p><b>测试范围</b>:纯 Mockito + 反射,无 Spring 上下文 / 无 DB / 无外部服务依赖。</p>
+ * <p><b>RED 预期</b>:SC-T-30 / SC-T-07 两个触发钩子在实现中未落地,结构探测用例失败(红);
+ * 正常流持久化默认值用例为现状回归(绿),作为测试脚手架有效性的对照。GREEN 阶段由实现方
+ * 在 {@code AssessmentService.recordResult} 补上五维维度刷新 + 方案生成触发后,红用例自动转绿。</p>
+ */
+class DanAssessmentResultRecordContractTest {
+
+    /** 契约端点载体(源文件锚点) */
+    private static final String CONTROLLER_CLASS = "com.etotem.cfc.controller.DanAssessmentController";
+    /** 服务宿主 */
+    private static final String SERVICE_CLASS = "com.etotem.cfc.service.AssessmentService";
+    /** 五维维度服务(SC-T-30 契约触发目标) */
+    private static final String DIMENSION_SERVICE = "com.etotem.cfc.service.DimensionScoreService";
+
+    // ==================== 正常流(已落地):持久化默认值 ====================
+
+    @Test
+    @DisplayName("正常流:录入测评结果 → 默认 status=completed 且落库")
+    void recordResult_defaults_statusCompleted_andPersisted() throws Exception {
+        AssessmentService svc = new AssessmentService();
+        DanAssessmentResultMapper mapper = mock(DanAssessmentResultMapper.class);
+        inject(svc, "danAssessmentResultMapper", mapper);
+        // recordResult 尾部调用 reportParseService / growthRecordService(非致命 try-catch),mock 掉避免副作用
+        inject(svc, "reportParseService", mock(ReportParseService.class));
+        inject(svc, "growthRecordService", mock(GrowthRecordService.class));
+        when(mapper.insert(any(DanAssessmentResult.class))).thenReturn(1);
+
+        DanAssessmentResult result = new DanAssessmentResult();
+        result.setFamilyMemberId(101L);
+        result.setDimension("mind");
+        result.setFocusScore(85);
+        result.setSource(DanAssessmentResult.SOURCE_PLANNER_ENTRY);
+
+        DanAssessmentResult saved = svc.recordResult(result);
+
+        assertEquals("completed", saved.getStatus(),
+                "[dan.assessment.resultRecord] 录入结果后 status 应为 completed");
+        assertNotNull(saved.getCreatedAt(), "[dan.assessment.resultRecord] createdAt 应默认写入");
+        assertNotNull(saved.getUpdatedAt(), "[dan.assessment.resultRecord] updatedAt 应默认写入");
+        assertNotNull(saved.getAssessmentDate(),
+                "[dan.assessment.resultRecord] assessmentDate 为空时应默认当前时间");
+        verify(mapper).insert(result);
+    }
+
+    // ==================== SC-T-30:录入触发五维维度变化(未落地 → RED) ====================
+
+    @Test
+    @DisplayName("SC-T-30:录入测评结果应触发五维维度变化(refreshFromDanAssessment)")
+    void scT30_recordResult_triggersFiveDimensionRefresh() {
+        Class<?> svc = loadClass(SERVICE_CLASS);
+        assertNotNull(svc, "RED[dan.assessment.resultRecord] AssessmentService 类不存在");
+
+        // 结构探测:AssessmentService 应注入 DimensionScoreService 作为五维维度刷新钩子。
+        // 契约 SC-T-30 要求「测评录入触发五维维度变化」——recordResult 后必须刷新五维维度分。
+        Field dimField = findFieldOfType(svc, DIMENSION_SERVICE);
+        assertNotNull(dimField,
+                "RED[dan.assessment.resultRecord] SC-T-30 未实现:AssessmentService 未注入 "
+                        + "DimensionScoreService。契约要求录入测评结果后触发五维维度变化"
+                        + "(refreshFromDanAssessment(memberId, resultId, dimension))。实现未落地。");
+    }
+
+    // ==================== SC-T-07:录入触发方案生成链(未落地 → RED) ====================
+
+    @Test
+    @DisplayName("SC-T-07:录入测评结果应触发方案生成链")
+    void scT07_recordResult_triggersPlanGenerationChain() {
+        Class<?> svc = loadClass(SERVICE_CLASS);
+        assertNotNull(svc, "RED[dan.assessment.resultRecord] AssessmentService 类不存在");
+
+        // 结构探测:AssessmentService 应具备方案生成触发钩子(AssessmentPlanGenerator 或
+        // TaskPlanService / GrowthPlanService 一类方案生成能力)。契约 SC-T-07 要求「录入测评
+        // 结果触发方案生成链」——参照 AssessmentReportController.confirmUpload 的
+        // planGenerator.generate(...) 先例,recordResult 录入后必须触发方案生成。
+        boolean hasPlanHook = hasFieldOfAnyType(svc,
+                "com.etotem.cfc.service.AssessmentPlanGenerator",
+                "com.etotem.cfc.service.TaskPlanService",
+                "com.etotem.cfc.service.GrowthPlanService");
+        assertTrue(hasPlanHook,
+                "RED[dan.assessment.resultRecord] SC-T-07 未实现:AssessmentService.recordResult "
+                        + "未触发方案生成链(无 plan generator 钩子)。契约要求录入测评结果后"
+                        + "自动生成测评方案(参照 confirmUpload 的 planGenerator.generate 先例)。"
+                        + "实现未落地。");
+    }
+
+    // ==================== 反射辅助 ====================
+
+    /** 反射注入字段值(沿类层次向上查找;字段缺失即抛异常 → 用例失败) */
+    private static void inject(Object target, String fieldName, Object value) throws Exception {
+        Class<?> cls = target.getClass();
+        Field f = null;
+        while (cls != null && f == null) {
+            try {
+                f = cls.getDeclaredField(fieldName);
+            } catch (NoSuchFieldException e) {
+                cls = cls.getSuperclass();
+            }
+        }
+        if (f == null) {
+            throw new NoSuchFieldException(target.getClass().getName() + "#" + fieldName);
+        }
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
+    private static Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    private static Field findFieldOfType(Class<?> clazz, String typeName) {
+        for (Class<?> c = clazz; c != null; c = c.getSuperclass()) {
+            for (Field f : c.getDeclaredFields()) {
+                if (typeName.equals(f.getType().getName())) {
+                    return f;
+                }
+            }
+        }
+        return null;
+    }
+
+    private static boolean hasFieldOfAnyType(Class<?> clazz, String... typeNames) {
+        for (Class<?> c = clazz; c != null; c = c.getSuperclass()) {
+            for (Field f : c.getDeclaredFields()) {
+                for (String tn : typeNames) {
+                    if (tn.equals(f.getType().getName())) {
+                        return true;
+                    }
+                }
+            }
+        }
+        return false;
+    }
+}