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