|
|
@@ -0,0 +1,263 @@
|
|
|
+package com.etotem.cfc.unit;
|
|
|
+
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+
|
|
|
+import java.lang.reflect.Field;
|
|
|
+import java.lang.reflect.Method;
|
|
|
+import java.lang.reflect.Modifier;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+import static org.junit.jupiter.api.Assertions.fail;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 契约级单测:guhe_v2 分页残段清洗下沉(能力键 {@code guhe.l1.pageTailClean})。
|
|
|
+ *
|
|
|
+ * <p><b>契约来源</b>(计划 2026-11-08-浠艾福平台程序修改计划.md §三-T12 / §一-L1 接入归一化 ② / §2.1):
|
|
|
+ * <blockquote>
|
|
|
+ * T12 分页残段清洗下沉(L1-2):Adapter 调用 {@code extract_guhe_v2()} <b>自动获得清洗</b>;
|
|
|
+ * <b>校验层断言</b>物种库条目不含 {@code \d{1,3}/16\d}。
|
|
|
+ * </blockquote>
|
|
|
+ * 输入基线 §2.1:分页残段清洗用 {@code _PAGE_TAIL} / {@code _strip_table_tail()} 根治
|
|
|
+ * 「与健康相关菌评估」分页表拼接(残段样例 {@code 34/167 0.0033 参考 0-0.1746% ND}),
|
|
|
+ * 个人层丰度不再混入知识层;清洗后 76 条、残段 0 命中。</p>
|
|
|
+ *
|
|
|
+ * <p><b>平台现状(src/main 探测,C 档 #1 Adapter 形态)</b>:L1 接入归一化层为
|
|
|
+ * <ul>
|
|
|
+ * <li>{@code PdfParseService}(文本解析,{@code reportFormat} 仅 {@code "beijing"} / {@code "guhe"},无版本维度,
|
|
|
+ * guhe 报告按 triplet/inline 解析,无分页残段清洗步骤)</li>
|
|
|
+ * <li>{@code ReportParserDispatchService#parseReport}(指纹检测 → 专用解析器 / 2b. LLM 兜底)</li>
|
|
|
+ * <li>{@code ReportFingerprintService#detect}(DB 指纹规则 → Python 引擎 → {@code typeId})</li>
|
|
|
+ * </ul>
|
|
|
+ * 全库无分页残段清洗入口、无 {@code _PAGE_TAIL} 残段标记常量、无 {@code \d{1,3}/16\d}
|
|
|
+ * 残段断言正则——本能力<b>实现未落地</b>。因此本测试以「反射结构契约」探测契约所需标记缺失:
|
|
|
+ * 能编译通过、能运行、断言失败(红),失败断言原文清晰可贴。</p>
|
|
|
+ *
|
|
|
+ * <p><b>GREEN 升级指引</b>:实现者在 L1 接入归一化层落地四件事后,本测试自动转绿——
|
|
|
+ * <ol>
|
|
|
+ * <li>{@code PdfParseService}(或分派层)提供分页残段清洗<b>执行入口</b>(方法名含
|
|
|
+ * {@code TableTailClean}/{@code PageTail}/{@code StripTail},如 {@code stripTableTail}——
|
|
|
+ * Python {@code _strip_table_tail()} 的 Java 下沉),在 guhe 报告解析时自动调用;</li>
|
|
|
+ * <li>提供分页残段清洗<b>校验断言入口</b>(方法名含 {@code AssertTailClean}/{@code ValidateTail}
|
|
|
+ * 等),断言清洗后产物「残段 0 命中」;</li>
|
|
|
+ * <li>声明<b>残段标记常量</b>(静态 String 常量,字段名/值含 {@code PAGE_TAIL} / 「分页」/「残段」,
|
|
|
+ * 值含残段样例 {@code 34/167});</li>
|
|
|
+ * <li>声明<b>残段断言正则</b>(静态 String 常量,值含 {@code \d{1,3}/16\d} 模式,
|
|
|
+ * 用于校验层断言物种库条目不含该模式)。</li>
|
|
|
+ * </ol>
|
|
|
+ * 届时可将结构探测升级为行为级验证(喂入含 {@code 34/167 0.0033 参考 0-0.1746% ND} 残段的文本,
|
|
|
+ * 断言清洗后物种库条目不含 {@code \d{1,3}/16\d}、清洗后 76 条)。</p>
|
|
|
+ *
|
|
|
+ * <p><b>与同批契约的边界</b>:{@code extract_guhe_v2} 提取器寻址(§2.1 Adapter 调用点)
|
|
|
+ * 由同批 {@code guhe.l1.shellGuard}(T13 空壳守卫)契约覆盖,本契约不重复探测;
|
|
|
+ * 本契约聚焦 T12 特有的「清洗入口 + 残段标记 + 残段断言正则」三面。</p>
|
|
|
+ *
|
|
|
+ * <p><b>权限流说明</b>:本能力为 L1 接入归一化层的内部解析/清洗管道(报告解析 → 残段清洗 →
|
|
|
+ * 校验断言),无用户权限维度,三流中的权限流不适用,以说明性用例标注。</p>
|
|
|
+ */
|
|
|
+class GuhePageTailCleanContractTest {
|
|
|
+
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+ // 契约锚点(计划 §三-T12 / L1-2 / §2.1)
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+
|
|
|
+ /** 能力键 */
|
|
|
+ private static final String CAP_KEY = "guhe.l1.pageTailClean";
|
|
|
+
|
|
|
+ /** 残段样例(§2.1:分页表拼接残段,如 34/167 ...) */
|
|
|
+ private static final String TAIL_SAMPLE = "34/167";
|
|
|
+
|
|
|
+ /** 残段断言正则语义片段(校验层断言物种库条目不含 \d{1,3}/16\d;Java 字符串中 \d 转义为 \\d) */
|
|
|
+ private static final String TAIL_REGEX_FRAGMENT = "/16\\d";
|
|
|
+
|
|
|
+ /** 清洗后条数(§2.1:清洗后 76 条)——GREEN 行为级验证锚点,结构探测阶段仅记录 */
|
|
|
+ private static final int CLEAN_COUNT_76 = 76;
|
|
|
+
|
|
|
+ // L1 接入归一化层现有类(src/main 现状探测;实现者在其上落地残段清洗)
|
|
|
+ private static final String PARSED_REPORT_RESULT = "com.etotem.cfc.dto.ParsedReportResult";
|
|
|
+ private static final String DISPATCH_SERVICE = "com.etotem.cfc.service.ReportParserDispatchService";
|
|
|
+ private static final String FINGERPRINT_SERVICE = "com.etotem.cfc.service.ReportFingerprintService";
|
|
|
+ private static final String PDF_PARSE_SERVICE = "com.etotem.cfc.service.PdfParseService";
|
|
|
+
|
|
|
+ /** 分页残段清洗「执行入口」方法名探针(大小写不敏感) */
|
|
|
+ private static final String[] CLEAN_ENTRY_FRAGMENTS = {
|
|
|
+ "cleantabletail", "striptabletail", "cleanpagetail", "strippagetail",
|
|
|
+ "tabletailclean", "pagetailclean", "removetail", "striptail"
|
|
|
+ };
|
|
|
+
|
|
|
+ /** 分页残段清洗「校验断言入口」方法名探针(大小写不敏感) */
|
|
|
+ private static final String[] ASSERT_ENTRY_FRAGMENTS = {
|
|
|
+ "asserttailclean", "assertnotail", "validatetail", "assertclean", "tailclean"
|
|
|
+ };
|
|
|
+
|
|
|
+ /** 残段标记常量名探针(大小写不敏感) */
|
|
|
+ private static final String[] TAIL_MARK_NAME_FRAGMENTS = {
|
|
|
+ "pagetail", "page_tail", "残段", "分页"
|
|
|
+ };
|
|
|
+
|
|
|
+ /** 残段断言正则常量名探针(大小写不敏感) */
|
|
|
+ private static final String[] TAIL_REGEX_NAME_FRAGMENTS = {
|
|
|
+ "tail", "残段", "regex", "pattern", "断言"
|
|
|
+ };
|
|
|
+
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+ // 正常流:分页残段清洗执行入口(Adapter 调用 extract_guhe_v2() 自动获得清洗)
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-1:L1 层应提供分页残段清洗执行入口(_strip_table_tail 的 Java 下沉)")
|
|
|
+ void l1Layer_providesTailCleanEntry() {
|
|
|
+ boolean hasEntry = anyClassHasMethodNamedLike(new String[]{PDF_PARSE_SERVICE, DISPATCH_SERVICE,
|
|
|
+ FINGERPRINT_SERVICE, PARSED_REPORT_RESULT}, CLEAN_ENTRY_FRAGMENTS);
|
|
|
+ assertTrue(hasEntry,
|
|
|
+ "[" + CAP_KEY + "] L1 接入归一化层(PdfParseService / ReportParserDispatchService / "
|
|
|
+ + "ReportFingerprintService / ParsedReportResult)应提供分页残段清洗执行入口"
|
|
|
+ + "(方法名含 TableTailClean / PageTail / StripTail,如 stripTableTail——"
|
|
|
+ + "Python _strip_table_tail() 的 Java 下沉),在 guhe 报告解析时自动调用(T12/L1-2)。"
|
|
|
+ + "当前 parseText 直接按 triplet/inline 解析,无分页残段清洗步骤,"
|
|
|
+ + "「与健康相关菌评估」分页表拼接残段将混入个人层丰度。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-2:L1 层应提供残段清洗校验断言入口(断言清洗后残段 0 命中)")
|
|
|
+ void l1Layer_providesTailCleanAssertEntry() {
|
|
|
+ boolean hasEntry = anyClassHasMethodNamedLike(new String[]{PDF_PARSE_SERVICE, DISPATCH_SERVICE,
|
|
|
+ FINGERPRINT_SERVICE, PARSED_REPORT_RESULT}, ASSERT_ENTRY_FRAGMENTS);
|
|
|
+ assertTrue(hasEntry,
|
|
|
+ "[" + CAP_KEY + "] L1 接入归一化层应提供残段清洗校验断言入口"
|
|
|
+ + "(方法名含 AssertTailClean / ValidateTail / AssertClean),"
|
|
|
+ + "断言清洗后产物「残段 0 命中」(§2.1 清洗后 76 条、残段 0 命中;T12/L1-2 校验层断言)。"
|
|
|
+ + "当前无残段断言步骤,无法验证清洗效果。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+ // 边界流:残段标记常量 _PAGE_TAIL
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("边界流-1:L1 层应声明 _PAGE_TAIL 残段标记常量(值含残段样例 34/167)")
|
|
|
+ void l1Layer_declaresPageTailMarker() {
|
|
|
+ boolean hasTailMark = anyClassHasStringConstantContaining(new String[]{PDF_PARSE_SERVICE,
|
|
|
+ DISPATCH_SERVICE, FINGERPRINT_SERVICE, PARSED_REPORT_RESULT},
|
|
|
+ new String[]{TAIL_SAMPLE, "/16"}, TAIL_MARK_NAME_FRAGMENTS);
|
|
|
+ assertTrue(hasTailMark,
|
|
|
+ "[" + CAP_KEY + "] L1 接入归一化层应声明 _PAGE_TAIL 残段标记常量"
|
|
|
+ + "(静态 String 常量,字段名/值含 PAGE_TAIL / 分页 / 残段,值含残段样例 "
|
|
|
+ + TAIL_SAMPLE + "),作为分页表拼接残段的识别依据(§2.1 / T12)。"
|
|
|
+ + "当前无残段标记常量,残段无法被识别与清洗。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("边界流-2:L1 层应声明残段断言正则 \\d{1,3}/16\\d(校验层断言物种库条目不含)")
|
|
|
+ void l1Layer_declaresTailAssertRegex() {
|
|
|
+ boolean hasRegex = anyClassHasStringConstantContaining(new String[]{PDF_PARSE_SERVICE,
|
|
|
+ DISPATCH_SERVICE, FINGERPRINT_SERVICE, PARSED_REPORT_RESULT},
|
|
|
+ new String[]{TAIL_REGEX_FRAGMENT}, TAIL_REGEX_NAME_FRAGMENTS);
|
|
|
+ assertTrue(hasRegex,
|
|
|
+ "[" + CAP_KEY + "] L1 接入归一化层应声明残段断言正则常量"
|
|
|
+ + "(静态 String 常量,值含 " + TAIL_REGEX_FRAGMENT + " 即 \\d{1,3}/16\\d 模式,"
|
|
|
+ + "用于校验层断言物种库条目不含 \\d{1,3}/16\\d,T12/L1-2)。"
|
|
|
+ + "当前无残段断言正则,无法对清洗结果做「残段 0 命中」的程序化校验。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+ // 结构契约:parseText 解析管线挂载点(已存在 → 说明性绿,GREEN 挂载点)
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("结构契约-1:PdfParseService.parseText 解析管线入口应存在(残段清洗挂载点)")
|
|
|
+ void pdfParseService_hasParseTextPipeline() {
|
|
|
+ Class<?> clazz = loadClass(PDF_PARSE_SERVICE);
|
|
|
+ if (clazz == null) {
|
|
|
+ fail("[" + CAP_KEY + "] PdfParseService 类不存在(L1 文本解析入口),"
|
|
|
+ + "无法验证 parseText 管线(GREEN 挂载点)。");
|
|
|
+ }
|
|
|
+ boolean found = false;
|
|
|
+ for (Method m : clazz.getDeclaredMethods()) {
|
|
|
+ if (m.getName().equals("parseText")) {
|
|
|
+ found = true;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ assertTrue(found,
|
|
|
+ "[" + CAP_KEY + "] PdfParseService 应提供 parseText 管线入口"
|
|
|
+ + "(T12 分页残段清洗挂载点;GREEN 时在解析前/后调用残段清洗 + 校验断言即可)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+ // 权限流(不适用):L1 内部解析/清洗管道,无用户权限维度
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("权限流:本能力为 L1 接入归一化内部解析/清洗管道,无用户权限维度,权限流不适用")
|
|
|
+ void permissionFlow_notApplicable() {
|
|
|
+ // 说明性用例:契约 AC 为「报告解析 → 残段清洗 → 校验断言」内部管道,无权限校验维度。
|
|
|
+ // 不产生断言失败;仅为三流覆盖标注「不适用」。
|
|
|
+ assertTrue(true, "权限流不适用:" + CAP_KEY + " 为 L1 内部解析/清洗管道,无用户权限维度。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+ // 反射辅助
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+
|
|
|
+ private Class<?> loadClass(String name) {
|
|
|
+ try {
|
|
|
+ return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
|
|
|
+ } catch (ClassNotFoundException e) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 任一候选类存在方法名(忽略大小写)包含任一片段 */
|
|
|
+ private boolean anyClassHasMethodNamedLike(String[] classNames, String[] fragments) {
|
|
|
+ for (String cn : classNames) {
|
|
|
+ Class<?> clazz = loadClass(cn);
|
|
|
+ if (clazz == null) continue;
|
|
|
+ for (Method m : clazz.getDeclaredMethods()) {
|
|
|
+ String name = m.getName().toLowerCase();
|
|
|
+ for (String frag : fragments) {
|
|
|
+ if (name.contains(frag)) return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 任一候选类存在静态 String 常量:值包含任一 valueFragment,且字段名(忽略大小写)
|
|
|
+ * 含任一 nameFragment —— 残段标记 / 残段断言正则常量探针。
|
|
|
+ * 收紧理由:PdfParseService 存在多个无关的 KNOWN_* 静态列表(元素含大量中文字符串),
|
|
|
+ * 仅按值匹配会误绿;常量字段名需带残段/断言语义以锚定「分页残段」契约(T12)。
|
|
|
+ */
|
|
|
+ private boolean anyClassHasStringConstantContaining(String[] classNames, String[] valueFragments,
|
|
|
+ String[] nameFragments) {
|
|
|
+ for (String cn : classNames) {
|
|
|
+ Class<?> clazz = loadClass(cn);
|
|
|
+ if (clazz == null) continue;
|
|
|
+ for (Field f : clazz.getDeclaredFields()) {
|
|
|
+ if (f.getType() != String.class) continue;
|
|
|
+ if (!Modifier.isStatic(f.getModifiers()) || !Modifier.isFinal(f.getModifiers())) continue;
|
|
|
+ String name = f.getName().toLowerCase();
|
|
|
+ boolean nameMatch = false;
|
|
|
+ for (String nf : nameFragments) {
|
|
|
+ if (name.contains(nf.toLowerCase())) {
|
|
|
+ nameMatch = true;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!nameMatch) continue;
|
|
|
+ try {
|
|
|
+ f.setAccessible(true);
|
|
|
+ Object v = f.get(null);
|
|
|
+ if (!(v instanceof String)) continue;
|
|
|
+ for (String vf : valueFragments) {
|
|
|
+ if (((String) v).contains(vf)) return true;
|
|
|
+ }
|
|
|
+ } catch (Exception ignored) {
|
|
|
+ // 常量读取失败 → 跳过
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+}
|