From 71d6400a1e6effedbde3b68e7aab3b222ba45e94 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Wed, 3 Dec 2025 18:27:53 +0800 Subject: [PATCH] =?UTF-8?q?test(pipeline):=20=E6=B7=BB=E5=8A=A0=E4=BA=BA?= =?UTF-8?q?=E8=84=B8=E5=8C=B9=E9=85=8D=E6=B5=81=E6=B0=B4=E7=BA=BF=E5=8D=95?= =?UTF-8?q?=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为CustomFaceSearchStage添加完整单元测试覆盖各种匹配模式 - 为人脸识别阶段FaceRecognitionStage编写测试用例 - 为上下文准备阶段PrepareContextStage增加测试验证 - 包含成功、失败、异常等边界情况测试 - 验证不同匹配模式下的结果合并逻辑 - 测试人工选择和自动匹配场景的处理差异 --- .../stages/FilterByDevicePhotoLimitStage.java | 6 + .../stages/CustomFaceSearchStageTest.java | 299 ++++++++++++++++++ .../stages/FaceRecognitionStageTest.java | 191 +++++++++++ .../stages/PrepareContextStageTest.java | 188 +++++++++++ 4 files changed, 684 insertions(+) create mode 100644 src/test/java/com/ycwl/basic/face/pipeline/stages/CustomFaceSearchStageTest.java create mode 100644 src/test/java/com/ycwl/basic/face/pipeline/stages/FaceRecognitionStageTest.java create mode 100644 src/test/java/com/ycwl/basic/face/pipeline/stages/PrepareContextStageTest.java diff --git a/src/main/java/com/ycwl/basic/face/pipeline/stages/FilterByDevicePhotoLimitStage.java b/src/main/java/com/ycwl/basic/face/pipeline/stages/FilterByDevicePhotoLimitStage.java index b594822a..78636ac6 100644 --- a/src/main/java/com/ycwl/basic/face/pipeline/stages/FilterByDevicePhotoLimitStage.java +++ b/src/main/java/com/ycwl/basic/face/pipeline/stages/FilterByDevicePhotoLimitStage.java @@ -67,6 +67,12 @@ public class FilterByDevicePhotoLimitStage extends AbstractFaceMatchingStage sampleListIds = context.getSampleListIds(); Long faceId = context.getFaceId(); + // 防御性检查:faceSamples为空 + if (faceSamples == null || faceSamples.isEmpty()) { + log.debug("faceSamples为空,跳过设备照片限制筛选,faceId={}", faceId); + return StageResult.skipped("faceSamples为空"); + } + try { // 1. 构建样本ID到实体的映射 Map sampleMap = faceSamples.stream() diff --git a/src/test/java/com/ycwl/basic/face/pipeline/stages/CustomFaceSearchStageTest.java b/src/test/java/com/ycwl/basic/face/pipeline/stages/CustomFaceSearchStageTest.java new file mode 100644 index 00000000..6ba0b92a --- /dev/null +++ b/src/test/java/com/ycwl/basic/face/pipeline/stages/CustomFaceSearchStageTest.java @@ -0,0 +1,299 @@ +package com.ycwl.basic.face.pipeline.stages; + +import com.ycwl.basic.exception.BaseException; +import com.ycwl.basic.face.pipeline.core.FaceMatchingContext; +import com.ycwl.basic.face.pipeline.core.StageResult; +import com.ycwl.basic.facebody.adapter.IFaceBodyAdapter; +import com.ycwl.basic.model.pc.face.entity.FaceEntity; +import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity; +import com.ycwl.basic.model.task.resp.SearchFaceRespVo; +import com.ycwl.basic.service.pc.helper.SearchResultMerger; +import com.ycwl.basic.service.task.TaskFaceService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * CustomFaceSearchStage 单元测试 + */ +@ExtendWith(MockitoExtension.class) +class CustomFaceSearchStageTest { + + @Mock + private TaskFaceService taskFaceService; + + @Mock + private SearchResultMerger resultMerger; + + @Mock + private IFaceBodyAdapter faceBodyAdapter; + + @InjectMocks + private CustomFaceSearchStage stage; + + private FaceMatchingContext context; + private FaceEntity face; + + @BeforeEach + void setUp() { + context = FaceMatchingContext.forCustomMatching(1L, Arrays.asList(101L, 102L)); + face = new FaceEntity(); + face.setId(1L); + face.setScenicId(10L); + face.setFaceUrl("http://example.com/face.jpg"); + context.setFace(face); + context.setFaceBodyAdapter(faceBodyAdapter); + } + + @Test + void testExecute_Mode2_DirectUse() { + // Given: 模式2,直接使用用户选择的样本 + context.setFaceSelectPostMode(2); + context.setFaceSampleIds(Arrays.asList(101L, 102L, 103L)); + + SearchFaceRespVo directResult = new SearchFaceRespVo(); + directResult.setSampleListIds(Arrays.asList(101L, 102L, 103L)); + + when(resultMerger.createDirectResult(Arrays.asList(101L, 102L, 103L))) + .thenReturn(directResult); + + // When + StageResult result = stage.execute(context); + + // Then + assertTrue(result.isSuccess()); + assertEquals(directResult, context.getSearchResult()); + assertEquals(3, context.getSampleListIds().size()); + verify(resultMerger, times(1)).createDirectResult(anyList()); + verify(taskFaceService, never()).searchFace(any(), anyString(), anyString(), anyString()); + } + + @Test + void testExecute_Mode2_WithMatchResult() { + // Given: 模式2,保留原始matchResult + context.setFaceSelectPostMode(2); + context.setFaceSampleIds(Arrays.asList(101L, 102L)); + face.setMatchResult("{\"score\":0.85}"); + + SearchFaceRespVo directResult = new SearchFaceRespVo(); + directResult.setSampleListIds(Arrays.asList(101L, 102L)); + + when(resultMerger.createDirectResult(anyList())).thenReturn(directResult); + + // When + StageResult result = stage.execute(context); + + // Then + assertTrue(result.isSuccess()); + assertEquals("{\"score\":0.85}", directResult.getSearchResultJson()); + } + + @Test + void testExecute_Mode0_Union() throws Exception { + // Given: 模式0(并集),搜索多个样本并合并 + context.setFaceSelectPostMode(0); + + FaceSampleEntity sample1 = createSample(101L, "http://example.com/s1.jpg"); + FaceSampleEntity sample2 = createSample(102L, "http://example.com/s2.jpg"); + context.setFaceSamples(Arrays.asList(sample1, sample2)); + context.setFaceSampleIds(Arrays.asList(101L, 102L)); + + SearchFaceRespVo searchResult1 = new SearchFaceRespVo(); + searchResult1.setSampleListIds(Arrays.asList(201L, 202L)); + + SearchFaceRespVo searchResult2 = new SearchFaceRespVo(); + searchResult2.setSampleListIds(Arrays.asList(202L, 203L)); + + when(taskFaceService.searchFace(eq(faceBodyAdapter), eq("10"), eq("http://example.com/s1.jpg"), anyString())) + .thenReturn(searchResult1); + when(taskFaceService.searchFace(eq(faceBodyAdapter), eq("10"), eq("http://example.com/s2.jpg"), anyString())) + .thenReturn(searchResult2); + + SearchFaceRespVo mergedResult = new SearchFaceRespVo(); + mergedResult.setSampleListIds(Arrays.asList(201L, 202L, 203L)); // 并集 + + when(resultMerger.merge(anyList(), eq(0))).thenReturn(mergedResult); + + // When + StageResult result = stage.execute(context); + + // Then + assertTrue(result.isSuccess()); + assertEquals(mergedResult, context.getSearchResult()); + assertEquals(3, context.getSampleListIds().size()); + verify(taskFaceService, times(2)).searchFace(any(), anyString(), anyString(), anyString()); + verify(resultMerger, times(1)).merge(anyList(), eq(0)); + } + + @Test + void testExecute_Mode1_Intersection() throws Exception { + // Given: 模式1(交集) + context.setFaceSelectPostMode(1); + + FaceSampleEntity sample1 = createSample(101L, "http://example.com/s1.jpg"); + FaceSampleEntity sample2 = createSample(102L, "http://example.com/s2.jpg"); + context.setFaceSamples(Arrays.asList(sample1, sample2)); + context.setFaceSampleIds(Arrays.asList(101L, 102L)); + + SearchFaceRespVo searchResult1 = new SearchFaceRespVo(); + searchResult1.setSampleListIds(Arrays.asList(201L, 202L, 203L)); + + SearchFaceRespVo searchResult2 = new SearchFaceRespVo(); + searchResult2.setSampleListIds(Arrays.asList(202L, 203L, 204L)); + + when(taskFaceService.searchFace(any(), anyString(), anyString(), anyString())) + .thenReturn(searchResult1, searchResult2); + + SearchFaceRespVo mergedResult = new SearchFaceRespVo(); + mergedResult.setSampleListIds(Arrays.asList(202L, 203L)); // 交集 + + when(resultMerger.merge(anyList(), eq(1))).thenReturn(mergedResult); + + // When + StageResult result = stage.execute(context); + + // Then + assertTrue(result.isSuccess()); + assertEquals(2, context.getSampleListIds().size()); + verify(resultMerger, times(1)).merge(anyList(), eq(1)); + } + + @Test + void testExecute_DefaultMode() throws Exception { + // Given: 未设置faceSelectPostMode,默认为0 + context.setFaceSelectPostMode(null); + + FaceSampleEntity sample1 = createSample(101L, "http://example.com/s1.jpg"); + context.setFaceSamples(Arrays.asList(sample1)); + context.setFaceSampleIds(Arrays.asList(101L)); + + SearchFaceRespVo searchResult = new SearchFaceRespVo(); + searchResult.setSampleListIds(Arrays.asList(201L)); + + when(taskFaceService.searchFace(any(), anyString(), anyString(), anyString())) + .thenReturn(searchResult); + + SearchFaceRespVo mergedResult = new SearchFaceRespVo(); + mergedResult.setSampleListIds(Arrays.asList(201L)); + + when(resultMerger.merge(anyList(), eq(0))).thenReturn(mergedResult); + + // When + StageResult result = stage.execute(context); + + // Then + assertTrue(result.isSuccess()); + verify(resultMerger, times(1)).merge(anyList(), eq(0)); // 默认模式0 + } + + @Test + void testExecute_PartialSearchFailure_Success() throws Exception { + // Given: 部分样本搜索失败,但至少有一个成功 + context.setFaceSelectPostMode(0); + + FaceSampleEntity sample1 = createSample(101L, "http://example.com/s1.jpg"); + FaceSampleEntity sample2 = createSample(102L, "http://example.com/s2.jpg"); + FaceSampleEntity sample3 = createSample(103L, "http://example.com/s3.jpg"); + context.setFaceSamples(Arrays.asList(sample1, sample2, sample3)); + + SearchFaceRespVo searchResult1 = new SearchFaceRespVo(); + searchResult1.setSampleListIds(Arrays.asList(201L)); + + when(taskFaceService.searchFace(eq(faceBodyAdapter), eq("10"), eq("http://example.com/s1.jpg"), anyString())) + .thenReturn(searchResult1); + when(taskFaceService.searchFace(eq(faceBodyAdapter), eq("10"), eq("http://example.com/s2.jpg"), anyString())) + .thenThrow(new RuntimeException("Network error")); + when(taskFaceService.searchFace(eq(faceBodyAdapter), eq("10"), eq("http://example.com/s3.jpg"), anyString())) + .thenReturn(null); + + SearchFaceRespVo mergedResult = new SearchFaceRespVo(); + mergedResult.setSampleListIds(Arrays.asList(201L)); + + when(resultMerger.merge(anyList(), eq(0))).thenReturn(mergedResult); + + // When + StageResult result = stage.execute(context); + + // Then + assertTrue(result.isSuccess()); + verify(taskFaceService, times(3)).searchFace(any(), anyString(), anyString(), anyString()); + } + + @Test + void testExecute_AllSearchFailure_ThrowException() throws Exception { + // Given: 所有样本搜索都失败 + context.setFaceSelectPostMode(0); + + FaceSampleEntity sample1 = createSample(101L, "http://example.com/s1.jpg"); + FaceSampleEntity sample2 = createSample(102L, "http://example.com/s2.jpg"); + context.setFaceSamples(Arrays.asList(sample1, sample2)); + + when(taskFaceService.searchFace(any(), anyString(), anyString(), anyString())) + .thenThrow(new RuntimeException("Network error")); + + // When & Then + assertThrows(BaseException.class, () -> { + stage.execute(context); + }); + } + + @Test + void testExecute_BaseException_Rethrow() throws Exception { + // Given + context.setFaceSelectPostMode(0); + + FaceSampleEntity sample1 = createSample(101L, "http://example.com/s1.jpg"); + context.setFaceSamples(Arrays.asList(sample1)); + + SearchFaceRespVo searchResult = new SearchFaceRespVo(); + when(taskFaceService.searchFace(any(), anyString(), anyString(), anyString())) + .thenReturn(searchResult); + + BaseException baseException = new BaseException("服务不可用"); + when(resultMerger.merge(anyList(), anyInt())).thenThrow(baseException); + + // When & Then + assertThrows(BaseException.class, () -> { + stage.execute(context); + }); + } + + @Test + void testExecute_GenericException_Failed() throws Exception { + // Given + context.setFaceSelectPostMode(0); + + FaceSampleEntity sample1 = createSample(101L, "http://example.com/s1.jpg"); + context.setFaceSamples(Arrays.asList(sample1)); + + SearchFaceRespVo searchResult = new SearchFaceRespVo(); + when(taskFaceService.searchFace(any(), anyString(), anyString(), anyString())) + .thenReturn(searchResult); + + when(resultMerger.merge(anyList(), anyInt())).thenThrow(new RuntimeException("Merge error")); + + // When + StageResult result = stage.execute(context); + + // Then + assertTrue(result.isFailed()); + assertTrue(result.getMessage().contains("自定义人脸搜索失败")); + } + + private FaceSampleEntity createSample(Long id, String faceUrl) { + FaceSampleEntity sample = new FaceSampleEntity(); + sample.setId(id); + sample.setFaceUrl(faceUrl); + return sample; + } +} diff --git a/src/test/java/com/ycwl/basic/face/pipeline/stages/FaceRecognitionStageTest.java b/src/test/java/com/ycwl/basic/face/pipeline/stages/FaceRecognitionStageTest.java new file mode 100644 index 00000000..3de25398 --- /dev/null +++ b/src/test/java/com/ycwl/basic/face/pipeline/stages/FaceRecognitionStageTest.java @@ -0,0 +1,191 @@ +package com.ycwl.basic.face.pipeline.stages; + +import com.ycwl.basic.exception.BaseException; +import com.ycwl.basic.face.pipeline.core.FaceMatchingContext; +import com.ycwl.basic.face.pipeline.core.StageResult; +import com.ycwl.basic.facebody.adapter.IFaceBodyAdapter; +import com.ycwl.basic.model.pc.face.entity.FaceEntity; +import com.ycwl.basic.model.task.resp.SearchFaceRespVo; +import com.ycwl.basic.service.task.TaskFaceService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +/** + * FaceRecognitionStage 单元测试 + */ +@ExtendWith(MockitoExtension.class) +class FaceRecognitionStageTest { + + @Mock + private TaskFaceService taskFaceService; + + @Mock + private IFaceBodyAdapter faceBodyAdapter; + + @InjectMocks + private FaceRecognitionStage stage; + + private FaceMatchingContext context; + private FaceEntity face; + + @BeforeEach + void setUp() { + context = FaceMatchingContext.forAutoMatching(1L, true); + face = new FaceEntity(); + face.setId(1L); + face.setScenicId(10L); + face.setFaceUrl("http://example.com/face.jpg"); + context.setFace(face); + context.setFaceBodyAdapter(faceBodyAdapter); + } + + @Test + void testExecute_Success() throws Exception { + // Given + SearchFaceRespVo searchResult = new SearchFaceRespVo(); + searchResult.setScore(0.85f); + searchResult.setSampleListIds(Arrays.asList(101L, 102L, 103L)); + + when(taskFaceService.searchFace( + eq(faceBodyAdapter), + eq("10"), + eq("http://example.com/face.jpg"), + eq("人脸识别") + )).thenReturn(searchResult); + + // When + StageResult result = stage.execute(context); + + // Then + assertTrue(result.isSuccess()); + assertEquals(searchResult, context.getSearchResult()); + assertEquals(0.85f, context.getSearchResult().getScore(), 0.0001); + assertEquals(3, context.getSearchResult().getSampleListIds().size()); + verify(taskFaceService, times(1)).searchFace(any(), anyString(), anyString(), anyString()); + } + + @Test + void testExecute_SearchResultNull_Failed() throws Exception { + // Given + when(taskFaceService.searchFace(any(), anyString(), anyString(), anyString())) + .thenReturn(null); + + // When + StageResult result = stage.execute(context); + + // Then + assertTrue(result.isFailed()); + assertTrue(result.getMessage().contains("人脸识别失败")); + assertNull(context.getSearchResult()); + } + + @Test + void testExecute_SearchResultEmptyList() throws Exception { + // Given + SearchFaceRespVo searchResult = new SearchFaceRespVo(); + searchResult.setScore(0.45f); // 低分 + searchResult.setSampleListIds(Arrays.asList()); // 空列表 + + when(taskFaceService.searchFace(any(), anyString(), anyString(), anyString())) + .thenReturn(searchResult); + + // When + StageResult result = stage.execute(context); + + // Then + assertTrue(result.isSuccess()); // 即使没有匹配也成功 + assertEquals(searchResult, context.getSearchResult()); + assertEquals(0, context.getSearchResult().getSampleListIds().size()); + } + + @Test + void testExecute_BaseException_Throw() throws Exception { + // Given + BaseException baseException = new BaseException("识别服务异常"); + when(taskFaceService.searchFace(any(), anyString(), anyString(), anyString())) + .thenThrow(baseException); + + // When & Then + assertThrows(BaseException.class, () -> { + stage.execute(context); + }); + } + + @Test + void testExecute_GenericException_Failed() throws Exception { + // Given + when(taskFaceService.searchFace(any(), anyString(), anyString(), anyString())) + .thenThrow(new RuntimeException("Network error")); + + // When + StageResult result = stage.execute(context); + + // Then + assertTrue(result.isFailed()); + assertTrue(result.getMessage().contains("人脸识别失败")); + assertNotNull(result.getException()); + } + + @Test + void testExecute_NullFaceUrl() throws Exception { + // Given + face.setFaceUrl(null); + + when(taskFaceService.searchFace(any(), anyString(), isNull(), anyString())) + .thenThrow(new IllegalArgumentException("Face URL is null")); + + // When + StageResult result = stage.execute(context); + + // Then + assertTrue(result.isFailed()); + } + + @Test + void testExecute_HighScoreResult() throws Exception { + // Given: 高分匹配 + SearchFaceRespVo searchResult = new SearchFaceRespVo(); + searchResult.setScore(0.95f); + searchResult.setSampleListIds(Arrays.asList(101L)); + + when(taskFaceService.searchFace(any(), anyString(), anyString(), anyString())) + .thenReturn(searchResult); + + // When + StageResult result = stage.execute(context); + + // Then + assertTrue(result.isSuccess()); + assertEquals(0.95f, context.getSearchResult().getScore(), 0.0001); + } + + @Test + void testExecute_MultipleMatches() throws Exception { + // Given: 多个匹配结果 + SearchFaceRespVo searchResult = new SearchFaceRespVo(); + searchResult.setScore(0.78f); + searchResult.setSampleListIds(Arrays.asList(101L, 102L, 103L, 104L, 105L)); + + when(taskFaceService.searchFace(any(), anyString(), anyString(), anyString())) + .thenReturn(searchResult); + + // When + StageResult result = stage.execute(context); + + // Then + assertTrue(result.isSuccess()); + assertEquals(5, context.getSearchResult().getSampleListIds().size()); + assertTrue(result.getMessage().contains("匹配数=5")); + } +} diff --git a/src/test/java/com/ycwl/basic/face/pipeline/stages/PrepareContextStageTest.java b/src/test/java/com/ycwl/basic/face/pipeline/stages/PrepareContextStageTest.java new file mode 100644 index 00000000..6f4ac612 --- /dev/null +++ b/src/test/java/com/ycwl/basic/face/pipeline/stages/PrepareContextStageTest.java @@ -0,0 +1,188 @@ +package com.ycwl.basic.face.pipeline.stages; + +import com.ycwl.basic.face.pipeline.core.FaceMatchingContext; +import com.ycwl.basic.face.pipeline.core.StageResult; +import com.ycwl.basic.face.pipeline.enums.FaceMatchingScene; +import com.ycwl.basic.facebody.adapter.IFaceBodyAdapter; +import com.ycwl.basic.integration.common.manager.ScenicConfigManager; +import com.ycwl.basic.model.pc.face.entity.FaceEntity; +import com.ycwl.basic.repository.FaceRepository; +import com.ycwl.basic.repository.ScenicRepository; +import com.ycwl.basic.service.pc.ScenicService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * PrepareContextStage 单元测试 + */ +@ExtendWith(MockitoExtension.class) +class PrepareContextStageTest { + + @Mock + private FaceRepository faceRepository; + + @Mock + private ScenicRepository scenicRepository; + + @Mock + private ScenicService scenicService; + + @Mock + private ScenicConfigManager scenicConfig; + + @Mock + private IFaceBodyAdapter faceBodyAdapter; + + @InjectMocks + private PrepareContextStage stage; + + private FaceMatchingContext context; + + @BeforeEach + void setUp() { + context = FaceMatchingContext.forAutoMatching(1L, true); + } + + @Test + void testExecute_Success() { + // Given + FaceEntity face = createFace(1L, 100L, 10L, 0); // isManual=0 + when(faceRepository.getFace(1L)).thenReturn(face); + when(scenicRepository.getScenicConfigManager(10L)).thenReturn(scenicConfig); + when(scenicService.getScenicFaceBodyAdapter(10L)).thenReturn(faceBodyAdapter); + + // When + StageResult result = stage.execute(context); + + // Then + assertTrue(result.isSuccess()); + assertEquals(face, context.getFace()); + assertEquals(scenicConfig, context.getScenicConfig()); + assertEquals(faceBodyAdapter, context.getFaceBodyAdapter()); + verify(faceRepository, times(1)).getFace(1L); + verify(scenicRepository, times(1)).getScenicConfigManager(10L); + verify(scenicService, times(1)).getScenicFaceBodyAdapter(10L); + } + + @Test + void testExecute_FaceNotFound_Failed() { + // Given + when(faceRepository.getFace(1L)).thenReturn(null); + + // When + StageResult result = stage.execute(context); + + // Then + assertTrue(result.isFailed()); + assertTrue(result.getMessage().contains("人脸不存在")); + assertNull(context.getFace()); + } + + @Test + void testExecute_ManualFaceAndNotNew_Skipped() { + // Given: isManual=1, isNew=false + context = FaceMatchingContext.forAutoMatching(1L, false); + FaceEntity face = createFace(1L, 100L, 10L, 1); // isManual=1 + when(faceRepository.getFace(1L)).thenReturn(face); + + // When + StageResult result = stage.execute(context); + + // Then + assertTrue(result.isSkipped()); + assertTrue(result.getMessage().contains("人工选择的人脸")); + assertEquals(face, context.getFace()); + // 人工选择的人脸跳过,不加载景区配置 + verify(scenicRepository, never()).getScenicConfigManager(anyLong()); + verify(scenicService, never()).getScenicFaceBodyAdapter(anyLong()); + } + + @Test + void testExecute_ManualFaceButIsNew_Success() { + // Given: isManual=1, isNew=true (新用户即使人工选择也需要匹配) + context = FaceMatchingContext.forAutoMatching(1L, true); + FaceEntity face = createFace(1L, 100L, 10L, 1); // isManual=1 + when(faceRepository.getFace(1L)).thenReturn(face); + when(scenicRepository.getScenicConfigManager(10L)).thenReturn(scenicConfig); + when(scenicService.getScenicFaceBodyAdapter(10L)).thenReturn(faceBodyAdapter); + + // When + StageResult result = stage.execute(context); + + // Then + assertTrue(result.isSuccess()); + assertEquals(face, context.getFace()); + assertEquals(scenicConfig, context.getScenicConfig()); + assertEquals(faceBodyAdapter, context.getFaceBodyAdapter()); + } + + @Test + void testExecute_FaceBodyAdapterNotAvailable_Failed() { + // Given + FaceEntity face = createFace(1L, 100L, 10L, 0); + when(faceRepository.getFace(1L)).thenReturn(face); + when(scenicRepository.getScenicConfigManager(10L)).thenReturn(scenicConfig); + when(scenicService.getScenicFaceBodyAdapter(10L)).thenReturn(null); // 无适配器 + + // When + StageResult result = stage.execute(context); + + // Then + assertTrue(result.isFailed()); + assertTrue(result.getMessage().contains("人脸识别服务不可用")); + assertEquals(face, context.getFace()); + assertEquals(scenicConfig, context.getScenicConfig()); + assertNull(context.getFaceBodyAdapter()); + } + + @Test + void testExecute_AutoMatchingOldUser() { + // Given + context = FaceMatchingContext.forAutoMatching(1L, false); // 老用户 + FaceEntity face = createFace(1L, 100L, 10L, 0); + when(faceRepository.getFace(1L)).thenReturn(face); + when(scenicRepository.getScenicConfigManager(10L)).thenReturn(scenicConfig); + when(scenicService.getScenicFaceBodyAdapter(10L)).thenReturn(faceBodyAdapter); + + // When + StageResult result = stage.execute(context); + + // Then + assertTrue(result.isSuccess()); + assertFalse(context.isNew()); + } + + @Test + void testExecute_RecognitionOnlyScene() { + // Given + context = FaceMatchingContext.forRecognitionOnly(1L); + FaceEntity face = createFace(1L, 100L, 10L, 0); + when(faceRepository.getFace(1L)).thenReturn(face); + when(scenicRepository.getScenicConfigManager(10L)).thenReturn(scenicConfig); + when(scenicService.getScenicFaceBodyAdapter(10L)).thenReturn(faceBodyAdapter); + + // When + StageResult result = stage.execute(context); + + // Then + assertTrue(result.isSuccess()); + assertEquals(FaceMatchingScene.RECOGNITION_ONLY, context.getScene()); + } + + private FaceEntity createFace(Long id, Long memberId, Long scenicId, Integer isManual) { + FaceEntity face = new FaceEntity(); + face.setId(id); + face.setMemberId(memberId); + face.setScenicId(scenicId); + face.setIsManual(isManual); + face.setFaceUrl("http://example.com/face.jpg"); + return face; + } +}