فهرست منبع

test: add service layer tests for ActivityAdminService, FoodService, NutritionDeficiencyService (36 tests)

Sisyphus Agent 4 ماه پیش
والد
کامیت
0643f7bbb2

+ 205 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/ActivityAdminServiceTest.java

@@ -0,0 +1,205 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Activity;
+import com.etotem.cfc.mapper.ActivityMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.lang.reflect.Field;
+import java.util.*;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * ActivityAdminService 纯单元测试
+ * 使用 MockitoExtension(无 Spring 上下文,无 MySQL 连接)
+ */
+public class ActivityAdminServiceTest {
+
+    private ActivityAdminService activityAdminService;
+
+    @Mock
+    private ActivityMapper activityMapper;
+
+    @BeforeEach
+    public void setup() throws Exception {
+        MockitoAnnotations.openMocks(this);
+        activityAdminService = new ActivityAdminService();
+        setField(activityAdminService, "activityMapper", activityMapper);
+    }
+
+    private static void setField(Object target, String fieldName, Object value) throws Exception {
+        Field field = target.getClass().getDeclaredField(fieldName);
+        field.setAccessible(true);
+        field.set(target, value);
+    }
+
+    private Activity mockActivity(Long id, String status, String dimensionCode) {
+        Activity a = new Activity();
+        a.setId(id);
+        a.setStatus(status);
+        a.setDimensionCode(dimensionCode);
+        a.setTitle("测试活动");
+        a.setUpdatedAt(new Date());
+        return a;
+    }
+
+    // ==================== list ====================
+
+    @Test
+    public void list_defaultPagination() {
+        List<Activity> records = Arrays.asList(
+            mockActivity(1L, "published", "body"),
+            mockActivity(2L, "draft", "mind")
+        );
+        Page<Activity> page = new Page<>(1, 20);
+        page.setRecords(records);
+        page.setTotal(2);
+        when(activityMapper.selectPage(any(Page.class), any())).thenReturn(page);
+
+        Result<Map<String, Object>> result = activityAdminService.list(new HashMap<>());
+
+        assertEquals(200, result.getCode());
+        assertNotNull(result.getData());
+        assertEquals(Long.valueOf(2), result.getData().get("total"));
+        assertEquals(1, result.getData().get("page"));
+        assertEquals(20, result.getData().get("size"));
+        assertEquals(2, ((List) result.getData().get("records")).size());
+    }
+
+    @Test
+    public void list_withStatusFilter() {
+        Map<String, Object> params = new HashMap<>();
+        params.put("status", "draft");
+        params.put("page", 2);
+        params.put("size", 10);
+
+        List<Activity> records = Collections.singletonList(mockActivity(3L, "draft", "mind"));
+        Page<Activity> page = new Page<>(2, 10);
+        page.setRecords(records);
+        page.setTotal(1);
+        when(activityMapper.selectPage(any(Page.class), any())).thenReturn(page);
+
+        Result<Map<String, Object>> result = activityAdminService.list(params);
+
+        assertEquals(200, result.getCode());
+        assertEquals(2, result.getData().get("page"));
+        assertEquals(10, result.getData().get("size"));
+    }
+
+    @Test
+    public void list_emptyResult() {
+        Page<Activity> page = new Page<>(1, 20);
+        page.setRecords(Collections.emptyList());
+        page.setTotal(0);
+        when(activityMapper.selectPage(any(Page.class), any())).thenReturn(page);
+
+        Result<Map<String, Object>> result = activityAdminService.list(new HashMap<>());
+
+        assertEquals(200, result.getCode());
+        assertEquals(Long.valueOf(0), result.getData().get("total"));
+        assertTrue(((List) result.getData().get("records")).isEmpty());
+    }
+
+    // ==================== review ====================
+
+    @Test
+    public void review_publish_success() {
+        Activity draft = mockActivity(5L, "draft", "body");
+        when(activityMapper.selectById(5L)).thenReturn(draft);
+        when(activityMapper.updateById(any())).thenReturn(1);
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("action", "publish");
+        params.put("id", 5L);
+        Result<String> result = activityAdminService.review(params);
+
+        assertEquals(200, result.getCode());
+        assertEquals("发布成功", result.getData());
+        assertEquals("published", draft.getStatus());
+        verify(activityMapper).updateById(draft);
+    }
+
+    @Test
+    public void review_reject_deletesActivity() {
+        Activity draft = mockActivity(6L, "draft", "mind");
+        when(activityMapper.selectById(6L)).thenReturn(draft);
+        when(activityMapper.deleteById(6L)).thenReturn(1);
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("action", "reject");
+        params.put("id", 6L);
+        Result<String> result = activityAdminService.review(params);
+
+        assertEquals(200, result.getCode());
+        assertEquals("已驳回并删除", result.getData());
+        verify(activityMapper).deleteById(6L);
+    }
+
+    @Test
+    public void review_publish_nonDraft_fails() {
+        Activity published = mockActivity(7L, "published", "body");
+        when(activityMapper.selectById(7L)).thenReturn(published);
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("action", "publish");
+        params.put("id", 7L);
+        Result<String> result = activityAdminService.review(params);
+
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("仅草稿状态可发布"));
+    }
+
+    @Test
+    public void review_activityNotFound() {
+        when(activityMapper.selectById(999L)).thenReturn(null);
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("action", "publish");
+        params.put("id", 999L);
+        Result<String> result = activityAdminService.review(params);
+
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("活动不存在"));
+    }
+
+    @Test
+    public void review_invalidAction() {
+        Activity draft = mockActivity(8L, "draft", "body");
+        when(activityMapper.selectById(8L)).thenReturn(draft);
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("action", "unknown");
+        params.put("id", 8L);
+        Result<String> result = activityAdminService.review(params);
+
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("无效的action"));
+    }
+
+    @Test
+    public void review_missingId() {
+        Map<String, Object> params = new HashMap<>();
+        params.put("action", "publish");
+        Result<String> result = activityAdminService.review(params);
+
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("id不能为空"));
+    }
+
+    @Test
+    public void review_missingAction() {
+        Map<String, Object> params = new HashMap<>();
+        params.put("id", 5L);
+        Result<String> result = activityAdminService.review(params);
+
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("action不能为空"));
+    }
+}

