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

test(S-cfc-210): mind.model.manage 契约测试 RED - 模型版本/开关/上下线/回退方案契约未落地,反射结构契约 10 红 1 绿(权限流说明)

Sisyphus Agent 6 часов назад
Родитель
Сommit
0b9c5edf40

+ 319 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/MindModelManageContractTest.java

@@ -0,0 +1,319 @@
+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.assertEquals;
+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 mind.model.manage},单元 mind)。
+ *
+ * <p><b>需求/设计锚点(SC-A-21)</b></p>
+ * <blockquote>
+ *   SC-A-21 · 舌诊/情绪模型管理 —— 目标:模型版本/开关。流程:版控/上下线。
+ *   边界:下线前回退方案。模块:模型管理。
+ * </blockquote>
+ * <p>用户故事:作为运营,我想管理舌诊/情绪模型版本,以便保障识别质量。</p>
+ *
+ * <p><b>契约 AC 条款(从 SC-A-21 提取,TDD-RED 锚点)</b></p>
+ * <ul>
+ *   <li><b>AC-1 模型版本/开关</b>:模型管理应提供<b>版本登记</b> + <b>上下线开关</b>的数据载体
+ *       —— 承载 {@code modelType}(emotion|tongue)、{@code version}、{@code enabled} 的模型实体/表。</li>
+ *   <li><b>AC-2 版控/上下线</b>:应提供<b>上线/下线</b>服务入口({@code online}/{@code offline})
+ *       与<b>版本列表</b>查询({@code listAll}),支撑运营版控。</li>
+ *   <li><b>AC-3 下线前回退方案(边界)</b>:下线必须<b>指定回退版本</b>({@code fallbackVersion}),
+ *       未指定回退方案时不得下线。</li>
+ *   <li><b>AC-4 非法版本 400(边界)</b>:对<b>非法/未知版本</b>的操作应返回 {@code Result} code 400。</li>
+ * </ul>
+ *
+ * <p><b>设计载体(API_REFERENCE §4.37/§4.14/§4.13 锚点)</b>:现有三个模型载体均已存在,但均
+ * <b>无模型版本/上下线管理</b>:
+ * <ul>
+ *   <li>{@code EmotionRecognitionController}(/api/mind/emotion:analyze/analyze-url)—— 情绪模型,无版本/开关</li>
+ *   <li>{@code TraditionalMirrorController}(/api/mind/traditional:mirror/family-dashboard/compatibility)—— 舌诊(传统)模型,无版本/开关</li>
+ *   <li>{@code InnatePortraitConfigController}(/api/admin/innate-portrait-config:list/update/invalidate)—— 先天画像来源权重,仅 weight/enabled/aiEnabled,无版本/上下线</li>
+ * </ul></p>
+ *
+ * <p><b>实现现状</b>:本分支 src/main 中 {@code MindModel} 实体、{@code MindModelManageService}
+ * 服务、{@code MindModelManageController} 控制器、{@code mind_model} 表均<b>未落地</b>
+ * (全库 0 引用,schema.sql 无对应建表)。因此本测试以「反射结构契约」探测实现缺失:
+ * 能编译通过、能运行、断言失败(红),且失败断言原文清晰可贴。</p>
+ *
+ * <p><b>GREEN 升级指引</b>:实现者创建 {@code MindModel} 实体(含 modelType/version/enabled/fallbackVersion)、
+ * {@code MindModelManageService}(含 listAll/online/offline/isEnabled,offline 三参含 fallbackVersion、
+ * 非法版本返回 Result 400)、建 {@code mind_model} 表与 {@code MindModelManageController}
+ * (/api/admin/mind-model:list/online/offline)后,本测试自动转绿。</p>
+ *
+ * <p><b>权限流说明</b>:本能力为平台运营内部模型管理(版本/开关/回退),无用户权限维度,
+ * 「权限(适用时)」三流中的权限流不适用,以说明性用例标注。</p>
+ */
+class MindModelManageContractTest {
+
+    /** 契约宿主服务类(未实现,GREEN 后落地)。 */
+    private static final String HOST_SERVICE = "com.etotem.cfc.service.MindModelManageService";
+
+    /** 契约模型实体类(未实现,GREEN 后落地)。 */
+    private static final String HOST_ENTITY = "com.etotem.cfc.entity.MindModel";
+
+    /** 契约管理控制器类(未实现,GREEN 后落地)。 */
+    private static final String HOST_CONTROLLER = "com.etotem.cfc.controller.admin.MindModelManageController";
+
+    /** 契约承载表(AC-1/AC-3 数据载体)。 */
+    private static final String MODEL_TABLE = "mind_model";
+
+    /** AC-1 模型类型字段(emotion|tongue)。 */
+    private static final String FIELD_MODEL_TYPE = "modelType";
+
+    /** AC-1 模型版本字段。 */
+    private static final String FIELD_VERSION = "version";
+
+    /** AC-1 上下线开关字段。 */
+    private static final String FIELD_ENABLED = "enabled";
+
+    /** AC-3 下线前回退版本字段。 */
+    private static final String FIELD_FALLBACK = "fallbackVersion";
+
+    // ------------------------------------------------------------------
+    // 正常流-AC1:模型版本/开关的数据载体(MindModel 实体 + mind_model 表)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-AC1-1:MindModel 实体应存在(模型版本/开关数据载体)")
+    void mindModel_entityExists() {
+        assertNotNull(loadClass(HOST_ENTITY),
+                "[mind.model.manage] MindModel 实体不存在:契约 AC-1 要求模型版本/开关的数据载体"
+                        + "(modelType/version/enabled),实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流-AC1-2:MindModel 实体应含 modelType/version/enabled 字段")
+    void mindModel_entityCarriesVersionAndSwitch() {
+        Class<?> clazz = loadClass(HOST_ENTITY);
+        if (clazz == null) {
+            fail("[mind.model.manage] MindModel 实体不存在,无法验证字段(AC-1)。");
+        }
+        assertField(clazz, FIELD_MODEL_TYPE, "MindModel 应含 modelType(emotion|tongue)字段(AC-1)。");
+        assertField(clazz, FIELD_VERSION, "MindModel 应含 version(模型版本)字段(AC-1)。");
+        assertField(clazz, FIELD_ENABLED, "MindModel 应含 enabled(上下线开关)字段(AC-1)。");
+    }
+
+    // ------------------------------------------------------------------
+    // 正常流-AC2:版控/上下线服务入口(listAll/online/offline)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-AC2-1:MindModelManageService 服务类应存在")
+    void modelManageService_classExists() {
+        assertNotNull(loadClass(HOST_SERVICE),
+                "[mind.model.manage] MindModelManageService 类不存在:契约 AC-2 要求版控/上下线服务入口"
+                        + "(listAll/online/offline),实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流-AC2-2:MindModelManageService 应提供 listAll/online/offline/isEnabled 方法")
+    void modelManageService_hasVersionControlMethods() {
+        Class<?> clazz = loadClass(HOST_SERVICE);
+        if (clazz == null) {
+            fail("[mind.model.manage] MindModelManageService 类不存在,无法验证版控方法(AC-2)。");
+        }
+        assertMethod(clazz, "listAll", "MindModelManageService 应提供 listAll() 版本列表(版控,AC-2)。");
+        assertMethod(clazz, "online", "MindModelManageService 应提供 online(...) 上线入口(AC-2)。");
+        assertMethod(clazz, "offline", "MindModelManageService 应提供 offline(...) 下线入口(AC-2)。");
+        assertMethod(clazz, "isEnabled", "MindModelManageService 应提供 isEnabled(...) 开关查询(AC-1/AC-2)。");
+    }
+
+    @Test
+    @DisplayName("正常流-AC2-3:MindModelManageController 应存在(模型管理 admin 端点)")
+    void modelManageController_exists() {
+        Class<?> clazz = loadClass(HOST_CONTROLLER);
+        if (clazz == null) {
+            fail("[mind.model.manage] MindModelManageController 不存在:契约 AC-2 要求模型管理 admin 端点"
+                    + "(/api/admin/mind-model:list/online/offline),实现未落地。");
+        }
+        assertMethod(clazz, "list", "MindModelManageController 应提供 list()(版控列表,AC-2)。");
+        assertMethod(clazz, "online", "MindModelManageController 应提供 online(...)(上线,AC-2)。");
+        assertMethod(clazz, "offline", "MindModelManageController 应提供 offline(...)(下线,AC-2)。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流-AC3:下线前需回退方案(fallbackVersion 必填)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-AC3-1:offline 应接受回退版本参数(三参签名)")
+    void offline_requiresFallbackVersionParam() {
+        Class<?> clazz = loadClass(HOST_SERVICE);
+        if (clazz == null) {
+            fail("[mind.model.manage] MindModelManageService 类不存在,无法验证 offline 签名(AC-3)。");
+        }
+        Method m = findMethod(clazz, "offline");
+        assertNotNull(m, "[mind.model.manage] MindModelManageService 应提供 offline(...)(AC-3)。");
+        assertEquals(3, m.getParameterCount(),
+                "[mind.model.manage] offline(modelType, version, fallbackVersion) 应为三参签名:"
+                        + "下线必须接受回退版本 fallbackVersion(AC-3 下线前回退方案)。");
+    }
+
+    @Test
+    @DisplayName("边界流-AC3-2:MindModel 实体应含 fallbackVersion(承载回退方案)")
+    void mindModel_entityCarriesFallbackVersion() {
+        Class<?> clazz = loadClass(HOST_ENTITY);
+        if (clazz == null) {
+            fail("[mind.model.manage] MindModel 实体不存在,无法验证 fallbackVersion(AC-3)。");
+        }
+        assertField(clazz, FIELD_FALLBACK, "MindModel 应含 fallbackVersion(下线前回退版本)字段(AC-3)。");
+    }
+
+    @Test
+    @DisplayName("边界流-AC3-3:未指定回退方案时下线应被拒(Result 400)")
+    void offline_withoutFallback_isRejected() {
+        Object host = contractHost();
+        Object rejected = invoke(host, "offline",
+                new Class<?>[]{String.class, String.class, String.class},
+                "emotion", "v1", null);
+        Integer code = readResultCode(rejected);
+        assertEquals(400, code,
+                "[mind.model.manage] offline 未指定 fallbackVersion 应被拒(Result code 400):"
+                        + "下线前需回退方案(AC-3 边界)。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流-AC4:非法版本 400
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-AC4-1:对非法/未知版本操作应返回 Result 400")
+    void illegalVersion_returns400() {
+        Object host = contractHost();
+        Object result = invoke(host, "online",
+                new Class<?>[]{String.class, String.class},
+                "emotion", "v999999");
+        Integer code = readResultCode(result);
+        assertEquals(400, code,
+                "[mind.model.manage] 对非法/未知版本(v999999)执行上线应返回 Result code 400(AC-4 边界)。");
+    }
+
+    @Test
+    @DisplayName("边界流-AC4-2:mind_model 表应落地 schema.sql(承载模型版本/开关)")
+    void schemaSql_containsMindModelTable() {
+        String schema = readSchemaSql();
+        assertTrue(schema.contains(MODEL_TABLE),
+                "[mind.model.manage] schema.sql 缺少 " + MODEL_TABLE + " 建表:契约 AC-1 要求模型版本/开关"
+                        + "数据载体(modelType/version/enabled/fallbackVersion),实现未落地。");
+    }
+
+    // ------------------------------------------------------------------
+    // 权限流(不适用):平台运营内部模型管理,无用户权限维度
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("权限流:本能力为平台运营内部模型管理,无用户权限维度,权限流不适用")
+    void permissionFlow_notApplicable() {
+        // 说明性用例:契约 AC 为「运营版控/上下线/回退方案」内部管理链路,无用户权限校验维度。
+        // 不产生断言失败;仅为三流覆盖标注「不适用」。
+        assertTrue(true, "权限流不适用:mind.model.manage 为平台运营内部模型管理,无用户权限维度。");
+    }
+
+    // ------------------------------------------------------------------
+    // 反射辅助
+    // ------------------------------------------------------------------
+
+    /** 获取契约宿主服务实例(GREEN 后由 Spring 容器/直接实例化提供;RED 期类缺失即失败)。 */
+    private Object contractHost() {
+        Class<?> clazz = loadClass(HOST_SERVICE);
+        if (clazz == null) {
+            fail("[mind.model.manage] 契约宿主 MindModelManageService 未实现,无法验证行为级契约(AC-2/AC-3/AC-4)。");
+            return null;
+        }
+        try {
+            return clazz.getDeclaredConstructor().newInstance();
+        } catch (Exception e) {
+            // 宿主存在但无法无参实例化(如构造注入),退化为结构探测,行为用例标记跳过语义。
+            fail("[mind.model.manage] 契约宿主 MindModelManageService 无法实例化:" + e.getMessage()
+                    + "(GREEN 期应提供无参构造或以 mock 方式装配)。");
+            return null;
+        }
+    }
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    private void assertField(Class<?> clazz, String fieldName, String message) {
+        for (Field f : clazz.getDeclaredFields()) {
+            if (f.getName().equals(fieldName)) {
+                return;
+            }
+        }
+        fail("[mind.model.manage] " + message);
+    }
+
+    private void assertMethod(Class<?> clazz, String methodName, String message) {
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().equals(methodName)) {
+                return;
+            }
+        }
+        fail("[mind.model.manage] " + message);
+    }
+
+    private Method findMethod(Class<?> clazz, String methodName) {
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().equals(methodName)) {
+                return m;
+            }
+        }
+        return null;
+    }
+
+    private Object invoke(Object target, String methodName, Class<?>[] paramTypes, Object... args) {
+        try {
+            Method m = target.getClass().getMethod(methodName, paramTypes);
+            return m.invoke(target, args);
+        } catch (Exception e) {
+            String cause = e.getCause() != null ? e.getCause().getMessage() : e.getMessage();
+            fail("[mind.model.manage] 反射调用 " + methodName + " 失败:" + cause);
+            return null;
+        }
+    }
+
+    /** 反射读取 Result 的 code(getCode() 或 code 字段),契约宿主返回 Result 对象。 */
+    private Integer readResultCode(Object result) {
+        if (result == null) {
+            return null;
+        }
+        try {
+            Method getCode = result.getClass().getMethod("getCode");
+            Object code = getCode.invoke(result);
+            return code instanceof Integer ? (Integer) code : code instanceof Number ? ((Number) code).intValue() : null;
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    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 "";
+        }
+    }
+}