| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319 |
- 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 "";
- }
- }
- }
|