|
|
@@ -0,0 +1,346 @@
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.etotem.cfc.entity.FamilyFoodOverview;
|
|
|
+import com.etotem.cfc.entity.FamilyMember;
|
|
|
+import com.etotem.cfc.entity.Food;
|
|
|
+import com.etotem.cfc.entity.FoodRecommendIndex;
|
|
|
+import com.etotem.cfc.mapper.FamilyFoodOverviewMapper;
|
|
|
+import com.etotem.cfc.mapper.FamilyMemberMapper;
|
|
|
+import com.etotem.cfc.mapper.FoodRecommendIndexMapper;
|
|
|
+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.ArrayList;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.Collections;
|
|
|
+import java.util.Date;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.*;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.Mockito.*;
|
|
|
+
|
|
|
+/**
|
|
|
+ * FamilyFoodOverviewService 纯单元测试(T28)
|
|
|
+ * 校验家庭总览口径:家庭成员该项食材最新推荐指数的最小值;无记录成员不计入。
|
|
|
+ * 使用 Mockito(无 Spring 上下文,无 MySQL 连接)。
|
|
|
+ */
|
|
|
+public class FamilyFoodOverviewServiceTest {
|
|
|
+
|
|
|
+ private FamilyFoodOverviewService service;
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private FamilyFoodOverviewMapper overviewMapper;
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private FamilyMemberMapper familyMemberMapper;
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private FoodRecommendIndexMapper idxMapper;
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private FoodService foodService;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ public void setup() throws Exception {
|
|
|
+ MockitoAnnotations.openMocks(this);
|
|
|
+ service = new FamilyFoodOverviewService();
|
|
|
+ setField(service, "familyFoodOverviewMapper", overviewMapper);
|
|
|
+ setField(service, "familyMemberMapper", familyMemberMapper);
|
|
|
+ setField(service, "foodRecommendIndexMapper", idxMapper);
|
|
|
+ setField(service, "foodService", foodService);
|
|
|
+ }
|
|
|
+
|
|
|
+ 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 FamilyMember member(Long id, Long userId, String nickname, String status) {
|
|
|
+ FamilyMember m = new FamilyMember();
|
|
|
+ m.setId(id);
|
|
|
+ m.setFamilyId(100L);
|
|
|
+ m.setUserId(userId);
|
|
|
+ m.setNickname(nickname);
|
|
|
+ m.setStatus(status);
|
|
|
+ return m;
|
|
|
+ }
|
|
|
+
|
|
|
+ private FoodRecommendIndex idx(Long userId, Long foodId, String foodName, Integer score) {
|
|
|
+ FoodRecommendIndex i = new FoodRecommendIndex();
|
|
|
+ i.setId(userId * 100 + foodId);
|
|
|
+ i.setUserId(userId);
|
|
|
+ i.setFoodId(foodId);
|
|
|
+ i.setFoodName(foodName);
|
|
|
+ i.setIndexScore(score);
|
|
|
+ i.setCalculatedAt(new Date());
|
|
|
+ return i;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Food food(Long id, String name, String category) {
|
|
|
+ Food f = new Food();
|
|
|
+ f.setId(id);
|
|
|
+ f.setName(name);
|
|
|
+ f.setCategory(category);
|
|
|
+ return f;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 最小值口径 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void recalculate_takesMinAcrossMembers() {
|
|
|
+ when(familyMemberMapper.selectList(any())).thenReturn(Arrays.asList(
|
|
|
+ member(1L, 101L, "小宇宙", "active"),
|
|
|
+ member(2L, 102L, "妈妈", "active")
|
|
|
+ ));
|
|
|
+ when(idxMapper.selectList(any())).thenReturn(Arrays.asList(
|
|
|
+ idx(101L, 12L, "三文鱼", -18),
|
|
|
+ idx(102L, 12L, "三文鱼", 22),
|
|
|
+ idx(101L, 13L, "菠菜", 30),
|
|
|
+ idx(102L, 13L, "菠菜", 12)
|
|
|
+ ));
|
|
|
+ when(overviewMapper.selectList(any())).thenReturn(Collections.emptyList());
|
|
|
+ when(foodService.listByIds(any())).thenReturn(Arrays.asList(
|
|
|
+ food(12L, "三文鱼", "水产"),
|
|
|
+ food(13L, "菠菜", "蔬菜")
|
|
|
+ ));
|
|
|
+
|
|
|
+ service.recalculate(100L);
|
|
|
+
|
|
|
+ // 抓取插入的两行,校验 min_index_score / member_count
|
|
|
+ org.mockito.ArgumentCaptor<FamilyFoodOverview> captor =
|
|
|
+ org.mockito.ArgumentCaptor.forClass(FamilyFoodOverview.class);
|
|
|
+ verify(overviewMapper, times(2)).insert(captor.capture());
|
|
|
+ List<FamilyFoodOverview> inserted = captor.getAllValues();
|
|
|
+
|
|
|
+ FamilyFoodOverview salmon = findByFoodId(inserted, 12L);
|
|
|
+ assertNotNull(salmon);
|
|
|
+ assertEquals(-18, salmon.getMinIndexScore()); // min(-18, 22)
|
|
|
+ assertEquals(2, salmon.getMemberCount());
|
|
|
+ assertEquals("三文鱼", salmon.getFoodName());
|
|
|
+ assertEquals("水产", salmon.getCategory());
|
|
|
+ assertTrue(salmon.getMemberScores().contains("小宇宙"));
|
|
|
+ assertTrue(salmon.getMemberScores().contains("\"score\":-18"));
|
|
|
+
|
|
|
+ FamilyFoodOverview spinach = findByFoodId(inserted, 13L);
|
|
|
+ assertNotNull(spinach);
|
|
|
+ assertEquals(12, spinach.getMinIndexScore()); // min(30, 12)
|
|
|
+ assertEquals(2, spinach.getMemberCount());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void recalculate_memberWithoutRecordNotCountedInMin() {
|
|
|
+ // 妈妈没有"三文鱼"记录 → 不计入 min,也不能当 0
|
|
|
+ when(familyMemberMapper.selectList(any())).thenReturn(Arrays.asList(
|
|
|
+ member(1L, 101L, "小宇宙", "active"),
|
|
|
+ member(2L, 102L, "妈妈", "active")
|
|
|
+ ));
|
|
|
+ when(idxMapper.selectList(any())).thenReturn(Collections.singletonList(
|
|
|
+ idx(101L, 12L, "三文鱼", -18)
|
|
|
+ ));
|
|
|
+ when(overviewMapper.selectList(any())).thenReturn(Collections.emptyList());
|
|
|
+ when(foodService.listByIds(any())).thenReturn(Collections.singletonList(
|
|
|
+ food(12L, "三文鱼", "水产")
|
|
|
+ ));
|
|
|
+
|
|
|
+ service.recalculate(100L);
|
|
|
+
|
|
|
+ org.mockito.ArgumentCaptor<FamilyFoodOverview> captor =
|
|
|
+ org.mockito.ArgumentCaptor.forClass(FamilyFoodOverview.class);
|
|
|
+ verify(overviewMapper, times(1)).insert(captor.capture());
|
|
|
+ FamilyFoodOverview salmon = findByFoodId(captor.getAllValues(), 12L);
|
|
|
+ assertEquals(-18, salmon.getMinIndexScore());
|
|
|
+ assertEquals(1, salmon.getMemberCount());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void recalculate_foodWithNoRecordsAnywhere_doesNotAppear() {
|
|
|
+ // 成员都没有任何食材记录 → 总览为空,不插入任何行
|
|
|
+ when(familyMemberMapper.selectList(any())).thenReturn(Collections.singletonList(
|
|
|
+ member(1L, 101L, "小宇宙", "active")
|
|
|
+ ));
|
|
|
+ when(idxMapper.selectList(any())).thenReturn(Collections.emptyList());
|
|
|
+ when(overviewMapper.selectList(any())).thenReturn(Collections.emptyList());
|
|
|
+
|
|
|
+ service.recalculate(100L);
|
|
|
+
|
|
|
+ verify(overviewMapper, never()).insert(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void recalculate_excludesRecycledMembers() {
|
|
|
+ // 回收箱成员(status=recycled)不计入 —— SQL + Java 双重过滤,本测试只断言 Java 侧兜底
|
|
|
+ when(familyMemberMapper.selectList(any())).thenReturn(Arrays.asList(
|
|
|
+ member(1L, 101L, "小宇宙", "active"),
|
|
|
+ member(2L, 102L, "已踢出", "recycled")
|
|
|
+ ));
|
|
|
+ when(idxMapper.selectList(any())).thenReturn(Arrays.asList(
|
|
|
+ idx(101L, 12L, "三文鱼", -18),
|
|
|
+ idx(102L, 12L, "三文鱼", 50)
|
|
|
+ ));
|
|
|
+ when(overviewMapper.selectList(any())).thenReturn(Collections.emptyList());
|
|
|
+ when(foodService.listByIds(any())).thenReturn(Collections.singletonList(
|
|
|
+ food(12L, "三文鱼", "水产")
|
|
|
+ ));
|
|
|
+
|
|
|
+ service.recalculate(100L);
|
|
|
+
|
|
|
+ org.mockito.ArgumentCaptor<FamilyFoodOverview> captor =
|
|
|
+ org.mockito.ArgumentCaptor.forClass(FamilyFoodOverview.class);
|
|
|
+ verify(overviewMapper, times(1)).insert(captor.capture());
|
|
|
+ FamilyFoodOverview salmon = findByFoodId(captor.getAllValues(), 12L);
|
|
|
+ // 仅计入小宇宙 -18,已踢出 50 被过滤 → min = -18, count = 1
|
|
|
+ assertEquals(-18, salmon.getMinIndexScore());
|
|
|
+ assertEquals(1, salmon.getMemberCount());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void recalculate_noValidMembers_clearsExistingRows() {
|
|
|
+ when(familyMemberMapper.selectList(any())).thenReturn(Collections.emptyList());
|
|
|
+
|
|
|
+ service.recalculate(100L);
|
|
|
+
|
|
|
+ verify(overviewMapper).delete(any());
|
|
|
+ verify(overviewMapper, never()).insert(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 幂等 / 全量覆盖 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void recalculate_sameFingerprint_skipsWrite() {
|
|
|
+ FamilyFoodOverview existing = new FamilyFoodOverview();
|
|
|
+ existing.setId(1L);
|
|
|
+ existing.setFamilyId(100L);
|
|
|
+ existing.setFoodId(12L);
|
|
|
+ existing.setFoodName("三文鱼");
|
|
|
+ existing.setCategory("水产");
|
|
|
+ existing.setMinIndexScore(-18);
|
|
|
+ existing.setMemberCount(1);
|
|
|
+ existing.setMemberScores("[]");
|
|
|
+ existing.setSourceFingerprint("stale-fingerprint");
|
|
|
+
|
|
|
+ when(familyMemberMapper.selectList(any())).thenReturn(Collections.singletonList(
|
|
|
+ member(1L, 101L, "小宇宙", "active")
|
|
|
+ ));
|
|
|
+ when(idxMapper.selectList(any())).thenReturn(Collections.singletonList(
|
|
|
+ idx(101L, 12L, "三文鱼", -18)
|
|
|
+ ));
|
|
|
+ when(overviewMapper.selectList(any())).thenReturn(Collections.singletonList(existing));
|
|
|
+ when(foodService.listByIds(any())).thenReturn(Collections.singletonList(
|
|
|
+ food(12L, "三文鱼", "水产")
|
|
|
+ ));
|
|
|
+
|
|
|
+ // 第一次:指纹与库内不一致 → 更新
|
|
|
+ service.recalculate(100L);
|
|
|
+ org.mockito.ArgumentCaptor<FamilyFoodOverview> captor =
|
|
|
+ org.mockito.ArgumentCaptor.forClass(FamilyFoodOverview.class);
|
|
|
+ verify(overviewMapper, times(1)).updateById(captor.capture());
|
|
|
+ String computed = captor.getValue().getSourceFingerprint();
|
|
|
+ assertNotNull(computed);
|
|
|
+
|
|
|
+ // 第二次:库内指纹已等于本次计算值 → 幂等跳过写入
|
|
|
+ existing.setSourceFingerprint(computed);
|
|
|
+ reset(overviewMapper);
|
|
|
+ when(overviewMapper.selectList(any())).thenReturn(Collections.singletonList(existing));
|
|
|
+ service.recalculate(100L);
|
|
|
+ verify(overviewMapper, never()).updateById(any());
|
|
|
+ verify(overviewMapper, never()).insert(any());
|
|
|
+ verify(overviewMapper, never()).deleteById(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void recalculate_removesFoodsNoLongerPresent() {
|
|
|
+ FamilyFoodOverview stale = new FamilyFoodOverview();
|
|
|
+ stale.setId(9L);
|
|
|
+ stale.setFamilyId(100L);
|
|
|
+ stale.setFoodId(99L);
|
|
|
+ stale.setFoodName("已删除食材");
|
|
|
+ stale.setCategory("蔬菜");
|
|
|
+ stale.setMinIndexScore(5);
|
|
|
+ stale.setMemberCount(1);
|
|
|
+ stale.setSourceFingerprint("old");
|
|
|
+
|
|
|
+ when(familyMemberMapper.selectList(any())).thenReturn(Collections.singletonList(
|
|
|
+ member(1L, 101L, "小宇宙", "active")
|
|
|
+ ));
|
|
|
+ when(idxMapper.selectList(any())).thenReturn(Collections.singletonList(
|
|
|
+ idx(101L, 12L, "三文鱼", -18)
|
|
|
+ ));
|
|
|
+ when(overviewMapper.selectList(any())).thenReturn(Collections.singletonList(stale));
|
|
|
+ when(foodService.listByIds(any())).thenReturn(Collections.singletonList(
|
|
|
+ food(12L, "三文鱼", "水产")
|
|
|
+ ));
|
|
|
+
|
|
|
+ service.recalculate(100L);
|
|
|
+
|
|
|
+ verify(overviewMapper).deleteById(9L);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 读取 / backfill ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void getOverview_noPersistedRecords_backfillsFirst() {
|
|
|
+ when(overviewMapper.selectList(any())).thenReturn(Collections.emptyList());
|
|
|
+ when(familyMemberMapper.selectList(any())).thenReturn(Collections.singletonList(
|
|
|
+ member(1L, 101L, "小宇宙", "active")
|
|
|
+ ));
|
|
|
+ when(idxMapper.selectList(any())).thenReturn(Collections.singletonList(
|
|
|
+ idx(101L, 12L, "三文鱼", -18)
|
|
|
+ ));
|
|
|
+ when(foodService.listByIds(any())).thenReturn(Collections.singletonList(
|
|
|
+ food(12L, "三文鱼", "水产")
|
|
|
+ ));
|
|
|
+
|
|
|
+ List<FamilyFoodOverview> result = service.getOverview(100L);
|
|
|
+
|
|
|
+ // 无记录 → 先 backfill 再返回:getOverview 首次读空 + recalculate 内读现有 + backfill 后再读
|
|
|
+ verify(overviewMapper).insert(any());
|
|
|
+ verify(overviewMapper, times(3)).selectList(any());
|
|
|
+ assertNotNull(result);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void getOverviewMaps_exposesSpecShape() {
|
|
|
+ FamilyFoodOverview row = new FamilyFoodOverview();
|
|
|
+ row.setFamilyId(100L);
|
|
|
+ row.setFoodId(12L);
|
|
|
+ row.setFoodName("三文鱼");
|
|
|
+ row.setCategory("水产");
|
|
|
+ row.setMinIndexScore(-18);
|
|
|
+ row.setMemberCount(2);
|
|
|
+ row.setMemberScores("[{\"userId\":101,\"nickname\":\"小宇宙\",\"score\":-18},{\"userId\":102,\"nickname\":\"妈妈\",\"score\":22}]");
|
|
|
+ row.setCalculatedAt(new Date());
|
|
|
+
|
|
|
+ when(overviewMapper.selectList(any())).thenReturn(Collections.singletonList(row));
|
|
|
+
|
|
|
+ List<Map<String, Object>> maps = service.getOverviewMaps(100L);
|
|
|
+
|
|
|
+ assertEquals(1, maps.size());
|
|
|
+ Map<String, Object> m = maps.get(0);
|
|
|
+ assertEquals(12L, ((Number) m.get("foodId")).longValue());
|
|
|
+ assertEquals("三文鱼", m.get("foodName"));
|
|
|
+ assertEquals("水产", m.get("category"));
|
|
|
+ assertEquals(-18, ((Number) m.get("minIndexScore")).intValue());
|
|
|
+ assertEquals(2, ((Number) m.get("memberCount")).intValue());
|
|
|
+ assertNotNull(m.get("calculatedAt"));
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ List<Map<String, Object>> details = (List<Map<String, Object>>) m.get("memberScores");
|
|
|
+ assertEquals(2, details.size());
|
|
|
+ assertEquals("小宇宙", details.get(0).get("nickname"));
|
|
|
+ assertEquals(-18, ((Number) details.get(0).get("score")).intValue());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 辅助 ====================
|
|
|
+
|
|
|
+ private static FamilyFoodOverview findByFoodId(List<FamilyFoodOverview> list, Long foodId) {
|
|
|
+ for (FamilyFoodOverview fp : list) {
|
|
|
+ if (foodId.equals(fp.getFoodId())) return fp;
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+}
|