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

test(cfc-backend): S-cfc-194 teacher.dimension.score 契约级 RED 单测

EVD-10 契约(SC-T-30/31/32)补契约 JUnit 证据,纯 Mockito/反射、无 Spring/DB:
- SC-T-30 测评录入触发五维变化:锚定 AssessmentService → DimensionScoreService.refreshFromDanAssessment 桥接(绿)
- SC-T-31 手动维度打分:ManualDimensionScoreController submit/latest/history + submit 落 five_dimension_scores(source_type=manual)(绿)
- SC-T-32 五维自评录入与变化:自评 submit 应桥接五维分数/能量落点(DimensionScoreService/EnergyService/FiveDimensionScoreMapper)+ five_dimension_scores.source_type 应含 selfcheck 来源(红:当前 submitSelfCheck 仅写 five_dimension_self_checks,契约"自评→变化落 EnergyController"未落地)

RED 结果:Tests run: 6, Failures: 2, Errors: 0(2 红=SC-T-32 桥接缺口,GREEN 阶段实现后转绿)
zxyj-langgraph 1 час назад
Родитель
Сommit
69e8ed20e7

+ 295 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/TeacherDimensionScoreContractTest.java

@@ -0,0 +1,295 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.controller.family.ManualDimensionScoreController;
+import com.etotem.cfc.dto.ManualScoreSubmitDTO;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.FiveDimensionScore;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.FiveDimensionScoreMapper;
+import com.etotem.cfc.mapper.FiveDimensionSelfCheckMapper;
+import com.etotem.cfc.service.FiveDimensionSelfCheckService;
+import com.etotem.cfc.service.impl.ManualDimensionScoreServiceImpl;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Scanner;
+import java.util.Set;
+
+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.ArgumentMatchers.eq;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:成长规划师五维变化录入/打分(能力键 {@code teacher.dimension.score})。
+ *
+ * <p><b>需求/设计锚点(EVD-10 契约,2026-11-07)</b>:
+ * {@code docs/superpowers/specs/2026-11-07-planner-zone-contract.md} SC-T-30/31/32
+ * + {@code docs/superpowers/api/API_REFERENCE.md} §4.57 成长规划师域 + sc-key-map.md
+ * (SC-T-30/31/32 均「未跑通」,本测试为其补契约 JUnit 证据)。</p>
+ *
+ * <p><b>被校验的契约行为</b></p>
+ * <ul>
+ *   <li>SC-T-30 测评录入触发五维维度变化:{@code DanAssessmentController /result/record}
+ *       → {@code AssessmentService.recordResult} → {@link com.etotem.cfc.service.DimensionScoreService#refreshFromDanAssessment}</li>
+ *   <li>SC-T-31 手动维度打分覆盖/调整:{@code ManualDimensionScoreController /api/family/manual-score/*}
+ *       (submit/latest/history)→ 落 {@code five_dimension_scores}(source_type=manual)</li>
+ *   <li>SC-T-32 五维自评录入与变化:{@code FiveDimensionSelfCheckController /api/family/self-check/*}
+ *       (questions/submit/latest/history/status/ignore/generate-plan/trend-analysis/ai-generate-questions)
+ *       → <b>自评分数应桥接落五维变化</b>(契约原文「五维自评 → 变化落 EnergyController」)</li>
+ * </ul>
+ *
+ * <p><b>实现现状(origin/cfclub 实测核实)</b>:
+ * SC-T-30/31 载体已落地(测评桥接钩子、手动打分 controller+service 均存在);
+ * <b>SC-T-32 的「自评 → 五维变化桥接」未落地</b>——{@code FiveDimensionSelfCheckService}
+ * 仅声明 {@code selfCheckMapper/familyMemberMapper/wuxingSourcingService/onboardingService/
+ * ignoreMapper/selfCheckAnalysisService/selfCheckTrendService} 等字段,<b>无任何五维变化落点
+ * (DimensionScoreService / EnergyService / FiveDimensionScoreMapper)引用</b>;
+ * schema.sql 中 {@code five_dimension_scores.source_type} 枚举注释
+ * (periodic/task/assessment/manual)也<b>无 selfcheck 来源</b>。故 SC-T-32 桥接用例预期红。</p>
+ *
+ * <p><b>测试范围</b>:纯契约级单测,Mockito + 反射结构探测,无 Spring / 无 DB / 无外部服务。</p>
+ */
+class TeacherDimensionScoreContractTest {
+
+    /** SC-T-31 五维编码(契约口径 body/mind/wisdom/action/wealth) */
+    private static final String[] DIMENSIONS = {"body", "mind", "wisdom", "action", "wealth"};
+
+    // ==================== SC-T-31 行为级绿测试的 mock ====================
+
+    @Mock
+    private FiveDimensionScoreMapper fiveDimensionScoreMapper;
+
+    @Mock
+    private FiveDimensionSelfCheckMapper selfCheckMapper;
+
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+
+    @InjectMocks
+    private ManualDimensionScoreServiceImpl manualScoreService;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    // ------------------------------------------------------------------
+    // 正常流-1(SC-T-31):手动维度打分端点 submit/latest/history 应存在
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-1(SC-T-31):ManualDimensionScoreController 应暴露 submit/latest/history 端点")
+    void manualScore_controllerExposesSubmitLatestHistory() {
+        Class<?> clazz = ManualDimensionScoreController.class;
+        String[] requiredPaths = {"/submit", "/latest", "/history"};
+        for (String path : requiredPaths) {
+            boolean found = false;
+            for (Method m : clazz.getDeclaredMethods()) {
+                org.springframework.web.bind.annotation.PostMapping pm =
+                        m.getAnnotation(org.springframework.web.bind.annotation.PostMapping.class);
+                if (pm != null && java.util.Arrays.asList(pm.value()).contains(path)) {
+                    found = true;
+                    break;
+                }
+            }
+            assertTrue(found,
+                    "[teacher.dimension.score] ManualDimensionScoreController 应暴露 POST "
+                            + path + " 端点(契约 SC-T-31 手动维度打分覆盖/调整)。");
+        }
+    }
+
+    // ------------------------------------------------------------------
+    // 正常流-2(SC-T-31):手动打分 submit 应落 five_dimension_scores(source_type=manual)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-2(SC-T-31):手动打分 submit 应写 five_dimension_scores(source_type=manual)")
+    void manualScore_submitPersistsManualSourceRecords() {
+        FamilyMember member = new FamilyMember();
+        member.setId(88001L);
+        member.setFamilyId(9001L);
+        member.setNickname("契约之子");
+
+        Map<String, Integer> scores = new LinkedHashMap<>();
+        scores.put("body", 80);
+        scores.put("mind", 60);
+        scores.put("wisdom", 70);
+        scores.put("action", 50);
+        scores.put("wealth", 90);
+
+        ManualScoreSubmitDTO dto = new ManualScoreSubmitDTO();
+        dto.setMemberId(88001L);
+        dto.setScores(scores);
+
+        when(familyMemberMapper.selectById(88001L)).thenReturn(member);
+
+        manualScoreService.submit(dto, 10001L);
+
+        ArgumentCaptor<FiveDimensionScore> captor = ArgumentCaptor.forClass(FiveDimensionScore.class);
+        verify(fiveDimensionScoreMapper, times(5)).insert(captor.capture());
+        Set<String> codes = new HashSet<>();
+        for (FiveDimensionScore record : captor.getAllValues()) {
+            codes.add(record.getDimensionCode());
+            assertTrue("manual".equals(record.getSourceType()),
+                    "[teacher.dimension.score] 手动打分应落 source_type=manual,实际="
+                            + record.getSourceType() + "(契约 SC-T-31)。");
+        }
+        for (String dim : DIMENSIONS) {
+            assertTrue(codes.contains(dim),
+                    "[teacher.dimension.score] 手动打分应覆盖五维编码 " + dim + "(契约 SC-T-31)。");
+        }
+        // 自检记录同步落表(复用 five_dimension_self_checks 记录手动填分)
+        verify(selfCheckMapper, times(1)).insert(any());
+    }
+
+    // ------------------------------------------------------------------
+    // 正常流-3(SC-T-30):测评录入应触发五维维度变化(桥接钩子存在)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-3(SC-T-30):DimensionScoreService 应提供 refreshFromDanAssessment 桥接")
+    void dimensionScoreService_exposesDanAssessmentRefreshHook() throws Exception {
+        Class<?> ds = Class.forName("com.etotem.cfc.service.DimensionScoreService");
+        boolean hasHook = false;
+        for (Method m : ds.getDeclaredMethods()) {
+            if ("refreshFromDanAssessment".equals(m.getName())) {
+                hasHook = true;
+                break;
+            }
+        }
+        assertTrue(hasHook,
+                "[teacher.dimension.score] DimensionScoreService 应提供 refreshFromDanAssessment"
+                        + "(契约 SC-T-30 测评录入触发五维维度变化)。");
+
+        // AssessmentService 应注入该桥接(recordResult 触发点)
+        Class<?> as = Class.forName("com.etotem.cfc.service.AssessmentService");
+        boolean injected = false;
+        for (Field f : as.getDeclaredFields()) {
+            if ("com.etotem.cfc.service.DimensionScoreService".equals(f.getType().getName())) {
+                injected = true;
+                break;
+            }
+        }
+        assertTrue(injected,
+                "[teacher.dimension.score] AssessmentService 应注入 DimensionScoreService"
+                        + "(recordResult 录入测评结果时触发五维维度变化,契约 SC-T-30)。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流-1(SC-T-32,预期红):五维自评应桥接落五维变化(契约「自评 → 变化落 EnergyController」)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-1(SC-T-32,红):五维自评提交应桥接五维分数/能量变化(当前未落地)")
+    void selfCheck_submitShouldBridgeDimensionScoreChange() {
+        Class<?> clazz = FiveDimensionSelfCheckService.class;
+        String[] bridgeTypes = {
+                "com.etotem.cfc.service.DimensionScoreService", // 维度分桥接(refactor 同 SC-T-30)
+                "com.etotem.cfc.service.EnergyService",          // 能量落点(契约「变化落 EnergyController」)
+                "com.etotem.cfc.mapper.FiveDimensionScoreMapper" // 直接落 five_dimension_scores
+        };
+        String found = null;
+        for (Field f : clazz.getDeclaredFields()) {
+            String type = f.getType().getName();
+            for (String t : bridgeTypes) {
+                if (t.equals(type)) {
+                    found = t;
+                    break;
+                }
+            }
+            if (found != null) {
+                break;
+            }
+        }
+        assertNotNull(found,
+                "[teacher.dimension.score][SC-T-32] 五维自评提交后应触发五维变化落点:"
+                        + "FiveDimensionSelfCheckService 未注入任何维度分/能量桥接服务(DimensionScoreService / "
+                        + "EnergyService / FiveDimensionScoreMapper 均无)。契约要求「五维自评 → 变化落 "
+                        + "EnergyController」,当前自评只写 five_dimension_self_checks 表,"
+                        + "未桥接 five_dimension_scores / 五维能量。实现未落地,测试预期红。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流-2(SC-T-32,预期红):five_dimension_scores.source_type 应有自评来源
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-2(SC-T-32,红):five_dimension_scores.source_type 应含 selfcheck 来源(当前缺)")
+    void schema_sourceType_shouldSupportSelfCheck() {
+        String schema = readSchemaSql();
+        assertTrue(schema.contains("five_dimension_scores"),
+                "[teacher.dimension.score] schema.sql 缺少 five_dimension_scores 建表。");
+
+        // 定位 five_dimension_scores 建表段的 source_type 枚举注释
+        int idx = schema.indexOf("CREATE TABLE IF NOT EXISTS five_dimension_scores");
+        assertTrue(idx >= 0,
+                "[teacher.dimension.score] schema.sql 未找到 five_dimension_scores 建表语句。");
+        int end = schema.indexOf("ENGINE=InnoDB", idx);
+        String ddl = end > idx ? schema.substring(idx, end) : schema.substring(idx);
+        assertTrue(ddl.contains("selfcheck"),
+                "[teacher.dimension.score][SC-T-32] five_dimension_scores.source_type 枚举注释"
+                        + "(periodic/task/assessment/manual)缺少五维自评来源(selfcheck):"
+                        + "契约要求「五维自评 → 变化落 EnergyController」,自评分数应能写为五维分数变化,"
+                        + "当前建表定义无对应来源。实现未落地,测试预期红。");
+    }
+
+    // ------------------------------------------------------------------
+    // 权限流:teacher.dimension.score 为双角色业务域能力,无独立角色断言
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("权限流:手动打分/自评端点走 JWT 登录态,规划师双角色无独立角色断言")
+    void permissionFlow_teacherDualRoleNoSeparateRoleAssertion() {
+        // 说明性用例:SC-T-30/31/32 端点经 JwtInterceptor 登录态保护(@RequestAttribute("userId")),
+        // 规划师(role=teacher)与家长共用同端点;契约未定义独立 teacher-only 角色分支(与
+        // FamilyJoinValidationRulesContractTest 权限流口径一致)。不产生断言失败。
+        assertTrue(true,
+                "权限流说明:teacher.dimension.score 端点面向登录用户(含 role=teacher 双角色),"
+                        + "无独立角色门禁断言。");
+    }
+
+    // ------------------------------------------------------------------
+    // 辅助
+    // ------------------------------------------------------------------
+
+    private String readSchemaSql() {
+        try (InputStream is = getClass().getClassLoader().getResourceAsStream("schema.sql")) {
+            if (is == null) {
+                return "";
+            }
+            try (Scanner scanner = new Scanner(is, StandardCharsets.UTF_8.name())) {
+                scanner.useDelimiter("\\A");
+                return scanner.hasNext() ? scanner.next() : "";
+            }
+        } catch (Exception e) {
+            return "";
+        }
+    }
+
+    @SuppressWarnings("unused")
+    private Method findMethod(Class<?> clazz, String name) {
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().equals(name)) {
+                return m;
+            }
+        }
+        return null;
+    }
+}