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

test(S-cfc-218): 契约RED单测 child.diet.viewMenu 饮食食谱查看(SC-C-12 当日三餐·空态)

Agnes Agent 3 часов назад
Родитель
Сommit
b59357671c

+ 252 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ChildDietViewMenuContractTest.java

@@ -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 判定空态),应回落「暂无推荐,请生成」。");
+    }
+}