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