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