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

test(contract): TDD-RED child.mind.capture (SC-C-21) - S-cfc-221

契约级红测:孩子端舌诊/情绪自拍采集(child.mind.capture,SC-C-21)。
溯源:tests/user-scenarios 应用场景总览 SC-C-21 / tests/test-cases/child.md TC-C-21/21-E/21-P;
设计:API_REFERENCE §4.10 + §4.61.8(EmotionRecognitionController /analyze、/analyze-url + HealthReportController tongue 链)。

预期失败(红)15/16:
- MindCaptureService(analyzeFromImage/analyzeFromUrl/getRecordsByMember)未落地
- MindCaptureRecord 实体(memberId/captureType/confidenceScore/blurred/nonPhoto/privacyMarked/analysisResult/createdAt)未落地
- schema.sql 缺 mind_capture_record 建表
- EmotionRecognitionController 缺非照片/低置信度拒绝分支
- 权限流说明性标注(jwt 拦截域 + family.member.switch 契约覆盖)
E2E Test Bot 1 час назад
Родитель
Сommit
d1c3e04d11

+ 349 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ChildMindCaptureContractTest.java

@@ -0,0 +1,349 @@
+package com.etotem.cfc.unit;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.Scanner;
+
+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 child.mind.capture},SC-C-21)。
+ *
+ * <p><b>需求溯源</b></p>
+ * <ul>
+ *   <li>场景:tests/user-scenarios/应用场景总览.md SC-C-21「舌诊/情绪自拍采集(孩子端)」;
+ *       规格:tests/test-cases/child.md TC-C-21 / TC-C-21-E / TC-C-21-P</li>
+ *   <li>设计:API_REFERENCE §4.10 + §4.61.8 — EmotionRecognitionController
+ *       ({@code /api/mind/emotion/analyze} / {@code /analyze-url})+ HealthReportController
+ *       (tongue 链:{@code upload-only}/{@code parse-preview}/{@code confirm}/{@code discard})</li>
+ *   <li>TCD 缺口清单(child-zone-contract.md §5):C-21 模块当前仅暴露基础情绪识别端点;
+ *       尚未以「采集记录」形态落地服务层、记录实体与异常拒绝/隐私标注语义。</li>
+ * </ul>
+ *
+ * <p><b>被校验的契约行为</b></p>
+ * <ul>
+ *   <li><b>服务层</b>:{@code MindCaptureService} 应存在并提供
+ *       {@code analyzeFromImage/MultipartFile}(拍照上传)与
+ *       {@code analyzeFromUrl/String}(URL 兼容)两个主入口,以及
+ *       {@code getRecordsByMember(Long)} 查询受评孩子的采集历史。</li>
+ *   <li><b>实体层</b>:{@code MindCaptureRecord} 承载完整采集记录,含
+ *       {@code memberId}(家庭成员归属)、{@code captureType}
+ *       (tongue/emotion 双类)、{@code confidenceScore}(置信度)、
+ *       {@code blurred}(模糊标记)、{@code nonPhoto}(非照片标记)、
+ *       {@code privacyMarked}(隐私标注开关)、{@code analysisResult}
+ *       (JSON 快照)与创建时间。</li>
+ *   <li><b>控制器边界</b>:{@code EmotionRecognitionController.analyze()} 应对低置信度
+ *       /模糊结果返回错误而非正常 200;对非照片文件类型应抛出明确拒绝。</li>
+ *   <li><b>schema 锚定</b>:{@code mind_capture_record} 建表语句须落库 schema.sql。</li>
+ * </ul>
+ *
+ * <p><b>实现现状</b>:本分支 src/main 中
+ * {@code MindCaptureService} / {@code MindCaptureRecord} 均<b>未落地</b>
+ * (全库 0 引用,grep 无命中;EmotionRecognitionController 仅有基础的
+ * 文件转发给 AiGateway 的逻辑,缺少 blurred/nonPhoto/low-confidence 拒绝路径
+ * 与 privacy_marked 响应字段)。因此本测试以「反射结构契约」探测缺失,
+ * 预期全部失败(红),断言原文清晰可贴。</p>
+ *
+ * <p><b>GREEN 升级指引</b>:实现者创建 {@code MindCaptureService}(含
+ * analyzeFromImage/analyzeFromUrl/getRecordsByMember)+
+ * {@code MindCaptureRecord} 实体(含 memberId/captureType/confidenceScore/blurred/
+ * nonPhoto/privacyMarked/analysisResult/createdAt 字段)后,
+ * 并在 {@code EmotionRecognitionController.analyze()} 补齐低置信度/非照片/模糊拒绝逻辑,
+ * schema.sql 追加建表语句,本测试自动转绿。</p>
+ *
+ * <p><b>权限流说明</b>:本能力为「孩子端视角的拍摄→识别→入库」链路,
+ * 核心采集接口均落在 jwt 拦截的 {@code /api/**} 下(JWT 已强制登录),
+ * 角色维度的权限由 {@link com.etotem.cfc.config.JwtInterceptor} 统一下挂;
+ * 孩子端切换视角、代管等边界已在 {@code child.auth.login} /
+ * {@code family.member.switch} 契约覆盖。本键不重复断言,以说明性用例标注。</p>
+ */
+class ChildMindCaptureContractTest {
+
+    // ------------------------------------------------------------------
+    // 常量锚点
+    // ------------------------------------------------------------------
+
+    private static final String SERVICE_HOST = "com.etotem.cfc.service.MindCaptureService";
+    private static final String ENTITY_HOST   = "com.etotem.cfc.entity.MindCaptureRecord";
+    private static final String TABLE_NAME    = "mind_capture_record";
+
+    // ------------------------------------------------------------------
+    // 正常流:服务层结构契约
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-1:MindCaptureService 服务类应存在")
+    void service_mindCaptureService_classExists() {
+        assertNotNull(loadClass(SERVICE_HOST),
+                "[child.mind.capture] MindCaptureService 类不存在:"
+                        + "契约要求提供拍摄→识别→入库的统一服务层"
+                        + "(SC-C-21 / EVD-9 孩子端契约)。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流-2:MindCaptureService 应提供 analyzeFromImage 方法(拍照上传路径)")
+    void service_analyzeFromImage_methodExists() {
+        Class<?> clazz = loadClass(SERVICE_HOST);
+        if (clazz == null) {
+            fail("[child.mind.capture] MindCaptureService 类不存在,无法验证分析入口。");
+        }
+        assertTrue(hasMethod(clazz, "analyzeFromImage"),
+                "[child.mind.capture] MindCaptureService 应提供 analyzeFromImage 方法:"
+                        + "对应孩子端拍照上传后的情绪/舌诊识别主流程(SC-C-21 主流程:拍照 → 上传 → 识别)。");
+    }
+
+    @Test
+    @DisplayName("正常流-3:MindCaptureService 应提供 analyzeFromUrl 方法(URL 兼容路径)")
+    void service_analyzeFromUrl_methodExists() {
+        Class<?> clazz = loadClass(SERVICE_HOST);
+        if (clazz == null) {
+            fail("[child.mind.capture] MindCaptureService 类不存在,无法验证 URL 分析入口。");
+        }
+        assertTrue(hasMethod(clazz, "analyzeFromUrl"),
+                "[child.mind.capture] MindCaptureService 应提供 analyzeFromUrl 方法:"
+                        + "兼容已有调用方通过 URL 发起的情绪识别调用(对齐 EmotionRecognitionController /analyze-url)。");
+    }
+
+    @Test
+    @DisplayName("正常流-4:MindCaptureService 应提供 getRecordsByMember 方法(按孩子查看采集历史)")
+    void service_getRecordsByMember_methodExists() {
+        Class<?> clazz = loadClass(SERVICE_HOST);
+        if (clazz == null) {
+            fail("[child.mind.capture] MindCaptureService 类不存在,无法验证历史查询入口。");
+        }
+        assertTrue(hasMethod(clazz, "getRecordsByMember"),
+                "[child.mind.capture] MindCaptureService 应提供 getRecordsByMember 方法:"
+                        + "支持家长/规划师查询某孩子的舌诊/情绪采集历史记录。");
+    }
+
+    // ------------------------------------------------------------------
+    // 正常流:实体层结构契约
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-5:MindCaptureRecord 实体类应存在")
+    void entity_mindCaptureRecord_classExists() {
+        assertNotNull(loadClass(ENTITY_HOST),
+                "[child.mind.capture] MindCaptureRecord 实体不存在:"
+                        + "契约要求采集记录实体承载孩子侧每一次拍照识别的结果与状态"
+                        + "(SC-C-21 / EVD-9 孩子端契约)。实现未落地。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:实体字段——成员归属(用户模型统一口径)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-1:实体应含 memberId 字段(受评孩子归属,遵循用户模型统一口径)")
+    void entity_hasMemberIdField() {
+        assertField(ENTITY_HOST, "memberId",
+                "实体应含 memberId 字段:受评孩子的家庭成员归属"
+                        + "(遵循 AGENTS.md 统一口径,禁止回退 userId/childId 表达受评主体)");
+    }
+
+    @Test
+    @DisplayName("边界流-2:实体应含 captureType 字段(tongue / emotion 双类)")
+    void entity_hasCaptureTypeField() {
+        assertField(ENTITY_HOST, "captureType",
+                "实体应含 captureType 字段:采集类型 tongue(舌诊)/ emotion(情绪自拍)"
+                        + "双类,SC-C-21 同时覆盖两类,类型标识写入记录便于分类展示与趋势统计");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:实体字段——置信度与异常拒绝标记
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-3:实体应含 confidenceScore 字段(AI 识别置信度)")
+    void entity_hasConfidenceScoreField() {
+        assertField(ENTITY_HOST, "confidenceScore",
+                "实体应含 confidenceScore 字段:AI 识别结果的置信度值,"
+                        + "配合 EmotionRecognitionController 判定低置信度后拒绝入库");
+    }
+
+    @Test
+    @DisplayName("边界流-4:实体应含 blurred 字段(是否触发模糊拒绝)")
+    void entity_hasBlurredField() {
+        assertField(ENTITY_HOST, "blurred",
+                "实体应含 blurred 字段(Boolean):采集时因图像模糊导致识别不可信时置 true;"
+                        + "TC-C-21-E 异常流要求「模糊拒绝并提示重拍」,blurred 记录此次异常原因");
+    }
+
+    @Test
+    @DisplayName("边界流-5:实体应含 nonPhoto 字段(是否触发非照片/恶意文件拒绝)")
+    void entity_hasNonPhotoField() {
+        assertField(ENTITY_HOST, "nonPhoto",
+                "实体应含 nonPhoto 字段(Boolean):上传文件非有效图片(纯文本/.php/.html 等)时置 true;"
+                        + "TC-C-21-E 异常流要求「非照片拒绝」,非照片记录保留审计痕迹");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:实体字段——隐私与元数据
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-6:实体应含 privacyMarked 字段(隐私标注开关,默认 false)")
+    void entity_hasPrivacyMarkedField() {
+        assertField(ENTITY_HOST, "privacyMarked",
+                "实体应含 privacyMarked 字段(Boolean):隐私标注开关;"
+                        + "TC-C-21/E/P 三流均强调「隐私标注不外露」,该字段作为后续对外视图裁剪依据");
+    }
+
+    @Test
+    @DisplayName("边界流-7:实体应含 analysisResult 字段(JSON 快照,存储原始识别结果)")
+    void entity_hasAnalysisResultField() {
+        assertField(ENTITY_HOST, "analysisResult",
+                "实体应含 analysisResult 字段:JSON 字符串存放完整的 AiGateway 返回快照"
+                        + "(emotions / dominant_emotion / dominant_label_zh / all_emotions 或 tongue 指标),"
+                        + "便于事后审计与可视化还原");
+    }
+
+    @Test
+    @DisplayName("边界流-8:实体应含 createdAt 字段(采集时间,倒序排序键)")
+    void entity_hasCreatedAtField() {
+        assertField(ENTITY_HOST, "createdAt",
+                "实体应含 createdAt 字段:采集时间戳,getRecordsByMember 按此倒序排列返回历史列表");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:schema.sql 应包含 mind_capture_record 建表定义
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-9:schema.sql 应含 mind_capture_record 建表定义(含 capture_type 列)")
+    void schemaSql_containsMindCaptureTable() {
+        String schema = readSchemaSql();
+        assertTrue(schema.contains(TABLE_NAME),
+                "[child.mind.capture] schema.sql 缺少 " + TABLE_NAME + " 建表:"
+                        + "MindCaptureRecord 实体的持久化载体必须同步落地 schema.sql;"
+                        + "缺失则迁移(DatabaseInitializer.ensureColumn/CREATE TABLE)也会找不到表。");
+        assertTrue(schema.contains("capture_type"),
+                "[child.mind.capture] schema.sql 中 " + TABLE_NAME
+                        + " 应含 capture_type 列(区分 tongue/emotion 双类采集)");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:EmotionRecognitionController 应校验低置信度 / 非照片并返回错误
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-10:EmotionRecognitionController 源码应含非照片/低置信度拒绝的契约关键词")
+    void controller_shouldContainRejectKeywords() {
+        String source = readControllerSource("com/etotem/cfc/controller/mind/EmotionRecognitionController.java");
+        assertTrue(source.contains("非照片") || source.contains("not_photo") || source.contains("invalid image")
+                        || source.contains("请上传清晰") || source.contains("image_url 不能为空")
+                        || source.contains("image cannot"),
+                "[child.mind.capture] EmotionRecognitionController 源码缺少非照片/低置信度拒绝校验:"
+                        + "TC-C-21-E 要求「非照片/模糊拒绝」;契约端点至少应在方法体内做输入合法性校验"
+                        + "(当前仅有 file==null/empty 和 size>10MB 的基本校验,尚缺非照片/低置信度分支)。");
+    }
+
+    // ------------------------------------------------------------------
+    // 权限流(不适用):jwt 拦截器已统一下挂 /api/** 登录门;角色维度由 family.member.switch
+    // 等既有契约覆盖,本键不再重复断言。
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("权限流:孩子端采集接口均在 jwt 拦截域内,角色隔离由 JwtInterceptor + 家卷体系契约覆盖,不重复断言(说明性)")
+    void permissionFlow_notApplicable() {
+        // 说明性用例:child.mind.capture 的采集接口 (/api/mind/emotion/*) 已被 JwtInterceptor 强制登录守卫;
+        // 「孩子仅见自身数据 / 家长代管切换视角」已在 child.auth.login、family.member.switch 契约覆盖。
+        // 本键仅聚焦拍摄→识别→入库链路本身的契约,权限维度不在本案断言范围。
+        assertTrue(true, "权限流不适用:child.mind.capture 依赖 JwtInterceptor 登录门 + 家庭视角切换契约覆盖。");
+    }
+
+    // ------------------------------------------------------------------
+    // 反射辅助(锚定契约宿主,避免编译期依赖不存在类)
+    // ------------------------------------------------------------------
+
+    private void assertField(String fqcn, String fieldName, String contractText) {
+        Class<?> clazz = loadClass(fqcn);
+        if (clazz == null) {
+            fail("[child.mind.capture] " + simpleName(fqcn) + " 类不存在,无法验证字段 "
+                    + fieldName + ":" + contractText + "。实现未落地。");
+        }
+        assertTrue(hasField(clazz, fieldName),
+                "[child.mind.capture] " + 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;
+    }
+
+    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 "";
+        }
+    }
+
+    private String readControllerSource(String relativePath) {
+        java.io.File base = new java.io.File(
+                System.getProperty("user.dir"),
+                "cfc-backend/src/main/java");
+        if (!base.isDirectory()) {
+            base = new java.io.File(".");
+        }
+        java.io.File src = new java.io.File(base, relativePath);
+        if (!src.isFile()) {
+            // fallback:通过 resource path
+            try (InputStream is = getClass().getClassLoader().getResourceAsStream(relativePath)) {
+                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 "";
+            }
+        }
+        try (Scanner scanner = new Scanner(src.toPath(), StandardCharsets.UTF_8.name())) {
+            scanner.useDelimiter("\\A");
+            return scanner.hasNext() ? scanner.next() : "";
+        } catch (Exception e) {
+            return "";
+        }
+    }
+}