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