|
|
@@ -0,0 +1,118 @@
|
|
|
+package com.zsjz.ai.module.aiclean.match;
|
|
|
+
|
|
|
+import com.zsjz.ai.common.cache.GlobalCache;
|
|
|
+import com.zsjz.ai.module.aiclean.model.RuleBind;
|
|
|
+import com.zsjz.ai.module.aiclean.model.TemplateCandidate;
|
|
|
+import org.junit.jupiter.api.AfterEach;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 匹配规则边界严格测试(严格测试计划 A 类补充):
|
|
|
+ * 空表头、重复列、必填相似覆盖、无关表头、短词边界。
|
|
|
+ *
|
|
|
+ * @author cc
|
|
|
+ * @since 2026/10/25
|
|
|
+ */
|
|
|
+class AiTemplateMatchEdgeTest {
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ GlobalCache.clearForTesting();
|
|
|
+ }
|
|
|
+
|
|
|
+ @AfterEach
|
|
|
+ void tearDown() {
|
|
|
+ GlobalCache.clearForTesting();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static TemplateCandidate.FieldBrief field(String cn, String en, String type, boolean required) {
|
|
|
+ return new TemplateCandidate.FieldBrief(cn, en, type, required, 0, null);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static TemplateCandidate cand(List<String> headers, List<TemplateCandidate.FieldBrief> fields) {
|
|
|
+ return new TemplateCandidate(1, false, 77, null, 2, "测试模板", "t", headers, fields, 0d);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("A4 空表头/空模板:score=0 且不抛异常")
|
|
|
+ void emptyHeadersScoreZero() {
|
|
|
+ TemplateCandidate c = cand(List.of("交易日期", "金额"), List.of(field("交易日期", "d", "DATE", false)));
|
|
|
+ assertEquals(0d, HeaderScorer.score(List.of(), c));
|
|
|
+ assertEquals(0d, HeaderScorer.score(null, c));
|
|
|
+ assertEquals(0d, HeaderScorer.score(List.of("交易日期", "金额"),
|
|
|
+ cand(List.of(), List.of(field("交易日期", "d", "DATE", false)))));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("A5 重复列不膨胀 Jaccard:文件重复列与去重后同分")
|
|
|
+ void duplicateColumnsDoNotInflateScore() {
|
|
|
+ TemplateCandidate c = cand(List.of("交易日期", "金额"), List.of(
|
|
|
+ field("交易日期", "d", "DATE", false),
|
|
|
+ field("金额", "m", "MONEY", false)));
|
|
|
+ double withDup = HeaderScorer.score(List.of("交易日期", "交易日期", "金额"), c);
|
|
|
+ double deduped = HeaderScorer.score(List.of("交易日期", "金额"), c);
|
|
|
+ assertEquals(deduped, withDup, 1e-9, "重复列不得撑大分母拉低分数");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("A9 必填覆盖全量参与:required 字段靠相似命中也算覆盖,允许直通")
|
|
|
+ void requiredCoveredBySimilarWord() {
|
|
|
+ // 无词条:文件「通信类别」对必填字段「通信类型」是等长距 1 的相似命中
|
|
|
+ List<String> file = List.of("通信类别");
|
|
|
+ TemplateCandidate c = cand(List.of("通信类型"), List.of(field("通信类型", "commType", "TEXT", true)));
|
|
|
+ assertTrue(HeaderScorer.requiredCovered(file, c), "激进策略下相似命中应覆盖必填");
|
|
|
+ double score = HeaderScorer.score(file, c);
|
|
|
+ assertTrue(HeaderScorer.passesStrong(file, c, score, List.of()),
|
|
|
+ "相似必填覆盖 + 单列模板应允许直通,实际 " + score);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("A12 完全无关表头:低分不误命中")
|
|
|
+ void unrelatedHeadersScoreLow() {
|
|
|
+ List<String> file = List.of("姓名", "手机号", "住址");
|
|
|
+ TemplateCandidate c = cand(List.of("交易日期", "金额", "账号"), List.of(
|
|
|
+ field("交易日期", "transDate", "DATE", false),
|
|
|
+ field("金额", "transAmount", "MONEY", false),
|
|
|
+ field("账号", "accountNo", "ACCT_CARD_NO", false)));
|
|
|
+ double score = HeaderScorer.score(file, c);
|
|
|
+ assertTrue(score < HeaderScorer.WEAK, "无关表头不得过弱匹配线,实际 " + score);
|
|
|
+ assertFalse(HeaderScorer.passesStrong(file, c, score, List.of()));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("A13 短词边界:单字词、2 字等长距 1 不触发模糊")
|
|
|
+ void shortWordBoundary() {
|
|
|
+ // 卡(1 字) 与 卡号:包含规则要求短词 ≥2、等长规则要求 ≥3 → MISS
|
|
|
+ double single = HeaderScorer.score(List.of("卡"),
|
|
|
+ cand(List.of("卡号"), List.of(field("卡号", "cardNo", "ACCT_CARD_NO", false))));
|
|
|
+ assertTrue(single < HeaderScorer.WEAK, "单字词不得模糊命中,实际 " + single);
|
|
|
+ // 姓名/名称:2 字等长距 1 → 低于等长规则 3 字下限
|
|
|
+ double name = HeaderScorer.score(List.of("名称"),
|
|
|
+ cand(List.of("姓名"), List.of(field("姓名", "name", "TEXT", false))));
|
|
|
+ assertTrue(name < HeaderScorer.WEAK, "2 字距 1 不得模糊命中(防大面积误伤),实际 " + name);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("B4 同义词词条让绑定名 EXACT 命中并改写(通信类别→通信类型)")
|
|
|
+ void filterBindsExactViaSynonymEntry() {
|
|
|
+ GlobalCache.setSynonymsForTesting(Map.of("通信类别", "通信类型"));
|
|
|
+ var cand = new TemplateCandidate(1, false, 77, null, 2,
|
|
|
+ "通信记录", "comm_record", List.of("通信类型"),
|
|
|
+ List.of(new TemplateCandidate.FieldBrief("通信类型", "commType", "TEXT", false, 0, null)), 0.9);
|
|
|
+ RuleBind bind = new RuleBind();
|
|
|
+ bind.setFileColIndex(0);
|
|
|
+ bind.setFieldNameEn("通信类别");
|
|
|
+ List<RuleBind> kept = AiTemplateMatcher.filterBinds(List.of(bind), cand, 1);
|
|
|
+ assertEquals(1, kept.size(), "词条归一后应 EXACT 放行(而非相似解析)");
|
|
|
+ assertEquals("commType", kept.getFirst().getFieldNameEn());
|
|
|
+ }
|
|
|
+}
|