MindModelManageContractTest.java 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. package com.etotem.cfc.unit;
  2. import org.junit.jupiter.api.DisplayName;
  3. import org.junit.jupiter.api.Test;
  4. import java.io.InputStream;
  5. import java.lang.reflect.Field;
  6. import java.lang.reflect.Method;
  7. import java.nio.charset.StandardCharsets;
  8. import java.util.Scanner;
  9. import static org.junit.jupiter.api.Assertions.assertEquals;
  10. import static org.junit.jupiter.api.Assertions.assertNotNull;
  11. import static org.junit.jupiter.api.Assertions.assertTrue;
  12. import static org.junit.jupiter.api.Assertions.fail;
  13. /**
  14. * 契约级单测:舌诊/情绪模型管理(能力键 {@code mind.model.manage},单元 mind)。
  15. *
  16. * <p><b>需求/设计锚点(SC-A-21)</b></p>
  17. * <blockquote>
  18. * SC-A-21 · 舌诊/情绪模型管理 —— 目标:模型版本/开关。流程:版控/上下线。
  19. * 边界:下线前回退方案。模块:模型管理。
  20. * </blockquote>
  21. * <p>用户故事:作为运营,我想管理舌诊/情绪模型版本,以便保障识别质量。</p>
  22. *
  23. * <p><b>契约 AC 条款(从 SC-A-21 提取,TDD-RED 锚点)</b></p>
  24. * <ul>
  25. * <li><b>AC-1 模型版本/开关</b>:模型管理应提供<b>版本登记</b> + <b>上下线开关</b>的数据载体
  26. * —— 承载 {@code modelType}(emotion|tongue)、{@code version}、{@code enabled} 的模型实体/表。</li>
  27. * <li><b>AC-2 版控/上下线</b>:应提供<b>上线/下线</b>服务入口({@code online}/{@code offline})
  28. * 与<b>版本列表</b>查询({@code listAll}),支撑运营版控。</li>
  29. * <li><b>AC-3 下线前回退方案(边界)</b>:下线必须<b>指定回退版本</b>({@code fallbackVersion}),
  30. * 未指定回退方案时不得下线。</li>
  31. * <li><b>AC-4 非法版本 400(边界)</b>:对<b>非法/未知版本</b>的操作应返回 {@code Result} code 400。</li>
  32. * </ul>
  33. *
  34. * <p><b>设计载体(API_REFERENCE §4.37/§4.14/§4.13 锚点)</b>:现有三个模型载体均已存在,但均
  35. * <b>无模型版本/上下线管理</b>:
  36. * <ul>
  37. * <li>{@code EmotionRecognitionController}(/api/mind/emotion:analyze/analyze-url)—— 情绪模型,无版本/开关</li>
  38. * <li>{@code TraditionalMirrorController}(/api/mind/traditional:mirror/family-dashboard/compatibility)—— 舌诊(传统)模型,无版本/开关</li>
  39. * <li>{@code InnatePortraitConfigController}(/api/admin/innate-portrait-config:list/update/invalidate)—— 先天画像来源权重,仅 weight/enabled/aiEnabled,无版本/上下线</li>
  40. * </ul></p>
  41. *
  42. * <p><b>实现现状</b>:本分支 src/main 中 {@code MindModel} 实体、{@code MindModelManageService}
  43. * 服务、{@code MindModelManageController} 控制器、{@code mind_model} 表均<b>未落地</b>
  44. * (全库 0 引用,schema.sql 无对应建表)。因此本测试以「反射结构契约」探测实现缺失:
  45. * 能编译通过、能运行、断言失败(红),且失败断言原文清晰可贴。</p>
  46. *
  47. * <p><b>GREEN 升级指引</b>:实现者创建 {@code MindModel} 实体(含 modelType/version/enabled/fallbackVersion)、
  48. * {@code MindModelManageService}(含 listAll/online/offline/isEnabled,offline 三参含 fallbackVersion、
  49. * 非法版本返回 Result 400)、建 {@code mind_model} 表与 {@code MindModelManageController}
  50. * (/api/admin/mind-model:list/online/offline)后,本测试自动转绿。</p>
  51. *
  52. * <p><b>权限流说明</b>:本能力为平台运营内部模型管理(版本/开关/回退),无用户权限维度,
  53. * 「权限(适用时)」三流中的权限流不适用,以说明性用例标注。</p>
  54. */
  55. class MindModelManageContractTest {
  56. /** 契约宿主服务类(未实现,GREEN 后落地)。 */
  57. private static final String HOST_SERVICE = "com.etotem.cfc.service.MindModelManageService";
  58. /** 契约模型实体类(未实现,GREEN 后落地)。 */
  59. private static final String HOST_ENTITY = "com.etotem.cfc.entity.MindModel";
  60. /** 契约管理控制器类(未实现,GREEN 后落地)。 */
  61. private static final String HOST_CONTROLLER = "com.etotem.cfc.controller.admin.MindModelManageController";
  62. /** 契约承载表(AC-1/AC-3 数据载体)。 */
  63. private static final String MODEL_TABLE = "mind_model";
  64. /** AC-1 模型类型字段(emotion|tongue)。 */
  65. private static final String FIELD_MODEL_TYPE = "modelType";
  66. /** AC-1 模型版本字段。 */
  67. private static final String FIELD_VERSION = "version";
  68. /** AC-1 上下线开关字段。 */
  69. private static final String FIELD_ENABLED = "enabled";
  70. /** AC-3 下线前回退版本字段。 */
  71. private static final String FIELD_FALLBACK = "fallbackVersion";
  72. // ------------------------------------------------------------------
  73. // 正常流-AC1:模型版本/开关的数据载体(MindModel 实体 + mind_model 表)
  74. // ------------------------------------------------------------------
  75. @Test
  76. @DisplayName("正常流-AC1-1:MindModel 实体应存在(模型版本/开关数据载体)")
  77. void mindModel_entityExists() {
  78. assertNotNull(loadClass(HOST_ENTITY),
  79. "[mind.model.manage] MindModel 实体不存在:契约 AC-1 要求模型版本/开关的数据载体"
  80. + "(modelType/version/enabled),实现未落地。");
  81. }
  82. @Test
  83. @DisplayName("正常流-AC1-2:MindModel 实体应含 modelType/version/enabled 字段")
  84. void mindModel_entityCarriesVersionAndSwitch() {
  85. Class<?> clazz = loadClass(HOST_ENTITY);
  86. if (clazz == null) {
  87. fail("[mind.model.manage] MindModel 实体不存在,无法验证字段(AC-1)。");
  88. }
  89. assertField(clazz, FIELD_MODEL_TYPE, "MindModel 应含 modelType(emotion|tongue)字段(AC-1)。");
  90. assertField(clazz, FIELD_VERSION, "MindModel 应含 version(模型版本)字段(AC-1)。");
  91. assertField(clazz, FIELD_ENABLED, "MindModel 应含 enabled(上下线开关)字段(AC-1)。");
  92. }
  93. // ------------------------------------------------------------------
  94. // 正常流-AC2:版控/上下线服务入口(listAll/online/offline)
  95. // ------------------------------------------------------------------
  96. @Test
  97. @DisplayName("正常流-AC2-1:MindModelManageService 服务类应存在")
  98. void modelManageService_classExists() {
  99. assertNotNull(loadClass(HOST_SERVICE),
  100. "[mind.model.manage] MindModelManageService 类不存在:契约 AC-2 要求版控/上下线服务入口"
  101. + "(listAll/online/offline),实现未落地。");
  102. }
  103. @Test
  104. @DisplayName("正常流-AC2-2:MindModelManageService 应提供 listAll/online/offline/isEnabled 方法")
  105. void modelManageService_hasVersionControlMethods() {
  106. Class<?> clazz = loadClass(HOST_SERVICE);
  107. if (clazz == null) {
  108. fail("[mind.model.manage] MindModelManageService 类不存在,无法验证版控方法(AC-2)。");
  109. }
  110. assertMethod(clazz, "listAll", "MindModelManageService 应提供 listAll() 版本列表(版控,AC-2)。");
  111. assertMethod(clazz, "online", "MindModelManageService 应提供 online(...) 上线入口(AC-2)。");
  112. assertMethod(clazz, "offline", "MindModelManageService 应提供 offline(...) 下线入口(AC-2)。");
  113. assertMethod(clazz, "isEnabled", "MindModelManageService 应提供 isEnabled(...) 开关查询(AC-1/AC-2)。");
  114. }
  115. @Test
  116. @DisplayName("正常流-AC2-3:MindModelManageController 应存在(模型管理 admin 端点)")
  117. void modelManageController_exists() {
  118. Class<?> clazz = loadClass(HOST_CONTROLLER);
  119. if (clazz == null) {
  120. fail("[mind.model.manage] MindModelManageController 不存在:契约 AC-2 要求模型管理 admin 端点"
  121. + "(/api/admin/mind-model:list/online/offline),实现未落地。");
  122. }
  123. assertMethod(clazz, "list", "MindModelManageController 应提供 list()(版控列表,AC-2)。");
  124. assertMethod(clazz, "online", "MindModelManageController 应提供 online(...)(上线,AC-2)。");
  125. assertMethod(clazz, "offline", "MindModelManageController 应提供 offline(...)(下线,AC-2)。");
  126. }
  127. // ------------------------------------------------------------------
  128. // 边界流-AC3:下线前需回退方案(fallbackVersion 必填)
  129. // ------------------------------------------------------------------
  130. @Test
  131. @DisplayName("边界流-AC3-1:offline 应接受回退版本参数(三参签名)")
  132. void offline_requiresFallbackVersionParam() {
  133. Class<?> clazz = loadClass(HOST_SERVICE);
  134. if (clazz == null) {
  135. fail("[mind.model.manage] MindModelManageService 类不存在,无法验证 offline 签名(AC-3)。");
  136. }
  137. Method m = findMethod(clazz, "offline");
  138. assertNotNull(m, "[mind.model.manage] MindModelManageService 应提供 offline(...)(AC-3)。");
  139. assertEquals(3, m.getParameterCount(),
  140. "[mind.model.manage] offline(modelType, version, fallbackVersion) 应为三参签名:"
  141. + "下线必须接受回退版本 fallbackVersion(AC-3 下线前回退方案)。");
  142. }
  143. @Test
  144. @DisplayName("边界流-AC3-2:MindModel 实体应含 fallbackVersion(承载回退方案)")
  145. void mindModel_entityCarriesFallbackVersion() {
  146. Class<?> clazz = loadClass(HOST_ENTITY);
  147. if (clazz == null) {
  148. fail("[mind.model.manage] MindModel 实体不存在,无法验证 fallbackVersion(AC-3)。");
  149. }
  150. assertField(clazz, FIELD_FALLBACK, "MindModel 应含 fallbackVersion(下线前回退版本)字段(AC-3)。");
  151. }
  152. @Test
  153. @DisplayName("边界流-AC3-3:未指定回退方案时下线应被拒(Result 400)")
  154. void offline_withoutFallback_isRejected() {
  155. Object host = contractHost();
  156. Object rejected = invoke(host, "offline",
  157. new Class<?>[]{String.class, String.class, String.class},
  158. "emotion", "v1", null);
  159. Integer code = readResultCode(rejected);
  160. assertEquals(400, code,
  161. "[mind.model.manage] offline 未指定 fallbackVersion 应被拒(Result code 400):"
  162. + "下线前需回退方案(AC-3 边界)。");
  163. }
  164. // ------------------------------------------------------------------
  165. // 边界流-AC4:非法版本 400
  166. // ------------------------------------------------------------------
  167. @Test
  168. @DisplayName("边界流-AC4-1:对非法/未知版本操作应返回 Result 400")
  169. void illegalVersion_returns400() {
  170. Object host = contractHost();
  171. Object result = invoke(host, "online",
  172. new Class<?>[]{String.class, String.class},
  173. "emotion", "v999999");
  174. Integer code = readResultCode(result);
  175. assertEquals(400, code,
  176. "[mind.model.manage] 对非法/未知版本(v999999)执行上线应返回 Result code 400(AC-4 边界)。");
  177. }
  178. @Test
  179. @DisplayName("边界流-AC4-2:mind_model 表应落地 schema.sql(承载模型版本/开关)")
  180. void schemaSql_containsMindModelTable() {
  181. String schema = readSchemaSql();
  182. assertTrue(schema.contains(MODEL_TABLE),
  183. "[mind.model.manage] schema.sql 缺少 " + MODEL_TABLE + " 建表:契约 AC-1 要求模型版本/开关"
  184. + "数据载体(modelType/version/enabled/fallbackVersion),实现未落地。");
  185. }
  186. // ------------------------------------------------------------------
  187. // 权限流(不适用):平台运营内部模型管理,无用户权限维度
  188. // ------------------------------------------------------------------
  189. @Test
  190. @DisplayName("权限流:本能力为平台运营内部模型管理,无用户权限维度,权限流不适用")
  191. void permissionFlow_notApplicable() {
  192. // 说明性用例:契约 AC 为「运营版控/上下线/回退方案」内部管理链路,无用户权限校验维度。
  193. // 不产生断言失败;仅为三流覆盖标注「不适用」。
  194. assertTrue(true, "权限流不适用:mind.model.manage 为平台运营内部模型管理,无用户权限维度。");
  195. }
  196. // ------------------------------------------------------------------
  197. // 反射辅助
  198. // ------------------------------------------------------------------
  199. /** 获取契约宿主服务实例(GREEN 后由 Spring 容器/直接实例化提供;RED 期类缺失即失败)。 */
  200. private Object contractHost() {
  201. Class<?> clazz = loadClass(HOST_SERVICE);
  202. if (clazz == null) {
  203. fail("[mind.model.manage] 契约宿主 MindModelManageService 未实现,无法验证行为级契约(AC-2/AC-3/AC-4)。");
  204. return null;
  205. }
  206. try {
  207. return clazz.getDeclaredConstructor().newInstance();
  208. } catch (Exception e) {
  209. // 宿主存在但无法无参实例化(如构造注入),退化为结构探测,行为用例标记跳过语义。
  210. fail("[mind.model.manage] 契约宿主 MindModelManageService 无法实例化:" + e.getMessage()
  211. + "(GREEN 期应提供无参构造或以 mock 方式装配)。");
  212. return null;
  213. }
  214. }
  215. private Class<?> loadClass(String name) {
  216. try {
  217. return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
  218. } catch (ClassNotFoundException e) {
  219. return null;
  220. }
  221. }
  222. private void assertField(Class<?> clazz, String fieldName, String message) {
  223. for (Field f : clazz.getDeclaredFields()) {
  224. if (f.getName().equals(fieldName)) {
  225. return;
  226. }
  227. }
  228. fail("[mind.model.manage] " + message);
  229. }
  230. private void assertMethod(Class<?> clazz, String methodName, String message) {
  231. for (Method m : clazz.getDeclaredMethods()) {
  232. if (m.getName().equals(methodName)) {
  233. return;
  234. }
  235. }
  236. fail("[mind.model.manage] " + message);
  237. }
  238. private Method findMethod(Class<?> clazz, String methodName) {
  239. for (Method m : clazz.getDeclaredMethods()) {
  240. if (m.getName().equals(methodName)) {
  241. return m;
  242. }
  243. }
  244. return null;
  245. }
  246. private Object invoke(Object target, String methodName, Class<?>[] paramTypes, Object... args) {
  247. try {
  248. Method m = target.getClass().getMethod(methodName, paramTypes);
  249. return m.invoke(target, args);
  250. } catch (Exception e) {
  251. String cause = e.getCause() != null ? e.getCause().getMessage() : e.getMessage();
  252. fail("[mind.model.manage] 反射调用 " + methodName + " 失败:" + cause);
  253. return null;
  254. }
  255. }
  256. /** 反射读取 Result 的 code(getCode() 或 code 字段),契约宿主返回 Result 对象。 */
  257. private Integer readResultCode(Object result) {
  258. if (result == null) {
  259. return null;
  260. }
  261. try {
  262. Method getCode = result.getClass().getMethod("getCode");
  263. Object code = getCode.invoke(result);
  264. return code instanceof Integer ? (Integer) code : code instanceof Number ? ((Number) code).intValue() : null;
  265. } catch (Exception e) {
  266. return null;
  267. }
  268. }
  269. private String readSchemaSql() {
  270. try (InputStream is = getClass().getClassLoader().getResourceAsStream("schema.sql")) {
  271. if (is == null) {
  272. return "";
  273. }
  274. try (Scanner scanner = new Scanner(is, StandardCharsets.UTF_8.name())) {
  275. scanner.useDelimiter("\\A");
  276. return scanner.hasNext() ? scanner.next() : "";
  277. }
  278. } catch (Exception e) {
  279. return "";
  280. }
  281. }
  282. }