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