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

merge(S-cfc-192): [teacher.nutrition.plan] TDD-RED 契约测试 精准营养方案制定 合入 cfclub

Sisyphus 3 часов назад
Родитель
Сommit
44a00ad6fd

+ 254 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/NutritionPlanTeacherContractTest.java

@@ -0,0 +1,254 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.diet.DietRecommendationController;
+import com.etotem.cfc.mapper.DietRecommendationMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.MealConfigMapper;
+import com.etotem.cfc.service.AiGateway;
+import com.etotem.cfc.service.BeijingNutritionService;
+import com.etotem.cfc.service.DietRecommendationService;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * TDD-RED 契约级单测:成长规划师「精准营养方案制定」(能力键 {@code teacher.nutrition.plan},
+ * SC-T-26 / API_REFERENCE §4.57 / 用例场景 04-teacher.md T-26 / 契约 EVD-10 77026df42)。
+ *
+ * <p><b>契约来源</b>(用户故事 + 主流程 + 边界):
+ * <blockquote>
+ *   角色:规划师。前置:客户设定了营养目标(或由规划师设定)。<br>
+ *   主流程:设目标(<b>热量 / 宏量营养素</b>)→ 生成方案 → 关联食谱。<br>
+ *   异常/边界:<b>目标非法(负 / 超上限)拦截</b>。
+ * </blockquote></p>
+ *
+ * <p><b>端点载体</b>(EVD-10 §4.57 回填):{@code DietRecommendationController /api/diet/recommendation/*}
+ * (generate 为「生成方案」入口)。规划师经 {@code familyId}(拦截器注入)作用于其绑定家庭,
+ * 三类流中的权限流(家庭隔离)由 {@code JwtInterceptor} + {@code familyId} 作用域承载,
+ * 在本控制器单测粒度不重复建模,以说明性用例标注(不适用)。</p>
+ *
+ * <p><b>实现现状(TDD-RED,实测 origin/cfclub)</b>:本能力契约所需的「营养目标(宏量营养素)」
+ * 链路<b>未落地</b>——
+ * <ul>
+ *   <li>{@code DietRecommendationService.aggregateNutrition} 将 {@code protein/carbs/fat}
+ *       <b>恒置 0</b>(源码注释原文:「protein/carbs/fat 恒 0 不在本轮范围」),
+ *       且 {@code toDish} 归一化时丢弃 AI 菜品的宏量营养素,仅保留热量 →
+ *       生成的方案 nutritionSummary 无法承载「宏量营养素」目标;</li>
+ *   <li>{@code generateRecommendation} 仅读取 {@code date / meal_type / selected_foods /
+ *       participant_member_ids / view_key},<b>不读取 / 不校验营养目标</b>,
+ *       非法(负 / 超上限)目标被静默忽略并照常返回成功,无「拦截」。</li>
+ * </ul>
+ * 因此本测试以<b>纯 Mockito 行为级探测</b>(无 Spring / 无 DB / 无外部 AI)断言契约缺失:
+ * 能编译、能运行、断言失败(红),失败断言原文清晰可贴。</p>
+ *
+ * <p><b>GREEN 升级指引</b>:实现者在 {@code DietRecommendationService} 侧——
+ * ① 让 {@code toDish} 保留并让 {@code aggregateNutrition} 真实聚合 AI 菜品的
+ * {@code protein/carbs/fat}(占位菜 / 汤无营养数据时贡献 0);
+ * ② 在方案生成契约中读取营养目标(本测试以 {@code nutrition_goals} 承载,
+ * 形如 {@code {calories, protein, carbs, fat}}),并对非法目标(负值 / 超上限)
+ * 返回<b>拒绝</b>(非 200,如 badRequest 400),而非静默放行——本测试自动转绿。</p>
+ */
+class NutritionPlanTeacherContractTest extends AssessmentPlanContractTestBase {
+
+    private static final ObjectMapper OM = new ObjectMapper();
+
+    private static final Long FAMILY_ID = 7L;
+    private static final String DATE = "2026-11-13";
+
+    /**
+     * 模拟 AI 返回的午餐 + 晚餐菜单(每餐 2 道主菜,均带 nutrition 宏量营养素)。
+     *
+     * <p>N=2 → DishCountRule.dishCount(2)=4 主菜,normalizeMenu 会把每餐补齐到
+     * 4 主菜 + 1 汤;补齐的占位菜/汤 nutrition 为空 → 宏量营养素贡献 0。
+     * 故真实菜品宏量营养素合计:protein=30+5+28+3=66、carbs=5+20+10+30=65、
+     * fat=8+2+12+1=23、calories=200+100+250+150=700(热量当前已可聚合,作对照)。</p>
+     */
+    private static final String AI_MENU_JSON = "{"
+            + "\"meals\":["
+            + "{\"type\":\"lunch\",\"name\":\"午餐\",\"dishes\":["
+            +   "{\"name\":\"鸡胸肉\",\"type\":\"dish\",\"nutrition\":{\"calories\":200,\"protein\":30,\"carbs\":5,\"fat\":8},"
+            +     "\"ingredients\":[{\"name\":\"鸡胸肉\",\"grams\":150}],\"cooking_method\":\"煎\"},"
+            +   "{\"name\":\"西兰花\",\"type\":\"dish\",\"nutrition\":{\"calories\":100,\"protein\":5,\"carbs\":20,\"fat\":2},"
+            +     "\"ingredients\":[{\"name\":\"西兰花\",\"grams\":100}],\"cooking_method\":\"煮\"}"
+            + "]},"
+            + "{\"type\":\"dinner\",\"name\":\"晚餐\",\"dishes\":["
+            +   "{\"name\":\"牛肉\",\"type\":\"dish\",\"nutrition\":{\"calories\":250,\"protein\":28,\"carbs\":10,\"fat\":12},"
+            +     "\"ingredients\":[{\"name\":\"牛肉\",\"grams\":120}],\"cooking_method\":\"炒\"},"
+            +   "{\"name\":\"米饭\",\"type\":\"dish\",\"nutrition\":{\"calories\":150,\"protein\":3,\"carbs\":30,\"fat\":1},"
+            +     "\"ingredients\":[{\"name\":\"米饭\",\"grams\":100}],\"cooking_method\":\"蒸\"}"
+            + "]}"
+            + "]}";
+
+    private DietRecommendationMapper recMapper;
+    private MealConfigMapper mealConfigMapper;
+    private FamilyMemberMapper familyMemberMapper;
+    private AiGateway aiGateway;
+    private BeijingNutritionService beijingNutritionService;
+    private DietRecommendationService service;
+    private DietRecommendationController controller;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        recMapper = mock(DietRecommendationMapper.class);
+        mealConfigMapper = mock(MealConfigMapper.class);
+        familyMemberMapper = mock(FamilyMemberMapper.class);
+        aiGateway = mock(AiGateway.class);
+        beijingNutritionService = mock(BeijingNutritionService.class);
+
+        // 真实 Service(注入 mock 依赖,无 Spring / 无 DB)
+        service = new DietRecommendationService();
+        inject(service, "dietRecommendationMapper", recMapper);
+        inject(service, "mealConfigMapper", mealConfigMapper);
+        inject(service, "familyMemberMapper", familyMemberMapper);
+        inject(service, "aiGateway", aiGateway);
+        inject(service, "beijingNutritionService", beijingNutritionService);
+
+        // 真实 Controller(注入真实 Service)
+        controller = new DietRecommendationController();
+        inject(controller, "dietRecommendationService", service);
+
+        // MyBatis-Plus TableInfo 预注册(使 LambdaQueryWrapper 在无 Spring 上下文可解析列名)
+        initTable(com.etotem.cfc.entity.DietRecommendation.class);
+        initTable(com.etotem.cfc.entity.MealConfig.class);
+        initTable(com.etotem.cfc.entity.FamilyMember.class);
+
+        // AI 网关桩:返回带宏量营养素的可用菜单(含 lunch + dinner,跳过规则降级)
+        when(aiGateway.generateMenu(anyString(), anyString(), anyString(), anyInt()))
+                .thenReturn(AI_MENU_JSON);
+        // 食材可用性未桩:generateIngredientList 返回 null → availableIngredientNames 兜底空列表,
+        // 占位菜/汤贡献 0 宏量营养素(与 GREEN 语义一致)。
+    }
+
+    // ------------------------------------------------------------------
+    // 正常流:生成方案应聚合宏量营养素(热量/蛋白/碳水/脂肪)到 nutritionSummary
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-1:生成方案 nutritionSummary 应聚合宏量营养素 protein/carbs/fat(非恒 0)")
+    void generate_正常_宏量营养素应聚合() throws Exception {
+        Map<String, Object> request = baseRequest();
+
+        Result<Map> r = controller.generateRecommendation(FAMILY_ID, request);
+        assertEquals(200, r.getCode(), "方案生成应成功(AI 桩返回可用菜单)");
+
+        Map<String, Object> summary = readSummary(r);
+        // 对照(应通过):热量当前已聚合 —— 证明 mock 装配与归一化链路正确,
+        // 下方宏量营养素断言失败是「契约缺口」而非「测试装配错误」。
+        assertEquals(700, intOf(summary.get("calories")),
+                "对照:nutritionSummary.calories 应聚合真实菜品热量(占位菜/汤贡献 0)");
+        // RED 断言:宏量营养素应从 AI 菜品聚合,当前 aggregateNutrition 恒置 0。
+        assertEquals(66, intOf(summary.get("protein")),
+                "[teacher.nutrition.plan] nutritionSummary.protein 应聚合 AI 菜品蛋白质(30+5+28+3=66),"
+                        + "当前 aggregateNutrition 恒置 0、toDish 归一化丢弃宏量营养素 —— "
+                        + "契约要求的「宏量营养素」目标未承载。");
+        assertEquals(65, intOf(summary.get("carbs")),
+                "[teacher.nutrition.plan] nutritionSummary.carbs 应聚合 AI 菜品碳水(5+20+10+30=65),当前恒 0。");
+        assertEquals(23, intOf(summary.get("fat")),
+                "[teacher.nutrition.plan] nutritionSummary.fat 应聚合 AI 菜品脂肪(8+2+12+1=23),当前恒 0。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:营养目标非法(负 / 超上限)应被拦截(拒绝,非 200)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-1:营养目标 calories 为负(-500)应被拦截,返回非 200")
+    void generate_边界_负热量目标应拦截() {
+        Map<String, Object> request = baseRequest();
+        request.put("nutrition_goals", goals(-500, 60, 80, 40));
+
+        Result<Map> r = controller.generateRecommendation(FAMILY_ID, request);
+        assertTrue(r.getCode() != 200,
+                "[teacher.nutrition.plan] 营养目标 calories=-500(负值)非法,契约要求「拦截」"
+                        + "(返回拒绝态非 200,如 badRequest 400),而非静默忽略并照常返回成功。"
+                        + "实测 code=" + r.getCode() + ",当前实现未读取/未校验营养目标,非法目标被放行。");
+    }
+
+    @Test
+    @DisplayName("边界流-2:营养目标 calories 超上限(999999)应被拦截,返回非 200")
+    void generate_边界_超上限热量目标应拦截() {
+        Map<String, Object> request = baseRequest();
+        request.put("nutrition_goals", goals(999999, 60, 80, 40));
+
+        Result<Map> r = controller.generateRecommendation(FAMILY_ID, request);
+        assertTrue(r.getCode() != 200,
+                "[teacher.nutrition.plan] 营养目标 calories=999999(超上限)非法,契约要求「拦截」"
+                        + "(返回拒绝态非 200),而非静默忽略并照常返回成功。"
+                        + "实测 code=" + r.getCode() + ",当前实现未读取/未校验营养目标。");
+    }
+
+    // ------------------------------------------------------------------
+    // 权限流(不适用):家庭隔离由 JwtInterceptor + familyId 作用域承载
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("权限流:规划师经 familyId 作用于绑定家庭,家庭隔离由拦截器承载,本控制器单测粒度不适用")
+    void permissionFlow_家庭隔离由拦截器承载_不适用() {
+        // 说明性用例:teacher.nutrition.plan 的权限维度 = 规划师仅能作用于其绑定家庭,
+        // 由 JwtInterceptor 注入 familyId + 全局数据隔离承载;本控制器方法仅按 familyId 过滤,
+        // 不重复建模越权分支(与 TeamProfitHookContractTest 的「权限流不适用」标注一致)。
+        // 无家庭上下文时 controller 已返回 Result.noFamily(5001),为既有行为,此处不产生断言失败。
+        assertTrue(true, "权限流不适用:家庭隔离由 JwtInterceptor + familyId 作用域承载。");
+    }
+
+    // ------------------------------------------------------------------
+    // helpers
+    // ------------------------------------------------------------------
+
+    /** 基础请求:date + meal_type + 共餐人(避免回落 meal_config 兜底查询) */
+    private static Map<String, Object> baseRequest() {
+        Map<String, Object> request = new HashMap<>();
+        request.put("date", DATE);
+        request.put("meal_type", "lunch");
+        request.put("participant_member_ids", Arrays.asList(1, 2));
+        return request;
+    }
+
+    /** 营养目标载荷:{calories, protein, carbs, fat} */
+    private static Map<String, Object> goals(int calories, int protein, int carbs, int fat) {
+        Map<String, Object> goals = new HashMap<>();
+        goals.put("calories", calories);
+        goals.put("protein", protein);
+        goals.put("carbs", carbs);
+        goals.put("fat", fat);
+        return goals;
+    }
+
+    /** 从 Result.data 读取并解析 nutritionSummary(safeWrite 后为 JSON 串) */
+    private static Map<String, Object> readSummary(Result<Map> r) throws Exception {
+        Map<String, Object> data = r.getData();
+        if (data == null) {
+            fail("[teacher.nutrition.plan] 方案生成结果 data 为空,无法读取 nutritionSummary。");
+        }
+        Object summaryObj = data.get("nutritionSummary");
+        if (summaryObj == null) {
+            fail("[teacher.nutrition.plan] 方案生成结果缺 nutritionSummary。");
+        }
+        JsonNode node = OM.readTree(summaryObj.toString());
+        Map<String, Object> summary = new HashMap<>();
+        summary.put("calories", node.has("calories") ? node.get("calories").asInt() : null);
+        summary.put("protein", node.has("protein") ? node.get("protein").asInt() : null);
+        summary.put("carbs", node.has("carbs") ? node.get("carbs").asInt() : null);
+        summary.put("fat", node.has("fat") ? node.get("fat").asInt() : null);
+        return summary;
+    }
+
+    private static int intOf(Object v) {
+        return v instanceof Number ? ((Number) v).intValue() : 0;
+    }
+}