Browse Source

merge(S-cfc-192): 再同步 origin/cfclub 并发提交

Sisyphus 2 hours ago
parent
commit
cc5cd22bce

+ 257 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/TeacherMindRecordContractTest.java

@@ -0,0 +1,257 @@
+package com.etotem.cfc.unit;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * 契约级单测:规划师舌诊/情绪测评录入(能力键 {@code teacher.mind.record},SC-T-25)。
+ *
+ * <p><b>需求/设计溯源</b></p>
+ * <ul>
+ *   <li>需求:sc-key-map.md SC-T-25「舌诊/情绪测评录入(规划师)」(成长规划师域,EVD-10)</li>
+ *   <li>契约:{@code docs/superpowers/specs/2026-11-07-planner-zone-contract.md} §2(SC-T-25 端点核验)
+ *       + §5.5(舌诊链入库语义:parse-preview{@code type=tongue} → confirm{@code type=tongue} 入库,
+ *       <b>非 tongue 类型不落入舌诊链</b>)</li>
+ *   <li>设计:API_REFERENCE §4.57 建议能力键映射 {@code teacher.mind.record}(T-25)——“统一
+ *       {@code MindAssessmentController} 舌诊/情绪测评录入(规划师)”;模块结构参考
+ *       {@code docs/backup/superpowers/plans/2026-06-02-care-family-club-design.md}(mind 域
+ *       {@code controller/mind/MindAssessmentController} + {@code service/MindAssessmentService}
+ *       + {@code entity/MindPsychologicalAssessment},设计方法
+ *       {@code createAssessment(assessment)} 插入入库、{@code getAssessments(...)} 按
+ *       {@code assessmentDate} 倒序)</li>
+ * </ul>
+ *
+ * <p><b>被校验的契约行为(统一录入链路,规划师代客户录入)</b></p>
+ * <ul>
+ *   <li>{@code MindAssessmentController}(package {@code controller.mind})应作为规划师端
+ *       舌诊/情绪测评录入统一入口:提供 {@code create}(代录,受评客户按 memberId 归属)与
+ *       {@code list}(按客户成员查看)端点;</li>
+ *   <li>{@code MindAssessmentService} 应桥接既有双类载体(舌诊链 {@code TongueDiagnosisService}
+ *       + 情绪链 {@code EmotionCheckinService}),提供 {@code createAssessment} 入库与
+ *       {@code getAssessments} 查询;</li>
+ *   <li>{@code MindPsychologicalAssessment} 实体应含受评客户归属({@code memberId},遵循
+ *       用户模型统一口径,不用 userId/childId)、录入规划师归属({@code guideId})、测评类型
+ *       ({@code assessmentType} = tongue/emotion 双类)、状态机({@code status},默认
+ *       {@code draft} → {@code confirmed},对齐舌诊链)与测评时间({@code assessmentDate})。</li>
+ * </ul>
+ *
+ * <p><b>实现现状</b>:本分支 src/main 中 {@code MindAssessmentController} /
+ * {@code MindAssessmentService} / {@code MindPsychologicalAssessment} 均<b>未落地</b>
+ * (三者全库 0 引用,grep 仅命中 backup 设计文档;舌诊/情绪双类现有载体
+ * {@code HealthReportController tongue 链} 与 {@code EmotionCheckinController} 为分散端点,
+ * 尚无统一规划师录入入口)。因此本测试以「反射结构契约」探测实现缺失:能编译、能运行、
+ * 断言失败(红),失败断言原文清晰可贴。</p>
+ *
+ * <p><b>GREEN 升级指引</b>:实现者按设计落地 {@code MindAssessmentController}(create/list)、
+ * {@code MindAssessmentService}(createAssessment/getAssessments)、
+ * {@code MindPsychologicalAssessment} 实体(含 memberId/guideId/assessmentType/status/
+ * assessmentDate 字段,status 默认 draft)后,本测试自动转绿;届时可将结构探测升级为
+ * 行为级 Mockito 验证(verify 入库 + state 流转 + 非 tongue 不落舌诊链)。</p>
+ *
+ * <p><b>权限流说明</b>:规划师(role=teacher)角色隔离由 {@code JwtInterceptor} +
+ * 既有 SC-T-13「规划师视角数据隔离」契约(guide_families isBound)覆盖,本键专注统一录入
+ * 链路自身契约,权限流以说明性用例标注「不适用」,不重复断言。</p>
+ *
+ * <p><b>RED 预期</b>:三个契约宿主类均未实现,全部用例在类探测/方法探测/字段探测处失败(红),
+ * 断言原文为「[teacher.mind.record] ... 未实现(实现未落地)」及同类文案。</p>
+ */
+class TeacherMindRecordContractTest {
+
+    /** 统一录入控制器(设计锚点:controller/mind 包,API_REFERENCE §4.57)。 */
+    private static final String HOST_CONTROLLER = "com.etotem.cfc.controller.mind.MindAssessmentController";
+
+    /** 统一录入服务(桥接 TongueDiagnosisService 舌诊链 + EmotionCheckinService 情绪链)。 */
+    private static final String HOST_SERVICE = "com.etotem.cfc.service.MindAssessmentService";
+
+    /** 心理测评记录实体(含受评客户/录入规划师归属与状态机)。 */
+    private static final String HOST_ENTITY = "com.etotem.cfc.entity.MindPsychologicalAssessment";
+
+    // ------------------------------------------------------------------
+    // 正常流:统一录入控制器存在并提供 代录/查看 端点
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-1:MindAssessmentController 类应存在(规划师舌诊/情绪测评录入统一入口)")
+    void controller_mindAssessmentController_classExists() {
+        assertNotNull(loadClass(HOST_CONTROLLER),
+                "[teacher.mind.record] MindAssessmentController 类不存在(controller/mind 包):"
+                        + "契约要求规划师端舌诊/情绪测评录入统一入口(API_REFERENCE §4.57 / EVD-10 SC-T-25)。"
+                        + "实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流-2:MindAssessmentController 应提供 create 方法(规划师代录,受评客户按 memberId 归属)")
+    void controller_createEndpoint_exists() {
+        Class<?> clazz = loadClass(HOST_CONTROLLER);
+        if (clazz == null) {
+            fail("[teacher.mind.record] MindAssessmentController 类不存在,无法验证 create 代录入口。");
+        }
+        assertTrue(hasMethod(clazz, "create"),
+                "[teacher.mind.record] MindAssessmentController 应提供 create 方法:"
+                        + "规划师代客户录入舌诊/情绪测评(受评客户按 memberId 归属,不落 userId/childId)。");
+    }
+
+    @Test
+    @DisplayName("正常流-3:MindAssessmentController 应提供 list 方法(规划师按客户成员查看测评记录)")
+    void controller_listEndpoint_exists() {
+        Class<?> clazz = loadClass(HOST_CONTROLLER);
+        if (clazz == null) {
+            fail("[teacher.mind.record] MindAssessmentController 类不存在,无法验证 list 查看入口。");
+        }
+        assertTrue(hasMethod(clazz, "list"),
+                "[teacher.mind.record] MindAssessmentController 应提供 list 方法:"
+                        + "规划师按受评客户成员查看舌诊/情绪测评记录。");
+    }
+
+    // ------------------------------------------------------------------
+    // 正常流:统一录入服务存在并提供 入库/查询 能力
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-4:MindAssessmentService 类应存在(统一录入服务,桥接舌诊链 + 情绪链)")
+    void service_mindAssessmentService_classExists() {
+        assertNotNull(loadClass(HOST_SERVICE),
+                "[teacher.mind.record] MindAssessmentService 类不存在:契约要求统一录入服务"
+                        + "桥接 TongueDiagnosisService 舌诊链 + EmotionCheckinService 情绪链"
+                        + "(API_REFERENCE §4.57 / EVD-10 SC-T-25)。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流-5:MindAssessmentService 应提供 createAssessment 方法(测评记录入库)")
+    void service_createAssessment_methodExists() {
+        Class<?> clazz = loadClass(HOST_SERVICE);
+        if (clazz == null) {
+            fail("[teacher.mind.record] MindAssessmentService 类不存在,无法验证 createAssessment 入库。");
+        }
+        assertTrue(hasMethod(clazz, "createAssessment"),
+                "[teacher.mind.record] MindAssessmentService 应提供 createAssessment(...) 方法:"
+                        + "测评记录入库(设计 createAssessment(assessment) → mapper.insert)。");
+    }
+
+    @Test
+    @DisplayName("正常流-6:MindAssessmentService 应提供 getAssessments 方法(按 assessmentDate 倒序查询)")
+    void service_getAssessments_methodExists() {
+        Class<?> clazz = loadClass(HOST_SERVICE);
+        if (clazz == null) {
+            fail("[teacher.mind.record] MindAssessmentService 类不存在,无法验证 getAssessments 查询。");
+        }
+        assertTrue(hasMethod(clazz, "getAssessments"),
+                "[teacher.mind.record] MindAssessmentService 应提供 getAssessments(...) 方法:"
+                        + "按受评客户成员查询测评记录,orderByDesc(assessmentDate)(设计口径)。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:测评记录实体承载归属/类型/状态机(含默认值契约)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-1:MindPsychologicalAssessment 实体应存在(心理测评记录载体)")
+    void entity_mindPsychologicalAssessment_classExists() {
+        assertNotNull(loadClass(HOST_ENTITY),
+                "[teacher.mind.record] MindPsychologicalAssessment 实体不存在:契约要求测评记录实体"
+                        + "承载归属、类型与状态机(设计 mind 域实体)。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("边界流-2:实体应含 memberId 字段(受评客户归属,用户模型统一口径)")
+    void entity_hasMemberIdField() {
+        assertField(HOST_ENTITY, "memberId",
+                "实体应含 memberId 字段:受评客户家庭成员归属(遵循用户模型统一口径,"
+                        + "禁止回退 userId/childId 表达受评主体)");
+    }
+
+    @Test
+    @DisplayName("边界流-3:实体应含 guideId 字段(录入规划师归属,代录链路)")
+    void entity_hasGuideIdField() {
+        assertField(HOST_ENTITY, "guideId",
+                "实体应含 guideId 字段:录入规划师(role=teacher)归属,代录链路审计必备");
+    }
+
+    @Test
+    @DisplayName("边界流-4:实体应含 assessmentType 字段(tongue/emotion 双类,SC-T-25)")
+    void entity_hasAssessmentTypeField() {
+        assertField(HOST_ENTITY, "assessmentType",
+                "实体应含 assessmentType 字段:测评类型 tongue(舌诊)/ emotion(情绪)双类,"
+                        + "对应 SC-T-25 两类录入;非 tongue 类型不得落入舌诊链(契约 §5.5)");
+    }
+
+    @Test
+    @DisplayName("边界流-5:实体应含 status 字段且新建默认 draft(draft→confirmed 状态机,对齐舌诊链)")
+    void entity_hasStatusFieldWithDefaultDraft() {
+        assertField(HOST_ENTITY, "status",
+                "实体应含 status 字段:测评记录状态机默认 draft(新建未确认)→ confirmed(规划师确认入库),"
+                        + "对齐舌诊链 draft→confirmed 语义(契约 §5.5);默认值未落地即视为缺陷");
+    }
+
+    @Test
+    @DisplayName("边界流-6:实体应含 assessmentDate 字段(测评时间,倒序查询排序键)")
+    void entity_hasAssessmentDateField() {
+        assertField(HOST_ENTITY, "assessmentDate",
+                "实体应含 assessmentDate 字段:测评时间,getAssessments 按此倒序返回(设计口径)");
+    }
+
+    // ------------------------------------------------------------------
+    // 权限流(不适用):角色隔离由 JwtInterceptor + SC-T-13 契约覆盖
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("权限流:规划师角色隔离由 JwtInterceptor + SC-T-13 契约覆盖,本键不重复断言(说明性)")
+    void permissionFlow_notApplicable() {
+        // 说明性用例:teacher.mind.record 为规划师代录统一链路契约;访问边界(仅绑定家庭客户可查)
+        // 由 JwtInterceptor 角色隔离 + 既有「teacher.data.isolation」(SC-T-13)契约承担,本键不再重复。
+        assertTrue(true, "权限流不适用:teacher.mind.record 角色隔离由 JwtInterceptor + SC-T-13 契约覆盖。");
+    }
+
+    // ------------------------------------------------------------------
+    // 反射辅助(锚定契约宿主,避免编译期依赖不存在类)
+    // ------------------------------------------------------------------
+
+    private void assertField(String fqcn, String fieldName, String contractText) {
+        Class<?> clazz = loadClass(fqcn);
+        if (clazz == null) {
+            fail("[teacher.mind.record] " + simpleName(fqcn) + " 类不存在,无法验证字段 "
+                    + fieldName + ":" + contractText + "。实现未落地。");
+        }
+        assertTrue(hasField(clazz, fieldName),
+                "[teacher.mind.record] " + simpleName(fqcn) + " 应含 " + fieldName + " 字段:"
+                        + contractText + "。");
+    }
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    private boolean hasMethod(Class<?> clazz, String name) {
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().equals(name)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private boolean hasField(Class<?> clazz, String name) {
+        for (Field f : clazz.getDeclaredFields()) {
+            if (f.getName().equals(name)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private String simpleName(String fqcn) {
+        int idx = fqcn.lastIndexOf('.');
+        return idx >= 0 ? fqcn.substring(idx + 1) : fqcn;
+    }
+}