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