+ 207 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/FoodServiceTest.java

@@ -0,0 +1,207 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.Food;
+import com.etotem.cfc.mapper.FoodMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.lang.reflect.Field;
+import java.math.BigDecimal;
+import java.util.*;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * FoodService 纯单元测试
+ * 使用 MockitoExtension(无 Spring 上下文,无 MySQL 连接)
+ */
+public class FoodServiceTest {
+
+    private FoodService foodService;
+
+    @Mock
+    private FoodMapper foodMapper;
+
+    @BeforeEach
+    public void setup() throws Exception {
+        MockitoAnnotations.openMocks(this);
+        foodService = new FoodService();
+        setField(foodService, "foodMapper", foodMapper);
+    }
+
+    private static void setField(Object target, String fieldName, Object value) throws Exception {
+        Field field = target.getClass().getDeclaredField(fieldName);
+        field.setAccessible(true);
+        field.set(target, value);
+    }
+
+    private Food mockFood(Long id, String name, String category, String status, Long reportId) {
+        Food f = new Food();
+        f.setId(id);
+        f.setName(name);
+        f.setCategory(category);
+        f.setStatus(status);
+        f.setReportId(reportId);
+        f.setCalories(new BigDecimal("120.5"));
+        f.setProtein(new BigDecimal("10.2"));
+        f.setSortOrder(1);
+        return f;
+    }
+
+    // ==================== listAll ====================
+
+    @Test
+    public void listAll_noFilters_returnsAllActive() {
+        List<Food> foods = Arrays.asList(
+            mockFood(1L, "菠菜", "vegetable", "active", null),
+            mockFood(2L, "鸡胸肉", "meat", "active", null)
+        );
+        when(foodMapper.selectList(any())).thenReturn(foods);
+
+        List<Food> result = foodService.listAll(null, null);
+
+        assertEquals(2, result.size());
+    }
+
+    @Test
+    public void listAll_withCategoryFilter() {
+        List<Food> foods = Collections.singletonList(
+            mockFood(1L, "菠菜", "vegetable", "active", null)
+        );
+        when(foodMapper.selectList(any())).thenReturn(foods);
+
+        List<Food> result = foodService.listAll("vegetable", "active");
+
+        assertEquals(1, result.size());
+        assertEquals("菠菜", result.get(0).getName());
+    }
+
+    @Test
+    public void listAll_withStatusOnly() {
+        List<Food> foods = Collections.singletonList(
+            mockFood(2L, "鸡胸肉", "meat", "active", null)
+        );
+        when(foodMapper.selectList(any())).thenReturn(foods);
+
+        List<Food> result = foodService.listAll(null, "active");
+
+        assertEquals(1, result.size());
+    }
+
+    // ==================== listByPriceLevel ====================
+
+    @Test
+    public void listByPriceLevel_returnsActiveBelowMaxLevel() {
+        List<Food> foods = Arrays.asList(
+            mockFood(1L, "豆腐", "bean", "active", null),
+            mockFood(2L, "鸡蛋", "egg", "active", null)
+        );
+        when(foodMapper.selectList(any())).thenReturn(foods);
+
+        List<Food> result = foodService.listByPriceLevel(2);
+
+        assertEquals(2, result.size());
+        verify(foodMapper).selectList(any());
+    }
+
+    // ==================== getById ====================
+
+    @Test
+    public void getById_found() {
+        Food food = mockFood(3L, "西兰花", "vegetable", "active", null);
+        when(foodMapper.selectById(3L)).thenReturn(food);
+
+        Food result = foodService.getById(3L);
+
+        assertNotNull(result);
+        assertEquals("西兰花", result.getName());
+    }
+
+    @Test
+    public void getById_notFound() {
+        when(foodMapper.selectById(999L)).thenReturn(null);
+
+        Food result = foodService.getById(999L);
+
+        assertNull(result);
+    }
+
+    // ==================== create ====================
+
+    @Test
+    public void create_insertsFood() {
+        Food food = mockFood(null, "三文鱼", "fish", "active", null);
+        when(foodMapper.insert(any(Food.class))).thenAnswer(invocation -> {
+            food.setId(1L);
+            return 1;
+        });
+
+        foodService.create(food);
+
+        verify(foodMapper).insert(food);
+        assertNotNull(food.getId());
+    }
+
+    // ==================== update ====================
+
+    @Test
+    public void update_callsUpdateById() {
+        Food food = mockFood(10L, "牛肉", "meat", "active", null);
+        food.setCalories(new BigDecimal("250.0"));
+        when(foodMapper.updateById(any(Food.class))).thenReturn(1);
+
+        foodService.update(food);
+
+        verify(foodMapper).updateById(food);
+    }
+
+    // ==================== delete ====================
+
+    @Test
+    public void delete_deletesById() {
+        when(foodMapper.deleteById(11L)).thenReturn(1);
+
+        foodService.delete(11L);
+
+        verify(foodMapper).deleteById(11L);
+    }
+
+    // ==================== listByReportId ====================
+
+    @Test
+    public void listByReportId_withValidReportId() {
+        List<Food> foods = Arrays.asList(
+            mockFood(1L, "推荐食材A", "vegetable", "active", 5L),
+            mockFood(2L, "推荐食材B", "meat", "active", 5L)
+        );
+        when(foodMapper.selectList(any())).thenReturn(foods);
+
+        List<Food> result = foodService.listByReportId(5L);
+
+        assertEquals(2, result.size());
+        verify(foodMapper).selectList(any());
+    }
+
+    @Test
+    public void listByReportId_withNullReportId_returnsEmptyList() {
+        List<Food> result = foodService.listByReportId(null);
+
+        assertNotNull(result);
+        assertTrue(result.isEmpty());
+        verify(foodMapper, never()).selectList(any());
+    }
+
+    @Test
+    public void listByReportId_noMatchingFoods_returnsEmptyList() {
+        when(foodMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        List<Food> result = foodService.listByReportId(99L);
+
+        assertNotNull(result);
+        assertTrue(result.isEmpty());
+    }
+}

+ 288 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/NutritionDeficiencyServiceTest.java

@@ -0,0 +1,288 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.HealthIndicator;
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.entity.NutritionDeficiencyRecord;
+import com.etotem.cfc.entity.NutritionIndicatorMapping;
+import com.etotem.cfc.mapper.NutritionDeficiencyRecordMapper;
+import com.etotem.cfc.mapper.NutritionIndicatorMappingMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.lang.reflect.Field;
+import java.math.BigDecimal;
+import java.util.*;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * NutritionDeficiencyService 纯单元测试
+ * 使用 MockitoExtension(无 Spring 上下文,无 MySQL 连接)
+ */
+public class NutritionDeficiencyServiceTest {
+
+    private NutritionDeficiencyService nutritionDeficiencyService;
+
+    @Mock
+    private NutritionIndicatorMappingMapper mappingMapper;
+
+    @Mock
+    private NutritionDeficiencyRecordMapper recordMapper;
+
+    @Mock
+    private HealthReportService healthReportService;
+
+    @BeforeEach
+    public void setup() throws Exception {
+        MockitoAnnotations.openMocks(this);
+        nutritionDeficiencyService = new NutritionDeficiencyService();
+        setField(nutritionDeficiencyService, "mappingMapper", mappingMapper);
+        setField(nutritionDeficiencyService, "recordMapper", recordMapper);
+        setField(nutritionDeficiencyService, "healthReportService", healthReportService);
+    }
+
+    private static void setField(Object target, String fieldName, Object value) throws Exception {
+        Field field = target.getClass().getDeclaredField(fieldName);
+        field.setAccessible(true);
+        field.set(target, value);
+    }
+
+    private NutritionIndicatorMapping mockMapping(Long id, String nutrient, String pattern, Integer sortOrder) {
+        NutritionIndicatorMapping m = new NutritionIndicatorMapping();
+        m.setId(id);
+        m.setNutrient(nutrient);
+        m.setIndicatorNamePattern(pattern);
+        m.setSortOrder(sortOrder);
+        m.setDeficiencySymptoms("缺乏" + nutrient);
+        m.setExcessSymptoms("过量" + nutrient);
+        m.setFoodSuggestions("建议食用含" + nutrient + "的食物");
+        m.setSeverityWeight(BigDecimal.ONE);
+        return m;
+    }
+
+    private HealthReport mockReport(Long id, Long userId) {
+        HealthReport r = new HealthReport();
+        r.setId(id);
+        r.setUserId(userId);
+        r.setReportDate(new Date());
+        r.setNutritionScore(55);
+        r.setGutHealthScore(60);
+        return r;
+    }
+
+    private HealthIndicator mockIndicator(Long id, Long reportId, String name, String value, String status) {
+        HealthIndicator i = new HealthIndicator();
+        i.setId(id);
+        i.setReportId(reportId);
+        i.setIndicatorName(name);
+        i.setIndicatorValue(value);
+        i.setStatus(status);
+        i.setRefRange("20-100");
+        return i;
+    }
+
+    // ==================== getAllMappings ====================
+
+    @Test
+    public void getAllMappings_returnsAllMappingsOrdered() {
+        List<NutritionIndicatorMapping> mappings = Arrays.asList(
+            mockMapping(1L, "铁", "铁蛋白", 2),
+            mockMapping(2L, "维生素D", "维生素D", 1)
+        );
+        when(mappingMapper.selectList(any())).thenReturn(mappings);
+
+        List<NutritionIndicatorMapping> result = nutritionDeficiencyService.getAllMappings();
+
+        assertEquals(2, result.size());
+    }
+
+    // ==================== saveMapping ====================
+
+    @Test
+    public void saveMapping_insert_whenIdNull() {
+        NutritionIndicatorMapping m = mockMapping(null, "钙", "钙", 1);
+        when(mappingMapper.insert(any())).thenReturn(1);
+
+        nutritionDeficiencyService.saveMapping(m);
+
+        verify(mappingMapper).insert(m);
+    }
+
+    @Test
+    public void saveMapping_update_whenIdNotNull() {
+        NutritionIndicatorMapping m = mockMapping(5L, "锌", "锌", 1);
+        when(mappingMapper.updateById(any())).thenReturn(1);
+
+        nutritionDeficiencyService.saveMapping(m);
+
+        verify(mappingMapper).updateById(m);
+    }
+
+    // ==================== deleteMapping ====================
+
+    @Test
+    public void deleteMapping_callsDeleteById() {
+        when(mappingMapper.deleteById(3L)).thenReturn(1);
+
+        nutritionDeficiencyService.deleteMapping(3L);
+
+        verify(mappingMapper).deleteById(3L);
+    }
+
+    // ==================== getDeficiencyRecords ====================
+
+    @Test
+    public void getDeficiencyRecords_withChildIdOnly() {
+        List<NutritionDeficiencyRecord> records = Collections.singletonList(new NutritionDeficiencyRecord());
+        when(recordMapper.selectList(any())).thenReturn(records);
+
+        List<NutritionDeficiencyRecord> result = nutritionDeficiencyService.getDeficiencyRecords(5L, null);
+
+        assertEquals(1, result.size());
+        verify(recordMapper).selectList(any());
+    }
+
+    @Test
+    public void getDeficiencyRecords_withChildIdAndReportId() {
+        when(recordMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        List<NutritionDeficiencyRecord> result = nutritionDeficiencyService.getDeficiencyRecords(5L, 10L);
+
+        assertNotNull(result);
+        assertTrue(result.isEmpty());
+    }
+
+    // ==================== analyzeDeficiency ====================
+
+    @Test
+    public void analyzeDeficiency_noReport_returnsEmpty() {
+        when(healthReportService.getLatestReport(5L)).thenReturn(null);
+
+        List<NutritionDeficiencyRecord> result = nutritionDeficiencyService.analyzeDeficiency(5L, null);
+
+        assertNotNull(result);
+        assertTrue(result.isEmpty());
+    }
+
+    @Test
+    public void analyzeDeficiency_noIndicators_returnsEmpty() {
+        when(healthReportService.getLatestReport(5L)).thenReturn(mockReport(1L, 5L));
+        when(healthReportService.getReportIndicators(1L)).thenReturn(Collections.emptyList());
+
+        List<NutritionDeficiencyRecord> result = nutritionDeficiencyService.analyzeDeficiency(5L, null);
+
+        assertNotNull(result);
+        assertTrue(result.isEmpty());
+    }
+
+    @Test
+    public void analyzeDeficiency_withMatchingIndicator_createsRecord() {
+        HealthReport report = mockReport(2L, 5L);
+        HealthIndicator indicator = mockIndicator(1L, 2L, "铁蛋白", "15", "偏低");
+        NutritionIndicatorMapping mapping = mockMapping(1L, "铁", "铁蛋白", 1);
+
+        when(healthReportService.getLatestReport(5L)).thenReturn(report);
+        when(healthReportService.getReportIndicators(2L)).thenReturn(Collections.singletonList(indicator));
+        when(mappingMapper.selectList(any())).thenReturn(Collections.singletonList(mapping));
+        when(recordMapper.delete(any())).thenReturn(0);
+        when(recordMapper.insert(any())).thenReturn(1);
+
+        List<NutritionDeficiencyRecord> result = nutritionDeficiencyService.analyzeDeficiency(5L, null);
+
+        assertEquals(1, result.size());
+        assertEquals("铁", result.get(0).getNutrient());
+        assertEquals("deficiency", result.get(0).getDeficiencyType());
+        assertEquals("铁蛋白", result.get(0).getIndicatorName());
+        assertEquals("15", result.get(0).getIndicatorValue());
+    }
+
+    @Test
+    public void analyzeDeficiency_excessStatus_mapsToExcess() {
+        HealthReport report = mockReport(3L, 5L);
+        HealthIndicator indicator = mockIndicator(2L, 3L, "胆固醇", "250", "偏高");
+        NutritionIndicatorMapping mapping = mockMapping(2L, "胆固醇", "胆固醇", 1);
+
+        when(healthReportService.getLatestReport(5L)).thenReturn(report);
+        when(healthReportService.getReportIndicators(3L)).thenReturn(Collections.singletonList(indicator));
+        when(mappingMapper.selectList(any())).thenReturn(Collections.singletonList(mapping));
+        when(recordMapper.delete(any())).thenReturn(0);
+        when(recordMapper.insert(any())).thenReturn(1);
+
+        List<NutritionDeficiencyRecord> result = nutritionDeficiencyService.analyzeDeficiency(5L, null);
+
+        assertEquals(1, result.size());
+        assertEquals("excess", result.get(0).getDeficiencyType());
+    }
+
+    @Test
+    public void analyzeDeficiency_borderlineStatus_mapsToBorderline() {
+        HealthReport report = mockReport(4L, 5L);
+        HealthIndicator indicator = mockIndicator(3L, 4L, "血糖", "6.5", "临界");
+        NutritionIndicatorMapping mapping = mockMapping(3L, "血糖", "血糖", 1);
+
+        when(healthReportService.getLatestReport(5L)).thenReturn(report);
+        when(healthReportService.getReportIndicators(4L)).thenReturn(Collections.singletonList(indicator));
+        when(mappingMapper.selectList(any())).thenReturn(Collections.singletonList(mapping));
+        when(recordMapper.delete(any())).thenReturn(0);
+        when(recordMapper.insert(any())).thenReturn(1);
+
+        List<NutritionDeficiencyRecord> result = nutritionDeficiencyService.analyzeDeficiency(5L, null);
+
+        assertEquals(1, result.size());
+        assertEquals("borderline", result.get(0).getDeficiencyType());
+    }
+
+    @Test
+    public void analyzeDeficiency_noMatchingMapping_skipsIndicator() {
+        HealthReport report = mockReport(5L, 5L);
+        HealthIndicator indicator = mockIndicator(4L, 5L, "未知指标", "50", "偏低");
+
+        when(healthReportService.getLatestReport(5L)).thenReturn(report);
+        when(healthReportService.getReportIndicators(5L)).thenReturn(Collections.singletonList(indicator));
+        when(mappingMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(recordMapper.delete(any())).thenReturn(0);
+
+        List<NutritionDeficiencyRecord> result = nutritionDeficiencyService.analyzeDeficiency(5L, null);
+
+        assertTrue(result.isEmpty());
+    }
+
+    @Test
+    public void analyzeDeficiency_usesReportIdFromParam_whenProvided() {
+        HealthReport report = mockReport(6L, 5L);
+        HealthIndicator indicator = mockIndicator(5L, 6L, "铁蛋白", "12", "偏低");
+        NutritionIndicatorMapping mapping = mockMapping(1L, "铁", "铁蛋白", 1);
+
+        when(healthReportService.getLatestReport(5L)).thenReturn(report);
+        when(healthReportService.getReportIndicators(6L)).thenReturn(Collections.singletonList(indicator));
+        when(mappingMapper.selectList(any())).thenReturn(Collections.singletonList(mapping));
+        when(recordMapper.delete(any())).thenReturn(0);
+        when(recordMapper.insert(any())).thenReturn(1);
+
+        List<NutritionDeficiencyRecord> result = nutritionDeficiencyService.analyzeDeficiency(5L, 6L);
+
+        assertEquals(1, result.size());
+        verify(healthReportService).getReportIndicators(6L);
+    }
+
+    @Test
+    public void analyzeDeficiency_deletesExistingRecordsBeforeInsert() {
+        HealthReport report = mockReport(7L, 5L);
+        HealthIndicator indicator = mockIndicator(6L, 7L, "维生素D", "15", "偏低");
+        NutritionIndicatorMapping mapping = mockMapping(2L, "维生素D", "维生素D", 1);
+
+        when(healthReportService.getLatestReport(5L)).thenReturn(report);
+        when(healthReportService.getReportIndicators(7L)).thenReturn(Collections.singletonList(indicator));
+        when(mappingMapper.selectList(any())).thenReturn(Collections.singletonList(mapping));
+        when(recordMapper.delete(any())).thenReturn(1);
+        when(recordMapper.insert(any())).thenReturn(1);
+
+        nutritionDeficiencyService.analyzeDeficiency(5L, 7L);
+
+        verify(recordMapper).delete(any());
+    }
+}

+ 138 - 79
docs/test-report.md

@@ -1,8 +1,8 @@
-# 测试报告 - 角色权限测试覆盖增强 + PdfParseService 测试
+# 测试报告 - 设计实现差异分析 + 服务层测试覆盖
 
-**日期**: 2026-06-24
+**日期**: 2026-06-26
 **分支**: cfclub
-**最新提交**: `0e6ea3b` → 本次 `9e321c3`(角色测试增强+报告已推送)→ 本次变更待提交
+**最新提交**: `e193c02`(本次)
 
 ---
 
@@ -10,88 +10,143 @@
 
 | 项目 | 状态 |
 |------|------|
-| 代码同步 (git pull --rebase) | ✅ 完成 |
-| 设计-代码匹配度分析 | ✅ 完成 |
-| 缺失测试用例补充 | ✅ 完成 |
-| 编译验证 (BUILD SUCCESS) | ✅ 完成 |
-| PdfParseServiceTest (10/10 通过) | ✅ 完成 |
+| 代码同步 (git pull --rebase) | ✅ `74009f3` → `e193c02` |
+| 设计-实现差异分析 | ✅ 完成 |
+| 新增测试用例(服务层) | ✅ 完成 |
+| 编译验证 | ✅ 40 测试文件 BUILD SUCCESS |
+| 测试运行(无 MySQL 依赖) | ✅ 36/36 通过 |
 | 测试报告推送远程 | 🔄 进行中 |
 
 ---
 
-## 2. 远程变更探查(上次同步)
+## 2. 远程变更探查(本次同步)
 
-`git pull --rebase` 拉取的远程新提交:
+`git pull --rebase` 拉取的远程新提交(`74009f3` → `e193c02`):
 
-```
-0e6ea3b feat: 营养推荐相关实体和mappers (#178)
-dd002f3 fix: Login.vue 品牌升级 (#177)
-PdfParseService.java 新建(食物解析服务)
-FamilyMemberVO.java 重构(字段变更:memberType→capabilityRole,移除danLevel)
-schema.sql 新增表
-```
+### 后端新增(48 files, +3504 -5 行)
+
+| 类型 | 文件/目录 | 说明 |
+|------|----------|------|
+| Controller | `HealthDimensionController.java` | 健康维度评估控制器 |
+| Controller | `EmotionCheckinController.java` + `mind/` | 情绪打卡控制器 |
+| Controller | `MindController.java` | 心智维度控制器 |
+| Controller | `TraditionalMirrorController.java` | 智慧维度(八字命盘)控制器 |
+| Service | `EmotionCheckinService.java` (218行) | 情绪打卡核心逻辑 |
+| Service | `DimensionScoreService.java` + impl/ | 维度评分服务 |
+| Service | `HealthDataSourceRecordService.java` | 健康数据源记录服务 |
+| Entity | `EmotionCheckin.java`, `HealthDimensionScore.java`, `NumSoulConfig.java`, `BaziReadingTemplate.java` 等 7 个新实体 | 新功能数据模型 |
+| DTO | `EmotionCheckinVO/DTO`, `DimensionScoreVO`, `HealthDimensionVO` 等 | 新功能数据结构 |
+| Database | `DatabaseInitializer.java` (247行) | 数据库初始化 |
 
-后端新增 `PdfParseService.java` + `FamilyMemberVO.java` 重构 → 触发测试覆盖需求。
+### 前端新增
+- `pages/body/health-dimensions.vue` (605行) — 健康维度评估页面
+- `pages/body/food-list.vue` (286行) — 食材列表页面
 
 ---
 
-## 3. 本次新增变更
+## 3. 设计-实现差异分析
 
-### 3.1 UserService.java 编译错误修复
+### 3.1 P0 营养师核心功能(spec 2026-06-23)
 
-**文件**: `cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java:672`
+| 设计项 | 实现状态 | 差异说明 |
+|--------|---------|---------|
+| `NutritionDeficiencyRecord.priority` | ❌ 缺失 | 设计定义 high/medium/low,但 entity 只有 `severity (BigDecimal)` |
+| `ChildNutritionProfileService.getProfile()` 含 `diseaseRisks` | ⚠️ 部分 | `getProfile()` 未包含 diseaseRisks;已有 `getDeficiencySummary()` 提供缺乏摘要 |
+| `POST /api/health/nutrition-profile` 端点 | ✅ 已实现 | 对应端点 `/api/health/nutrition/profile` 已存在于 HealthReportController |
+| 营养缺乏分析(NutritionDeficiencyService) | ✅ 已实现 | `analyzeDeficiency()` 完整实现,字段映射 + 缺乏分类 + 记录持久化 |
+| 指标优先级排序(高/中/低) | ❌ 未实现 | 仅 severity 数值,未映射到 priority 标签 |
 
-**问题**: `FamilyMemberVO` 重构后无 `danLevel` 字段,调用 `vo.setDanLevel()` 导致编译失败。
+### 3.2 活动管理(AdminActivityController)
 
-**修复**: 删除 `vo.setDanLevel(c.getDanLevel())` 行。
+| 设计项 | 实现状态 | 说明 |
+|--------|---------|------|
+| 活动列表(支持状态/维度过滤) | ✅ 已实现 | `list()` 方法,pagination 支持 |
+| 审核发布(publish/reject) | ✅ 已实现 | `review()` 方法,草稿→发布,驳回→删除 |
+| 分页返回格式 | ✅ 一致 | `{records, total, page, size}` |
 
-### 3.2 PdfParseServiceTest(新建)
+**无对应设计文档** — 此为实现驱动的新功能(vendor 角色商品管理扩展)。
 
-**文件**: `cfc-backend/src/test/java/com/etotem/cfc/service/PdfParseServiceTest.java`
+### 3.3 食材推荐系统(FoodService)
 
-**背景**: `PdfParseService.parseText()` 解析 PDF 文本为结构化报告,文本格式为"关键词\n值"逐行格式。
+| 设计项 | 实现状态 | 说明 |
+|--------|---------|------|
+| 食材 CRUD | ✅ 已实现 | `create/update/delete/getById` |
+| 按分类/状态筛选列表 | ✅ 已实现 | `listAll(category, status)` |
+| 按价格等级查询 | ✅ 已实现 | `listByPriceLevel(maxLevel)` |
+| 按报告ID查询食材 | ✅ 已实现 | `listByReportId(reportId)` |
 
-| 测试方法 | 测试内容 | 结果 |
-|---------|---------|------|
-| `parseText_emptyText_returnsEmptyResult` | 空文本返回空对象 | ✅ |
-| `parseText_withAbstractScore_parsesCorrectly` | 摘要分数:健康总分/菌群健康 | ✅ |
-| `parseText_withReportInfo_parsesCorrectly` | 报告信息:编号/日期/姓名/肠道年龄/肠型 | ✅ |
-| `parseText_withDiseaseRisk_parsesCorrectly` | 疾病风险:慢病控制/营养均衡 | ✅ |
-| `parseText_withGutMicrobiome_parsesCorrectly` | 肠道菌群:平衡/多样性/有益菌/有害菌/核心菌属 | ✅ |
-| `parseText_withMetricsScore_parsesCorrectly` | 指标评分:各维度 Integer | ✅ |
-| `parseText_withIngredientSuitability_parsesCorrectly` | 食材适宜度 | ✅ |
-| `parseText_withAllSections_combinesResults` | 多 section 合并 | ✅ |
-| `parseText_withSpecialCharacters_handlesGracefully` | 特殊字符(数字/长名称/边界值) | ✅ |
+**无对应设计文档** — 食材推荐相关实现待补充设计规格。
 
-**10/10 测试通过,BUILD SUCCESS**
+### 3.4 待实现(设计仅)
 
-**注意**:PdfParseService 无 Spring 依赖,直接 `new PdfParseService()` 实例化,不触发 MySQL 连接,不受连接数限制。
+| 功能 | 设计文档 | 状态 |
+|------|---------|------|
+| RecommendationController + RecommendationService | 精准营养推荐系统设计方案 | ❌ 未实现 |
+| Dify AI 对话集成(AIChatController 扩展) | 精准营养推荐系统设计方案 | ❌ 未实现 |
+| 孩子营养档案统一视图(前端) | P0 营养师核心功能设计 | ⚠️ 部分实现(API 就绪,前端未接入) |
 
 ---
 
-## 4. 之前完成的测试覆盖
-
-### 4.1 WishControllerTest(前期已完成)
-
-| 测试方法 | 测试角色 | 预期结果 |
-|---------|---------|---------|
-| `createWish_ByTeacher` | teacher | 成功 |
-| `createWish_ByAdmin` | admin | 成功 |
-
-### 4.2 FamilyControllerTest(前期已完成)
-
-| 测试方法 | 测试角色 | 预期结果 |
-|---------|---------|---------|
-| `guideBindFamily_ChildDenied` | child | code=500 |
-| `guideBindFamily_AdminDenied` | admin | code=500 |
-
-### 4.3 GuideFamilyTaskControllerTest(前期已完成)
-
-7 个 teacher-only 方法 × 3 种非 teacher 角色 (parent/child/admin) = 21 条角色拒绝断言。
-
-### 4.4 TestRoles 工具类(前期已完成)
-
-`ROLE_PARENT / ROLE_CHILD / ROLE_TEACHER / ROLE_ADMIN` + `assertSuccess()` / `assertRoleDenied()`
+## 4. 测试覆盖改进
+
+### 4.1 本次新增测试(服务层)
+
+#### FoodServiceTest(12 tests)
+
+覆盖:`FoodService.java`
+
+| 测试方法 | 测试内容 |
+|---------|---------|
+| `listAll_noFilters_returnsAllActive` | 无过滤返回所有 active 食材 |
+| `listAll_withCategoryFilter` | 按分类筛选 |
+| `listAll_withStatusOnly` | 按状态筛选 |
+| `listByPriceLevel_returnsActiveBelowMaxLevel` | 价格等级上限过滤 |
+| `getById_found` | ID 存在 |
+| `getById_notFound` | ID 不存在 |
+| `create_insertsFood` | 插入食材(模拟 AUTO_INCREMENT) |
+| `update_callsUpdateById` | 更新食材 |
+| `delete_deletesById` | 删除食材 |
+| `listByReportId_withValidReportId` | 关联报告的食材查询 |
+| `listByReportId_withNullReportId_returnsEmptyList` | null 报告ID 返回空列表 |
+| `listByReportId_noMatchingFoods_returnsEmptyList` | 无匹配返回空列表 |
+
+#### ActivityAdminServiceTest(10 tests)
+
+覆盖:`ActivityAdminService.java`
+
+| 测试方法 | 测试内容 |
+|---------|---------|
+| `list_defaultPagination` | 默认分页(page=1, size=20) |
+| `list_withStatusFilter` | 按状态过滤 + 自定义分页 |
+| `list_emptyResult` | 空结果返回 |
+| `review_publish_success` | 发布草稿活动成功 |
+| `review_reject_deletesActivity` | 驳回删除活动 |
+| `review_publish_nonDraft_fails` | 非草稿状态不可发布 |
+| `review_activityNotFound` | 活动不存在返回错误 |
+| `review_invalidAction` | 无效 action 返回错误 |
+| `review_missingId` | 缺少 id 参数 |
+| `review_missingAction` | 缺少 action 参数 |
+
+#### NutritionDeficiencyServiceTest(14 tests)
+
+覆盖:`NutritionDeficiencyService.java`
+
+| 测试方法 | 测试内容 |
+|---------|---------|
+| `getAllMappings_returnsAllMappingsOrdered` | 获取所有指标映射(排序) |
+| `saveMapping_insert_whenIdNull` | 新增映射 |
+| `saveMapping_update_whenIdNotNull` | 更新映射 |
+| `deleteMapping_callsDeleteById` | 删除映射 |
+| `getDeficiencyRecords_withChildIdOnly` | 按孩子ID查询缺乏记录 |
+| `getDeficiencyRecords_withChildIdAndReportId` | 按孩子ID+报告ID查询 |
+| `analyzeDeficiency_noReport_returnsEmpty` | 无报告返回空 |
+| `analyzeDeficiency_noIndicators_returnsEmpty` | 无指标返回空 |
+| `analyzeDeficiency_withMatchingIndicator_createsRecord` | 匹配指标创建缺乏记录(偏低→deficiency) |
+| `analyzeDeficiency_excessStatus_mapsToExcess` | 偏高→excess |
+| `analyzeDeficiency_borderlineStatus_mapsToBorderline` | 临界→borderline |
+| `analyzeDeficiency_noMatchingMapping_skipsIndicator` | 无匹配映射跳过指标 |
+| `analyzeDeficiency_usesReportIdFromParam_whenProvided` | 优先使用参数中的 reportId |
+| `analyzeDeficiency_deletesExistingRecordsBeforeInsert` | 插入前删除旧记录 |
 
 ---
 
@@ -100,38 +155,42 @@ schema.sql 新增表
 ```
 mvn test-compile
 [INFO] BUILD SUCCESS
-[INFO] Compiling 37 source files  (新增 PdfParseServiceTest)
+[INFO] Compiling 40 source files
+[INFO] Total time:  45.615 s
 ```
 
 ---
 
-## 6. 测试执行限制
+## 6. 测试执行结果
 
-**MySQL 连接数限制**:HikariCP 13 个连接限额,13 个测试类同时运行会阻塞。
+| 测试类 | 用例数 | 通过 | 失败 | 耗时 |
+|--------|--------|------|------|------|
+| FoodServiceTest | 12 | 12 | 0 | 2.4s |
+| ActivityAdminServiceTest | 10 | 10 | 0 | 1.5s |
+| NutritionDeficiencyServiceTest | 14 | 14 | 0 | 50.8s |
+| **合计** | **36** | **36** | **0** | **54.7s** |
 
-**现状**:
-- 编译验证:✅ 通过
-- PdfParseServiceTest:✅ 10/10 通过(无 MySQL 依赖)
-- 完整测试运行:⚠️ MySQL `Too many connections` 阻塞(基础设施限制)
+**BUILD SUCCESS** — 无 MySQL 连接(纯 Mockito 单元测试)
 
 ---
 
-## 7. 待优化项
+## 7. 设计待办事项
 
 | 优先级 | 项目 | 说明 |
 |--------|------|------|
-| 🟡 中 | vendorType=planner 迁移 | 代码仍用 `role=teacher` |
-| 🟢 低 | GuideRecordController 测试 | 有 `@RequestAttribute("role")` 但无测试类 |
+| 🟡 中 | `NutritionDeficiencyRecord.priority` 字段 | 建议添加 priority 字段(high/medium/low),将 severity 数值映射为优先级标签 |
+| 🟡 中 | RecommendationController/Service | 设计文档存在但未实现,Dify 集成待开发 |
+| 🟢 低 | `ChildNutritionProfileService.getProfile()` 补全 diseaseRisks | 与 P0 设计文档要求一致 |
+| 🟢 低 | `ChildNutritionProfileService` 前端接入 | API 已就绪 (`/api/health/nutrition/profile`),前端页面待开发 |
+| 🟢 低 | `FoodService` 设计文档 | 食材推荐系统缺少对应设计规格 |
 
 ---
 
-## 8. 文件变更清单(本次)
+## 8. 文件变更清单
 
 | 文件 | 操作 | 说明 |
 |------|------|------|
-| `cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java` | 修改 | 删除 `setDanLevel` 编译错误 |
-| `cfc-backend/src/test/java/com/etotem/cfc/service/PdfParseServiceTest.java` | 新增 | 10 个测试方法 |
-| `cfc-backend/src/test/java/com/etotem/cfc/controller/WishControllerTest.java` | 修改 | 新增 teacher/admin 测试(前期) |
-| `cfc-backend/src/test/java/com/etotem/cfc/controller/FamilyControllerTest.java` | 修改 | 新增 child/admin 拒绝测试(前期) |
-| `cfc-backend/src/test/java/com/etotem/cfc/common/TestRoles.java` | 新增 | 角色常量(前期) |
-| `cfc-backend/src/test/java/com/etotem/cfc/controller/GuideFamilyTaskControllerTest.java` | 已存在 | 角色拒绝测试(前期) |
+| `cfc-backend/src/test/java/com/etotem/cfc/service/ActivityAdminServiceTest.java` | 新增 | 10 tests — 活动管理服务 |
+| `cfc-backend/src/test/java/com/etotem/cfc/service/FoodServiceTest.java` | 新增 | 12 tests — 食材管理服务 |
+| `cfc-backend/src/test/java/com/etotem/cfc/service/NutritionDeficiencyServiceTest.java` | 新增 | 14 tests — 营养缺乏分析服务 |
+| `docs/test-report.md` | 修改 | 更新为本次测试报告 |