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

merge(S-cfc-96): diet.food.healthAdjust RED 契约测试

Sisyphus Agent 4 дней назад
Родитель
Сommit
fc33090474

+ 317 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/DietFoodHealthAdjustContractTest.java

@@ -0,0 +1,317 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.entity.DietPreferences;
+import com.etotem.cfc.entity.Food;
+import com.etotem.cfc.entity.FoodRecommendIndex;
+import com.etotem.cfc.entity.HealthStatus;
+import com.etotem.cfc.mapper.DietPreferencesMapper;
+import com.etotem.cfc.mapper.DiseaseIntakeLimitMapper;
+import com.etotem.cfc.mapper.DrugInteractionMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.FoodRecommendIndexMapper;
+import com.etotem.cfc.mapper.HealthStatusMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.FoodRecommendService;
+import com.etotem.cfc.service.FoodService;
+import com.etotem.cfc.service.HealthFilterService;
+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.io.File;
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:能力键 {@code diet.food.healthAdjust}(健康问卷四维 → 食材推荐个性化调整)。
+ *
+ * <p>契约来源(2026-11-09-diet-recommendation-health-questionnaire-adjust.md):
+ * <blockquote>
+ *   用户 2026-11-09 拍板:报告解析出的食材推荐,须按健康问卷「饮食习惯 / 过敏 / 用药 / 主要健康体症」
+ *   做个性化调整(§1-2)。设计落点 §3 A1-A5:HealthFilterService 按食材检查扩展 + FoodRecommendService
+ *   getCurrentFoods 接入 + Food 瞬态字段 + generateIngredientList 口径补齐。
+ * </blockquote></p>
+ *
+ * <p>调整分级(叠加在现有 score「适合/戒备」之上的第二层个性化):
+ * <ul>
+ *   <li>{@code adjustment=blocked}:健康问卷硬性不建议(D1 过敏 / D2-risk1 药物禁忌 / D3 疾病 / D4 绝对避免 / D5 宗教 / D8 年龄),前端红条 + 「适合」Tab 降底;</li>
+ *   <li>{@code adjustment=warning}:软警告/间隔提示(D2-risk2/3),前端黄条,位置不变;</li>
+ *   <li>{@code adjustment=boost}:明确偏好(D6 must_eat),前端绿条置顶;</li>
+ *   <li>{@code adjustment=normal}:无调整。</li>
+ * </ul>
+ * 判定顺序:blocked(D1/D2-risk1/D3/D4/D5/D8)→ warning(D2-risk2/3)→ boost(D6)→ normal。</p>
+ *
+ * <p><b>实现现状(src/main 核实)</b>:本分支 src/main 中 {@code HealthFilterService.checkFoodBlocks /
+ * checkDietPreferenceAdjust} 方法、{@code Food.adjustment / adjustmentReason} 瞬态字段、
+ * {@code loadProfile} 的 lifestyle.allergySymptoms 合并均未落地;{@code FoodRecommendService.getCurrentFoods}
+ * 输出 Map 无 adjustment/adjustmentReason;{@code BeijingNutritionService.generateIngredientList}
+ * 未调用 checkDrugInteractions、未按 dietType 做宗教饮食过滤。因此本测试以「反射结构契约 + Mockito
+ * 行为契约 + 源码锚点」探测实现缺失:能编译通过、能运行、断言失败(红),且失败断言原文清晰可贴。</p>
+ *
+ * <p><b>GREEN 升级指引</b>:实现者按 §3 A1-A5 落地后,本测试自动转绿:A1 提供 checkFoodBlocks /
+ * checkDietPreferenceAdjust(适配 Food 命中面)并让 loadProfile 并入 allergySymptoms;A2 在
+ * getCurrentFoods 每食材 Map 追加 adjustment/adjustmentReason;A3 给 Food 增加
+ * {@code @TableField(exist = false)} 瞬态字段并透传;A5 在 generateIngredientList 循环内补
+ * checkDrugInteractions 与宗教饮食过滤。</p>
+ *
+ * <p><b>测试范围</b>:仅用 JUnit5 + Mockito(无 Spring / 无 DB / 无外部服务),与同批 RED 契约测试
+ * (TeamProfitHookContractTest / FamilyJoinValidationRulesContractTest)同风格。</p>
+ */
+class DietFoodHealthAdjustContractTest {
+
+    // ------------------------------------------------------------------
+    // HealthFilterService(A1)mocks
+    // ------------------------------------------------------------------
+
+    @Mock
+    private HealthStatusMapper healthStatusMapper;
+
+    @Mock
+    private DietPreferencesMapper dietPreferencesMapper;
+
+    @Mock
+    private DrugInteractionMapper drugInteractionMapper;
+
+    @Mock
+    private DiseaseIntakeLimitMapper diseaseIntakeLimitMapper;
+
+    @InjectMocks
+    private HealthFilterService healthFilterService;
+
+    // ------------------------------------------------------------------
+    // FoodRecommendService(A2)mocks
+    // ------------------------------------------------------------------
+
+    @Mock
+    private FoodRecommendIndexMapper foodRecommendIndexMapper;
+
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Mock
+    private UserMapper userMapper;
+
+    @Mock
+    private FoodService foodService;
+
+    @InjectMocks
+    private FoodRecommendService foodRecommendService;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    // ------------------------------------------------------------------
+    // A3:Food 瞬态字段(透传 adjustment / adjustmentReason,不动库表)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("A3-1:Food 应含瞬态字段 adjustment,供健康问卷调整结果透传")
+    void food_hasTransientAdjustmentField() {
+        Field f = findField(Food.class, "adjustment");
+        assertNotNull(f,
+                "[diet.food.healthAdjust] Food 实体缺少 adjustment 瞬态字段(§3 A3):"
+                        + "契约要求 @TableField(exist = false) 标记,供 getCurrentFoodsAsFoodList 透传"
+                        + "健康问卷调整结果(blocked/warning/boost/normal)。");
+    }
+
+    @Test
+    @DisplayName("A3-2:Food 应含瞬态字段 adjustmentReason,承载命中原因")
+    void food_hasTransientAdjustmentReasonField() {
+        Field f = findField(Food.class, "adjustmentReason");
+        assertNotNull(f,
+                "[diet.food.healthAdjust] Food 实体缺少 adjustmentReason 瞬态字段(§3 A3):"
+                        + "契约要求 @TableField(exist = false) 标记,承载命中原因文本(多条用「;」连接)。");
+    }
+
+    // ------------------------------------------------------------------
+    // A1:HealthFilterService 按食材检查便捷方法(结构契约)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("A1-1:HealthFilterService 应提供 checkFoodBlocks(HealthProfile, Food)")
+    void healthFilterService_hasCheckFoodBlocks() {
+        Method m = findMethod(HealthFilterService.class, "checkFoodBlocks",
+                HealthFilterService.HealthProfile.class, Food.class);
+        assertNotNull(m,
+                "[diet.food.healthAdjust] HealthFilterService 缺少 checkFoodBlocks(HealthProfile, Food)(§3 A1):"
+                        + "契约要求逻辑对齐 checkProductBlocks——以 food.name / food.allergens 为命中面,"
+                        + "疾病走 isFoodHardBlocked、用药走 checkDrugInteractions、年龄禁忌走 AGE_BLOCK_RULES,返回拦截原因列表。");
+    }
+
+    @Test
+    @DisplayName("A1-2:HealthFilterService 应提供 checkDietPreferenceAdjust(HealthProfile, DietPreferences, Food)")
+    void healthFilterService_hasCheckDietPreferenceAdjust() {
+        Method m = findMethod(HealthFilterService.class, "checkDietPreferenceAdjust",
+                HealthFilterService.HealthProfile.class, DietPreferences.class, Food.class);
+        assertNotNull(m,
+                "[diet.food.healthAdjust] HealthFilterService 缺少 checkDietPreferenceAdjust(HealthProfile, DietPreferences, Food)(§3 A1):"
+                        + "契约要求 D4 绝对避免 / D5 宗教饮食(food.dietType vs prefs.religiousDiet,none/空不拦)/ D6 mustEat boost 判定,"
+                        + "返回结构化结果(block 原因列表 + boost 布尔)。");
+    }
+
+    // ------------------------------------------------------------------
+    // A1:loadProfile 补充 lifestyle.allergySymptoms(行为契约,D1 体症维度)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("A1-3:loadProfile 应把 health_status.lifestyle.allergySymptoms 并入画像 allergies")
+    void loadProfile_mergesLifestyleAllergySymptoms() {
+        HealthStatus status = new HealthStatus();
+        status.setMemberId(1L);
+        // lifestyle 为对象:{mood, sleepDuration, ..., allergySymptoms}(HealthStatus.java 既有注释结构)
+        status.setLifestyle("{\"mood\":\"ok\",\"allergySymptoms\":[\"花粉过敏\"]}");
+        when(healthStatusMapper.selectList(any())).thenReturn(Collections.singletonList(status));
+        when(dietPreferencesMapper.selectOne(any())).thenReturn(null);
+
+        HealthFilterService.HealthProfile profile = healthFilterService.loadProfile(1L, null, 10);
+
+        assertTrue(profile.allergies.contains("花粉过敏"),
+                "[diet.food.healthAdjust] loadProfile 应将 health_status.lifestyle.allergySymptoms 并入画像 allergies(§3 A1,D1 过敏体症维度):"
+                        + "现状 loadProfile 只解析 diseaseHistory/medications,未读 lifestyle 字段,体检过敏症状维度缺位。");
+    }
+
+    // ------------------------------------------------------------------
+    // A2:FoodRecommendService.getCurrentFoods 接入调整标记(行为契约)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("A2-1:getCurrentFoods 每食材 Map 应输出 adjustment 标记")
+    void getCurrentFoods_emitsAdjustment() {
+        FoodRecommendIndex idx = new FoodRecommendIndex();
+        idx.setFoodId(100L);
+        idx.setFoodName("花生");
+        idx.setIndexScore(5);
+        Food food = new Food();
+        food.setId(100L);
+        food.setName("花生");
+        food.setAllergens("花生");
+        when(foodRecommendIndexMapper.selectList(any())).thenReturn(Collections.singletonList(idx));
+        when(foodService.listByIds(any())).thenReturn(Collections.singletonList(food));
+
+        List<Map<String, Object>> foods = foodRecommendService.getCurrentFoods(10L);
+
+        assertFalse(foods.isEmpty(),
+                "[diet.food.healthAdjust] getCurrentFoods 应能返回食材列表(测试前置:索引与 foods 均已 stub)。");
+        Map<String, Object> first = foods.get(0);
+        assertNotNull(first.get("adjustment"),
+                "[diet.food.healthAdjust] getCurrentFoods 每个食材 Map 应含 adjustment 标记(§3 A2):"
+                        + "按 D1-D8 规则按序判定,blocked/warning/boost/normal 之一;现状 Map 仅 name/score/category/nutrition,无调整层。");
+    }
+
+    @Test
+    @DisplayName("A2-2:getCurrentFoods 每食材 Map 应输出 adjustmentReason 命中原因")
+    void getCurrentFoods_emitsAdjustmentReason() {
+        FoodRecommendIndex idx = new FoodRecommendIndex();
+        idx.setFoodId(100L);
+        idx.setFoodName("鸡蛋");
+        idx.setIndexScore(8);
+        Food food = new Food();
+        food.setId(100L);
+        food.setName("鸡蛋");
+        when(foodRecommendIndexMapper.selectList(any())).thenReturn(Collections.singletonList(idx));
+        when(foodService.listByIds(any())).thenReturn(Collections.singletonList(food));
+
+        List<Map<String, Object>> foods = foodRecommendService.getCurrentFoods(10L);
+
+        assertFalse(foods.isEmpty(),
+                "[diet.food.healthAdjust] getCurrentFoods 应能返回食材列表(测试前置:索引与 foods 均已 stub)。");
+        Map<String, Object> first = foods.get(0);
+        assertNotNull(first.get("adjustmentReason"),
+                "[diet.food.healthAdjust] getCurrentFoods 每个食材 Map 应含 adjustmentReason 命中原因(§3 A2):"
+                        + "命中首条原因,多条用「;」连接;无命中时可为空串,但键必须存在。现状 Map 无该键。");
+    }
+
+    // ------------------------------------------------------------------
+    // A5:generateIngredientList 口径补齐(用药维度 + 宗教饮食,源码锚点)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("A5-1:generateIngredientList 应调用 checkDrugInteractions(用药维度 D2)")
+    void generateIngredientList_consultsDrugInteractions() {
+        String src = readMainSource("service/BeijingNutritionService.java");
+        assertTrue(src.contains("checkDrugInteractions"),
+                "[diet.food.healthAdjust] BeijingNutritionService.generateIngredientList 应调用 checkDrugInteractions(§3 A5):"
+                        + "食物名/food.allergens 命中药物交互时 risk1 剔除或降权、risk2/3 提示;"
+                        + "现状 L708-L714 循环只做过敏/避免/疾病硬拦截,用药维度缺位。");
+    }
+
+    @Test
+    @DisplayName("A5-2:generateIngredientList 应按 food.dietType 做宗教饮食过滤(D5)")
+    void generateIngredientList_filtersReligiousDiet() {
+        String src = readMainSource("service/BeijingNutritionService.java");
+        assertTrue(src.contains("getDietType"),
+                "[diet.food.healthAdjust] BeijingNutritionService.generateIngredientList 应按 food.dietType 做宗教饮食过滤(§3 A5,D5):"
+                        + "任一参与者 dietType 与宗教饮食(素食/清真)不匹配即剔除;none/空不受影响。现状循环未读 dietType。");
+    }
+
+    // ------------------------------------------------------------------
+    // A4(无改动):reportId 分支保持报告原始解析快照
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("A4-1:reportId 分支报告详情页保持原始解析快照(契约说明)")
+    void reportIdSnapshot_unchanged() {
+        // 契约:个性化调整只作用于「当前推荐」链(无 reportId 分支,getCurrentFoodsAsFoodList),
+        // 有 reportId 分支(报告详情页,foodService.listByReportId)保持快照不变(§3 A4)。本用例为说明性标注,
+        // 不产生断言失败,避免报告详情与历史快照失真。
+        assertTrue(true,
+                "A4 契约说明:HealthReportController.listFoods 有 reportId 分支不做个性化调整,保持报告原始解析快照。");
+    }
+
+    // ------------------------------------------------------------------
+    // 反射辅助
+    // ------------------------------------------------------------------
+
+    private Field findField(Class<?> clazz, String name) {
+        try {
+            return clazz.getDeclaredField(name);
+        } catch (NoSuchFieldException e) {
+            return null;
+        }
+    }
+
+    private Method findMethod(Class<?> clazz, String name, Class<?>... paramTypes) {
+        try {
+            return clazz.getDeclaredMethod(name, paramTypes);
+        } catch (NoSuchMethodException e) {
+            return null;
+        }
+    }
+
+    /** 读取 src/main 源码文件(surefire 工作目录 = cfc-backend 模块目录),找不到返回空串 */
+    private String readMainSource(String relative) {
+        String[] candidates = {
+                "src/main/java/com/etotem/cfc/" + relative,
+                "cfc-backend/src/main/java/com/etotem/cfc/" + relative,
+                "../cfc-backend/src/main/java/com/etotem/cfc/" + relative
+        };
+        for (String candidate : candidates) {
+            File file = new File(candidate);
+            if (file.isFile()) {
+                try {
+                    return new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
+                } catch (IOException e) {
+                    return "";
+                }
+            }
+        }
+        return "";
+    }
+}