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