|
@@ -22,12 +22,14 @@ import org.mockito.InOrder;
|
|
|
import java.util.List;
|
|
import java.util.List;
|
|
|
import java.util.Map;
|
|
import java.util.Map;
|
|
|
import java.util.Objects;
|
|
import java.util.Objects;
|
|
|
|
|
+import java.util.concurrent.CountDownLatch;
|
|
|
|
|
|
|
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
import static org.mockito.ArgumentMatchers.any;
|
|
import static org.mockito.ArgumentMatchers.any;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.anyBoolean;
|
|
|
import static org.mockito.ArgumentMatchers.anyLong;
|
|
import static org.mockito.ArgumentMatchers.anyLong;
|
|
|
import static org.mockito.ArgumentMatchers.eq;
|
|
import static org.mockito.ArgumentMatchers.eq;
|
|
|
import static org.mockito.ArgumentMatchers.isNull;
|
|
import static org.mockito.ArgumentMatchers.isNull;
|
|
@@ -80,6 +82,8 @@ class AiSmartCleanServiceTest {
|
|
|
batchMapper = mock(AiCleanBatchMapper.class);
|
|
batchMapper = mock(AiCleanBatchMapper.class);
|
|
|
aiCleanService = mock(AiCleanService.class);
|
|
aiCleanService = mock(AiCleanService.class);
|
|
|
dmService = mock(DmService.class);
|
|
dmService = mock(DmService.class);
|
|
|
|
|
+ // 按级串行清洗走 2 参 doClean(返回完成 Latch):测试里直接给已归零的 Latch
|
|
|
|
|
+ when(dmService.doClean(any(DoCleanDTO.class), anyBoolean())).thenReturn(new CountDownLatch(0));
|
|
|
service = new AiSmartCleanService(batchMapper, aiCleanService, dmService);
|
|
service = new AiSmartCleanService(batchMapper, aiCleanService, dmService);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -108,7 +112,7 @@ class AiSmartCleanServiceTest {
|
|
|
private static AiJudgeResult.ExistingHit hit(long sheetId, int templateId) {
|
|
private static AiJudgeResult.ExistingHit hit(long sheetId, int templateId) {
|
|
|
TableRuleDTO rule = new TableRuleDTO();
|
|
TableRuleDTO rule = new TableRuleDTO();
|
|
|
rule.setFieldNameEn("user_name");
|
|
rule.setFieldNameEn("user_name");
|
|
|
- return new AiJudgeResult.ExistingHit(sheetId, templateId, 7, "(姓名)", List.of(rule), "人员信息");
|
|
|
|
|
|
|
+ return new AiJudgeResult.ExistingHit(sheetId, templateId, 7, "(姓名)", List.of(rule), "人员信息", 2);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private static AiJudgeResult.AiPlan aiPlan() {
|
|
private static AiJudgeResult.AiPlan aiPlan() {
|
|
@@ -161,11 +165,11 @@ class AiSmartCleanServiceTest {
|
|
|
.toList();
|
|
.toList();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // ==================== ① 一次 doClean ====================
|
|
|
|
|
|
|
+ // ==================== ① 按级串行 doClean ====================
|
|
|
|
|
|
|
|
@Test
|
|
@Test
|
|
|
- @DisplayName("既有匹配的 + AI 命中既有模板的:合并成一次 doClean,且判定全部归零之后才发")
|
|
|
|
|
- void mergesExistingAndAiHitsIntoSingleDoClean() {
|
|
|
|
|
|
|
+ @DisplayName("1级(前端已匹配)先洗并等落库,2级(AI 命中)后洗;3级不进既有链路")
|
|
|
|
|
+ void cleansByLevelInOrder() {
|
|
|
FileInfo s1 = sheet(1, "s1", 11), s2 = sheet(2, "s2", 12), s3 = sheet(3, "s3", 13);
|
|
FileInfo s1 = sheet(1, "s1", 11), s2 = sheet(2, "s2", 12), s3 = sheet(3, "s3", 13);
|
|
|
FileInfo s4 = sheet(4, "s4", null), s5 = sheet(5, "s5", null), s6 = sheet(6, "s6", null);
|
|
FileInfo s4 = sheet(4, "s4", null), s5 = sheet(5, "s5", null), s6 = sheet(6, "s6", null);
|
|
|
FileInfo rootA = file(100L, 9L, s1, s2, s3);
|
|
FileInfo rootA = file(100L, 9L, s1, s2, s3);
|
|
@@ -174,18 +178,29 @@ class AiSmartCleanServiceTest {
|
|
|
|
|
|
|
|
service.run(9L, 1L, List.of(rootA, rootB));
|
|
service.run(9L, 1L, List.of(rootA, rootB));
|
|
|
|
|
|
|
|
- ArgumentCaptor<DoCleanDTO> dto = ArgumentCaptor.forClass(DoCleanDTO.class);
|
|
|
|
|
- verify(dmService, times(1)).doClean(dto.capture());
|
|
|
|
|
- List<FileInfo> roots = dto.getValue().getFileInfos();
|
|
|
|
|
- assertEquals(2, roots.size(), "整棵树原样交给既有链路,前端报文结构不变");
|
|
|
|
|
- long toExisting = roots.stream().flatMap(r -> r.getChildren().stream())
|
|
|
|
|
- .filter(f -> f.getTemplateId() != null).count();
|
|
|
|
|
- assertEquals(5, toExisting, "3 个原本匹配的 + 2 个 AI 命中的,共 5 个进既有清洗");
|
|
|
|
|
-
|
|
|
|
|
- // AI 命中的节点要带齐规则与瞬态字段,否则既有策略要么走空规则,要么从 code 反推 mainId 踩坑
|
|
|
|
|
|
|
+ // 两趟:先 1级(静默,只含前端匹配的 s1-s3),再 2级(收口,只含 AI 命中的 s4/s5)
|
|
|
|
|
+ ArgumentCaptor<DoCleanDTO> l1 = ArgumentCaptor.forClass(DoCleanDTO.class);
|
|
|
|
|
+ ArgumentCaptor<DoCleanDTO> l2 = ArgumentCaptor.forClass(DoCleanDTO.class);
|
|
|
|
|
+ InOrder inOrder = inOrder(dmService);
|
|
|
|
|
+ inOrder.verify(dmService).doClean(l1.capture(), eq(true));
|
|
|
|
|
+ inOrder.verify(dmService).doClean(l2.capture(), eq(false));
|
|
|
|
|
+ verify(dmService, times(2)).doClean(any(DoCleanDTO.class), anyBoolean());
|
|
|
|
|
+
|
|
|
|
|
+ List<FileInfo> l1Sheets = l1.getValue().getFileInfos().stream()
|
|
|
|
|
+ .flatMap(r -> r.getChildren().stream()).toList();
|
|
|
|
|
+ assertEquals(3, l1Sheets.size(), "1级趟只洗前端已匹配的表");
|
|
|
|
|
+ assertTrue(l1Sheets.stream().allMatch(s -> s.getTemplateId() != null));
|
|
|
|
|
+
|
|
|
|
|
+ List<FileInfo> l2Sheets = l2.getValue().getFileInfos().stream()
|
|
|
|
|
+ .flatMap(r -> r.getChildren().stream()).toList();
|
|
|
|
|
+ assertEquals(2, l2Sheets.size(), "2级趟只洗 AI 命中的表");
|
|
|
|
|
+ assertTrue(l2Sheets.stream().allMatch(s -> s.getTemplateId() != null));
|
|
|
|
|
+
|
|
|
|
|
+ // AI 命中的节点要带齐规则与瞬态字段(含上传时缺失的表头行号),否则既有链路首行 NPE
|
|
|
assertEquals(21, s4.getTemplateId());
|
|
assertEquals(21, s4.getTemplateId());
|
|
|
assertEquals(7, s4.getMainId(), "mainId 要显式塞进请求,CleanTask 只回填 templateId 和 tableNameEn");
|
|
assertEquals(7, s4.getMainId(), "mainId 要显式塞进请求,CleanTask 只回填 templateId 和 tableNameEn");
|
|
|
assertEquals("(姓名)", s4.getFuncRegx());
|
|
assertEquals("(姓名)", s4.getFuncRegx());
|
|
|
|
|
+ assertEquals(2, s4.getLineNo(), "出口 A 必须补 lineNo,StreamingEtlListener 按它切表头行");
|
|
|
assertNotNull(s4.getTableRuleList());
|
|
assertNotNull(s4.getTableRuleList());
|
|
|
assertEquals(1, s4.getTableRuleList().size());
|
|
assertEquals(1, s4.getTableRuleList().size());
|
|
|
assertEquals(22, s5.getTemplateId());
|
|
assertEquals(22, s5.getTemplateId());
|
|
@@ -195,10 +210,12 @@ class AiSmartCleanServiceTest {
|
|
|
verify(aiCleanService, times(1))
|
|
verify(aiCleanService, times(1))
|
|
|
.loadViaAiTemplate(any(), eq(rootB), eq(s6), any(AiJudgeResult.AiPlan.class));
|
|
.loadViaAiTemplate(any(), eq(rootB), eq(s6), any(AiJudgeResult.AiPlan.class));
|
|
|
|
|
|
|
|
- // 时序:三条判定全归零 → 才允许 doClean
|
|
|
|
|
|
|
+ // 时序:三条判定全归零 → 1级 → 2级 → 才允许 3级入库
|
|
|
InOrder order = inOrder(aiCleanService, dmService);
|
|
InOrder order = inOrder(aiCleanService, dmService);
|
|
|
order.verify(aiCleanService, times(3)).judge(any(AiCleanJob.class));
|
|
order.verify(aiCleanService, times(3)).judge(any(AiCleanJob.class));
|
|
|
- order.verify(dmService).doClean(any());
|
|
|
|
|
|
|
+ order.verify(dmService).doClean(any(DoCleanDTO.class), eq(true));
|
|
|
|
|
+ order.verify(dmService).doClean(any(DoCleanDTO.class), eq(false));
|
|
|
|
|
+ order.verify(aiCleanService).loadViaAiTemplate(any(), eq(rootB), eq(s6), any(AiJudgeResult.AiPlan.class));
|
|
|
assertEquals(List.of(AiCleanStageEnum.JUDGING, AiCleanStageEnum.CLEANING_EXISTING,
|
|
assertEquals(List.of(AiCleanStageEnum.JUDGING, AiCleanStageEnum.CLEANING_EXISTING,
|
|
|
AiCleanStageEnum.LOADING_AI, AiCleanStageEnum.DONE), stages());
|
|
AiCleanStageEnum.LOADING_AI, AiCleanStageEnum.DONE), stages());
|
|
|
assertTrue(sqlSet().stream().anyMatch(s -> s.contains("existing_hit_count")), "AI 命中要计数");
|
|
assertTrue(sqlSet().stream().anyMatch(s -> s.contains("existing_hit_count")), "AI 命中要计数");
|
|
@@ -220,27 +237,28 @@ class AiSmartCleanServiceTest {
|
|
|
// ==================== ② A 组为空 ====================
|
|
// ==================== ② A 组为空 ====================
|
|
|
|
|
|
|
|
@Test
|
|
@Test
|
|
|
- @DisplayName("A 组为空(没人匹配上模板)→ doClean 零调用,不白关 SSE")
|
|
|
|
|
|
|
+ @DisplayName("1、2级都为空(没人匹配上模板)→ doClean 零调用,不白关 SSE")
|
|
|
void skipDoCleanWhenExistingGroupEmpty() {
|
|
void skipDoCleanWhenExistingGroupEmpty() {
|
|
|
FileInfo s1 = sheet(1, "s1", null), s2 = sheet(2, "s2", null);
|
|
FileInfo s1 = sheet(1, "s1", null), s2 = sheet(2, "s2", null);
|
|
|
given(Map.of(1L, new AiJudgeResult.NeedReview("结构探测不出表头"), 2L, aiPlan()));
|
|
given(Map.of(1L, new AiJudgeResult.NeedReview("结构探测不出表头"), 2L, aiPlan()));
|
|
|
|
|
|
|
|
service.run(9L, 1L, List.of(file(100L, 9L, s1, s2)));
|
|
service.run(9L, 1L, List.of(file(100L, 9L, s1, s2)));
|
|
|
|
|
|
|
|
- verify(dmService, never()).doClean(any());
|
|
|
|
|
|
|
+ verify(dmService, never()).doClean(any(DoCleanDTO.class), anyBoolean());
|
|
|
// 出口 B 照旧入库,不受 A 组为空影响
|
|
// 出口 B 照旧入库,不受 A 组为空影响
|
|
|
verify(aiCleanService, times(1)).loadViaAiTemplate(any(), any(), eq(s2), any());
|
|
verify(aiCleanService, times(1)).loadViaAiTemplate(any(), any(), eq(s2), any());
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@Test
|
|
@Test
|
|
|
- @DisplayName("全是既有匹配(AI 一条都不接)→ 仍然只发一次 doClean,且不碰模型")
|
|
|
|
|
|
|
+ @DisplayName("只有 1级表(AI 一条都不接)→ 只发一趟非静默 doClean,由它收口会话")
|
|
|
void allExistingStillOneDoCleanAndNoJudge() {
|
|
void allExistingStillOneDoCleanAndNoJudge() {
|
|
|
FileInfo s1 = sheet(1, "s1", 11), s2 = sheet(2, "s2", 12);
|
|
FileInfo s1 = sheet(1, "s1", 11), s2 = sheet(2, "s2", 12);
|
|
|
given(Map.of());
|
|
given(Map.of());
|
|
|
|
|
|
|
|
service.run(9L, 1L, List.of(file(100L, 9L, s1, s2)));
|
|
service.run(9L, 1L, List.of(file(100L, 9L, s1, s2)));
|
|
|
|
|
|
|
|
- verify(dmService, times(1)).doClean(any());
|
|
|
|
|
|
|
+ verify(dmService, times(1)).doClean(any(DoCleanDTO.class), eq(false));
|
|
|
|
|
+ verify(dmService, never()).doClean(any(DoCleanDTO.class), eq(true));
|
|
|
verify(aiCleanService, never()).prepareJob(anyLong(), any(), any());
|
|
verify(aiCleanService, never()).prepareJob(anyLong(), any(), any());
|
|
|
verify(aiCleanService, never()).judge(any(AiCleanJob.class));
|
|
verify(aiCleanService, never()).judge(any(AiCleanJob.class));
|
|
|
}
|
|
}
|
|
@@ -341,7 +359,7 @@ class AiSmartCleanServiceTest {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@Test
|
|
@Test
|
|
|
- @DisplayName("首次受理:建批次 + 后台一轮跑完,全程一次 doClean")
|
|
|
|
|
|
|
+ @DisplayName("首次受理:建批次 + 后台一轮跑完,1级/2级按级串行两趟 doClean")
|
|
|
void firstSubmitBuildsBatchAndRunsOnce() {
|
|
void firstSubmitBuildsBatchAndRunsOnce() {
|
|
|
when(batchMapper.selectOne(any(Wrapper.class))).thenReturn(null);
|
|
when(batchMapper.selectOne(any(Wrapper.class))).thenReturn(null);
|
|
|
given(Map.of(4L, hit(4, 21), 5L, new AiJudgeResult.NeedReview("置信度不足")));
|
|
given(Map.of(4L, hit(4, 21), 5L, new AiJudgeResult.NeedReview("置信度不足")));
|
|
@@ -356,7 +374,9 @@ class AiSmartCleanServiceTest {
|
|
|
verify(batchMapper).insert(any(AiCleanBatch.class));
|
|
verify(batchMapper).insert(any(AiCleanBatch.class));
|
|
|
|
|
|
|
|
s.body.run(); // 同步跑后台那一轮
|
|
s.body.run(); // 同步跑后台那一轮
|
|
|
- verify(dmService, times(1)).doClean(any());
|
|
|
|
|
|
|
+ // 1级(s1)静默一趟 → 2级(s4)收口一趟;待人工的 s2 两趟都不进
|
|
|
|
|
+ verify(dmService, times(1)).doClean(any(DoCleanDTO.class), eq(true));
|
|
|
|
|
+ verify(dmService, times(1)).doClean(any(DoCleanDTO.class), eq(false));
|
|
|
verify(aiCleanService, times(2)).judge(any(AiCleanJob.class));
|
|
verify(aiCleanService, times(2)).judge(any(AiCleanJob.class));
|
|
|
}
|
|
}
|
|
|
|
|
|