|
|
@@ -0,0 +1,252 @@
|
|
|
+package com.etotem.cfc.unit;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
|
|
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
|
|
+import com.etotem.cfc.entity.DietRecommendation;
|
|
|
+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.apache.ibatis.builder.MapperBuilderAssistant;
|
|
|
+import org.junit.jupiter.api.BeforeAll;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.mockito.InjectMocks;
|
|
|
+import org.mockito.Mock;
|
|
|
+import org.mockito.MockitoAnnotations;
|
|
|
+
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.Collections;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 契约级单测:饮食食谱查看(能力键 {@code child.diet.viewMenu})。
|
|
|
+ *
|
|
|
+ * <p><b>契约锚点</b></p>
|
|
|
+ * <ul>
|
|
|
+ * <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-C-12「饮食食谱查看」——
|
|
|
+ * 当日三餐展示;<b>边界:无食谱空态</b>(前端以返回 {@code data.id} 是否为 null 判定空态卡)。</li>
|
|
|
+ * <li>能力映射:{@code docs/tdd/orchestration/sc-key-map.md}——
|
|
|
+ * {@code SC-C-12 = child.diet.viewMenu}(S-cfc-218,{@code FoodRecommend/HealthMeal/MealRecommend(当日三餐·空态)})。</li>
|
|
|
+ * <li>实测载体({@code docs/superpowers/specs/2026-11-07-child-zone-contract.md} EVD-9):
|
|
|
+ * {@code POST /api/diet/recommendation/today};{@code docs/superpowers/api/API_REFERENCE.md} §4.11
|
|
|
+ * ——返回 {@code data{id, date, mealType, menu, nutritionSummary, status, version, participantMemberIds}};
|
|
|
+ * 前端 {@code cfc-frontend/pages/diet/recommendation.vue} 以 {@code d.id} 判定空态、以 {@code menu.meals} 渲染三餐。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>被校验契约行为(GREEN 目标)</b></p>
|
|
|
+ * <ul>
|
|
|
+ * <li><b>正常流</b>:查询到记录 → 今日食谱视图透传契约字段(id/date/mealType/menu/nutritionSummary/status/version)。</li>
|
|
|
+ * <li><b>边界-空态</b>:无记录 → 返回 {@code id=null} 空态(不抛异常)。</li>
|
|
|
+ * <li><b>边界-当日三餐聚合(SC-C-12)</b>:早/午/晚分餐次记录并存时,视图应<b>聚合出当日三餐</b>
|
|
|
+ * (menu.meals 同时含 breakfast/lunch/dinner),而非只取第一条。</li>
|
|
|
+ * <li><b>边界-最新版本(regenerate 后)</b>:新旧版本记录并存时,应展示<b>最新版本</b>菜单,
|
|
|
+ * 不得回退展示旧菜单(regenerate 会递增 version)。</li>
|
|
|
+ * <li><b>边界-空壳回落空态(SC-C-12)</b>:记录存在但 {@code menuJson="{}"} 时,应回落空态
|
|
|
+ * ({@code id=null}),不得返回带 {@code id} 的空壳卡。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>RED 预期</b>:{@link DietRecommendationService#getTodayRecommendation(Long, String)} 当前
|
|
|
+ * {@code records.get(0)} 仅取首条、无版本选择、无空壳回落,故<b>三餐聚合 / 最新版本 / 空壳回落</b>三例在
|
|
|
+ * 实现补齐前应失败(红);正常流与空态为已实现契约锚点(绿),用于区分「实现存在但契约缺口行为未落地」而非全绿。</p>
|
|
|
+ *
|
|
|
+ * <p><b>测试范围</b>:{@link DietRecommendationService#getTodayRecommendation(Long, String, String)} 契约行为。
|
|
|
+ * 纯 Mockito(无 Spring / 无 DB / 无外部服务)。权限流不适用:familyId 由 {@code JwtInterceptor} 从 JWT 注入本人家庭,
|
|
|
+ * 服务层无独立鉴权分支(与 TeamStarMultiplierContractTest 同口径)。</p>
|
|
|
+ */
|
|
|
+class ChildDietViewMenuContractTest {
|
|
|
+
|
|
|
+ private static final Long FAMILY_ID = 100L;
|
|
|
+ private static final String DATE = "2026-11-13";
|
|
|
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private DietRecommendationMapper dietRecommendationMapper;
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private BeijingNutritionService beijingNutritionService;
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private AiGateway aiGateway;
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private MealConfigMapper mealConfigMapper;
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private FamilyMemberMapper familyMemberMapper;
|
|
|
+
|
|
|
+ @InjectMocks
|
|
|
+ private DietRecommendationService service;
|
|
|
+
|
|
|
+ /** 防御式初始化:LambdaQueryWrapper 在纯单测中解析列需要 TableInfo(参考 ReportDietRecommendContractTest)。 */
|
|
|
+ @BeforeAll
|
|
|
+ static void initMybatisPlusTableInfo() {
|
|
|
+ MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
|
|
|
+ TableInfoHelper.initTableInfo(assistant, DietRecommendation.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ MockitoAnnotations.openMocks(this);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 数据构造 ====================
|
|
|
+
|
|
|
+ private DietRecommendation record(Long id, String mealType, String menuJson, String status, Integer version) {
|
|
|
+ DietRecommendation r = new DietRecommendation();
|
|
|
+ r.setId(id);
|
|
|
+ r.setFamilyId(FAMILY_ID);
|
|
|
+ r.setMealType(mealType);
|
|
|
+ r.setMenuJson(menuJson);
|
|
|
+ r.setNutritionSummary("{\"calories\":800}");
|
|
|
+ r.setStatus(status);
|
|
|
+ r.setVersion(version);
|
|
|
+ r.setParticipantMemberIds("[1,2]");
|
|
|
+ return r;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 单餐次菜单 JSON:meals 数组含 type/name/dishes。 */
|
|
|
+ private static String mealMenu(String type, String name) {
|
|
|
+ return "{\"meals\":[{\"type\":\"" + type + "\",\"name\":\"" + name
|
|
|
+ + "\",\"participants\":1,\"dishes\":[{\"name\":\"契约菜\",\"type\":\"dish\","
|
|
|
+ + "\"calories\":100,\"ingredients\":[],\"method\":\"煮\"}]}]}";
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 从返回的 menu(String 或 Map 双形态)解析出餐次类型列表。
|
|
|
+ * GREEN 实现若聚合三餐,menu 可能为合并后的结构体而非原始 JSON 串,故双形态兼容。
|
|
|
+ */
|
|
|
+ private static List<String> mealTypesOf(Object menuObj) throws Exception {
|
|
|
+ List<String> types = new ArrayList<>();
|
|
|
+ if (menuObj == null) {
|
|
|
+ return types;
|
|
|
+ }
|
|
|
+ JsonNode mealsNode = null;
|
|
|
+ if (menuObj instanceof String) {
|
|
|
+ JsonNode root = OBJECT_MAPPER.readTree((String) menuObj);
|
|
|
+ if (root != null && root.isObject() && root.has("meals")) {
|
|
|
+ mealsNode = root.get("meals");
|
|
|
+ }
|
|
|
+ } else if (menuObj instanceof Map) {
|
|
|
+ Object m = ((Map<?, ?>) menuObj).get("meals");
|
|
|
+ if (m != null) {
|
|
|
+ mealsNode = OBJECT_MAPPER.valueToTree(m);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (mealsNode != null && mealsNode.isArray()) {
|
|
|
+ for (JsonNode meal : mealsNode) {
|
|
|
+ if (meal.has("type")) {
|
|
|
+ types.add(meal.get("type").asText());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return types;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 正常流(已实现契约锚点 → 绿) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流:单条完整记录 → 返回契约字段(id/date/mealType/menu/nutritionSummary/status/version)")
|
|
|
+ void 单条完整记录_返回契约字段() {
|
|
|
+ DietRecommendation rec = record(7L, "lunch", mealMenu("lunch", "午餐"), "accepted", 2);
|
|
|
+ when(dietRecommendationMapper.selectList(any())).thenReturn(Collections.singletonList(rec));
|
|
|
+
|
|
|
+ Map<String, Object> result = service.getTodayRecommendation(FAMILY_ID, DATE);
|
|
|
+
|
|
|
+ assertEquals(7L, ((Number) result.get("id")).longValue(),
|
|
|
+ "[child.diet.viewMenu] id 应透传记录 id。");
|
|
|
+ assertEquals(DATE, result.get("date"),
|
|
|
+ "[child.diet.viewMenu] date 应透传查询日期。");
|
|
|
+ assertEquals("lunch", result.get("mealType"),
|
|
|
+ "[child.diet.viewMenu] mealType 应透传记录餐次。");
|
|
|
+ assertNotNull(result.get("menu"),
|
|
|
+ "[child.diet.viewMenu] menu 不得为空。");
|
|
|
+ assertTrue(result.get("menu").toString().contains("meals"),
|
|
|
+ "[child.diet.viewMenu] menu 应含 meals 结构,实际: " + result.get("menu"));
|
|
|
+ assertNotNull(result.get("nutritionSummary"),
|
|
|
+ "[child.diet.viewMenu] nutritionSummary 应透传。");
|
|
|
+ assertEquals("accepted", result.get("status"),
|
|
|
+ "[child.diet.viewMenu] status 应透传。");
|
|
|
+ assertEquals(2, ((Number) result.get("version")).intValue(),
|
|
|
+ "[child.diet.viewMenu] version 应透传。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("空态:无记录 → 返回 id=null 空态且不抛异常")
|
|
|
+ void 无记录_返回空态不抛异常() {
|
|
|
+ when(dietRecommendationMapper.selectList(any())).thenReturn(Collections.emptyList());
|
|
|
+
|
|
|
+ Map<String, Object> result = service.getTodayRecommendation(FAMILY_ID, DATE);
|
|
|
+
|
|
|
+ assertNull(result.get("id"),
|
|
|
+ "[child.diet.viewMenu] 无记录时 id 应为 null(空态)。");
|
|
|
+ assertEquals("暂无推荐,请生成", result.get("message"),
|
|
|
+ "[child.diet.viewMenu] 空态应带提示 message。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 契约缺口-1:当日三餐聚合(SC-C-12,未落地 → 红) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("契约缺口-1:分餐次记录(早餐/午餐/晚餐)应聚合出当日三餐")
|
|
|
+ void 分餐次记录_应聚合当日三餐() throws Exception {
|
|
|
+ when(dietRecommendationMapper.selectList(any())).thenReturn(Arrays.asList(
|
|
|
+ record(1L, "breakfast", mealMenu("breakfast", "早餐"), "accepted", 1),
|
|
|
+ record(2L, "lunch", mealMenu("lunch", "午餐"), "accepted", 1),
|
|
|
+ record(3L, "dinner", mealMenu("dinner", "晚餐"), "accepted", 1)));
|
|
|
+
|
|
|
+ Map<String, Object> result = service.getTodayRecommendation(FAMILY_ID, DATE);
|
|
|
+
|
|
|
+ List<String> types = mealTypesOf(result.get("menu"));
|
|
|
+ assertTrue(types.containsAll(Arrays.asList("breakfast", "lunch", "dinner")),
|
|
|
+ "[child.diet.viewMenu] 分餐次记录并存时今日食谱应聚合早/午/晚三餐(SC-C-12 当日三餐),实际仅含: " + types);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 契约缺口-2:最新版本选择(regenerate 后,未落地 → 红) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("契约缺口-2:新旧版本记录并存 → 展示最新版本菜单(regenerate 后不得回退旧菜单)")
|
|
|
+ void 新旧版本并存_展示最新版本() throws Exception {
|
|
|
+ DietRecommendation oldV = record(11L, "lunch", mealMenu("lunch", "旧版午餐"), "pending", 1);
|
|
|
+ DietRecommendation newV = record(12L, "lunch", mealMenu("lunch", "新版午餐"), "completed", 2);
|
|
|
+ // 模拟 DB 不保证顺序:旧版本在前(无 ORDER BY 时 records.get(0) 取到旧版)
|
|
|
+ when(dietRecommendationMapper.selectList(any())).thenReturn(Arrays.asList(oldV, newV));
|
|
|
+
|
|
|
+ Map<String, Object> result = service.getTodayRecommendation(FAMILY_ID, DATE);
|
|
|
+
|
|
|
+ assertEquals(2, ((Number) result.get("version")).intValue(),
|
|
|
+ "[child.diet.viewMenu] 新旧版本并存时应选择最新版本(version=2),不得展示旧版本(version=1)。");
|
|
|
+ Object menu = result.get("menu");
|
|
|
+ assertNotNull(menu,
|
|
|
+ "[child.diet.viewMenu] 最新版本选择后 menu 不得为空。");
|
|
|
+ assertTrue(menu.toString().contains("新版午餐"),
|
|
|
+ "[child.diet.viewMenu] 应展示最新版本菜单(新版午餐),实际: " + menu);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 契约缺口-3:空壳回落空态(SC-C-12 无食谱空态,未落地 → 红) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("契约缺口-3:记录 menuJson 为空壳 {} → 回落空态(id=null)")
|
|
|
+ void 空壳记录_回落空态() {
|
|
|
+ when(dietRecommendationMapper.selectList(any()))
|
|
|
+ .thenReturn(Collections.singletonList(record(9L, "lunch", "{}", "accepted", 1)));
|
|
|
+
|
|
|
+ Map<String, Object> result = service.getTodayRecommendation(FAMILY_ID, DATE);
|
|
|
+
|
|
|
+ assertNull(result.get("id"),
|
|
|
+ "[child.diet.viewMenu] menuJson 为 {} 的空壳记录不得返回 id(前端据 id 判定空态),应回落「暂无推荐,请生成」。");
|
|
|
+ }
|
|
|
+}
|