Просмотр исходного кода

test(guhe.l1.versionSplit): S-cfc-84 add RED contract tests for report format version split (T8/L1-3 old 21-key vs guhe_v2 32-key)

Sisyphus Agent 4 дней назад
Родитель
Сommit
53502b0428

+ 230 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/GuheVersionSplitContractTest.java

@@ -0,0 +1,230 @@
+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.versionSplit})。
+ *
+ * <p><b>契约来源</b>(计划 2026-11-08-浠艾福平台程序修改计划.md §三-T8 / §一-L1 接入归一化 ③ / §2.1):
+ * <blockquote>
+ *   T8 新旧分流(L1-3):按 {@code 报告格式版本} 分流;旧 21 键走原 v5 流程;
+ *   <b>禁把 32 键误判成路由错误</b>、<b>禁把旧 21 键强套 guhe_v2</b>。
+ * </blockquote>
+ * 输入基线 §2.1:guhe_v2 新版标准报告 = 旧 21 键打底 + 11 增量键 = <b>32 键</b>;
+ * 判定顺序 {@code aidefansi → beijing → guhe_v2 → standard},旧三类路由不变。</p>
+ *
+ * <p><b>平台现状(src/main 探测,C 档 #1 Adapter 形态)</b>:L1 接入归一化层为
+ * <ul>
+ *   <li>{@code PdfParseService}(文本解析,{@code reportFormat} 仅取值 {@code "beijing"} / {@code "guhe"},无版本维度)</li>
+ *   <li>{@code ReportParserDispatchService#parseReport}(指纹检测 → 专用解析器 / 2b. LLM 兜底)</li>
+ *   <li>{@code ReportFingerprintService#detect}(DB 指纹规则 → Python 引擎 → {@code typeId})</li>
+ * </ul>
+ * 全库无 {@code guhe_v2}、无 21/32 键边界标记、无按格式版本分流入口——本能力<b>实现未落地</b>。
+ * 因此本测试以「反射结构契约」探测契约所需标记缺失:能编译、能运行、断言失败(红),失败原文清晰可贴。</p>
+ *
+ * <p><b>GREEN 升级指引</b>:实现者在 L1 接入归一化层落地三件事后,本测试自动转绿——
+ * <ol>
+ *   <li>{@code ParsedReportResult} 暴露 {@code guhe_v2} 格式版本标记(静态字符串常量值为 {@code "guhe_v2"}),
+ *       使新版 32 键格式与旧 {@code beijing}/{@code guhe} 可区分;</li>
+ *   <li>{@code ReportParserDispatchService} 增加按报告格式版本分流入口(方法名含 {@code FormatVersion}/{@code VersionSplit}),
+ *       32 键 guhe_v2 报告走专用分支,<b>不落入</b> unknown→LLM 兜底(路由错误);</li>
+ *   <li>L1 层声明 21/32 键数边界(静态 int 常量 {@code 21} 与 {@code 32} 并存),旧 21 键走原 v5 流程,
+ *       <b>不被</b> guhe_v2 提取器强套。</li>
+ * </ol>
+ * 届时可将结构探测升级为行为级验证(mock 分派服务断言 32 键→guhe_v2、21 键→legacy)。</p>
+ *
+ * <p><b>权限流说明</b>:本能力为 L1 接入归一化层的内部解析/路由管道(报告上传 → 格式版本 → 提取器分派),
+ * 无用户权限维度,「权限(适用时)」三流中的权限流不适用,以说明性用例标注。</p>
+ */
+class GuheVersionSplitContractTest {
+
+    // ------------------------------------------------------------------
+    // 契约锚点(计划 §三-T8 / L1-3 / §2.1)
+    // ------------------------------------------------------------------
+
+    /** 新版 32 键 guhe_v2 格式标记(§2.1:判定链 aidefansi→beijing→guhe_v2→standard) */
+    private static final String FORMAT_GUHE_V2 = "guhe_v2";
+
+    /** guhe_v2 专用解析器脚本名(L1-3 分流后 32 键应走此提取器,非 LLM 兜底) */
+    private static final String GUHE_V2_PARSER_SCRIPT = "extract_guhe_v2";
+
+    /** 旧版 21 键打底 + 11 增量键 = 32 键(§2.1) */
+    private static final int LEGACY_KEYS_21 = 21;
+    private static final int GUHE_V2_KEYS_32 = 32;
+
+    // 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";
+
+    // ------------------------------------------------------------------
+    // 正常流:格式版本分流标记(报告格式版本可区分)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-1:ParsedReportResult 应暴露 guhe_v2 格式版本标记")
+    void parsedReportResult_exposesGuheV2FormatMarker() {
+        Class<?> clazz = loadClass(PARSED_REPORT_RESULT);
+        if (clazz == null) {
+            fail("[guhe.l1.versionSplit] ParsedReportResult 类不存在(L1 产出 DTO),"
+                    + "无法验证格式版本标记(T8/L1-3)。");
+        }
+        boolean hasMarker = hasStringConstant(clazz, FORMAT_GUHE_V2);
+        assertTrue(hasMarker,
+                "[guhe.l1.versionSplit] ParsedReportResult 应暴露 guhe_v2 格式版本标记"
+                        + "(静态字符串常量值为 \"guhe_v2\"),使新版 32 键格式与旧 beijing/guhe 可区分"
+                        + "(§2.1 判定链 aidefansi→beijing→guhe_v2→standard)。当前实现仅 beijing/guhe,"
+                        + "格式版本分流未落地。");
+    }
+
+    // ------------------------------------------------------------------
+    // 正常流:按报告格式版本分流入口(L1 分派层)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-2:ReportParserDispatchService 应提供按报告格式版本分流入口")
+    void dispatchService_providesFormatVersionSplitEntry() {
+        Class<?> clazz = loadClass(DISPATCH_SERVICE);
+        if (clazz == null) {
+            fail("[guhe.l1.versionSplit] ReportParserDispatchService 类不存在(L1 分派入口),"
+                    + "无法验证版本分流入口(T8/L1-3)。");
+        }
+        boolean hasSplitEntry = hasMethodNamedLike(clazz, "FormatVersion") || hasMethodNamedLike(clazz, "VersionSplit");
+        assertTrue(hasSplitEntry,
+                "[guhe.l1.versionSplit] ReportParserDispatchService 应提供按报告格式版本分流入口"
+                        + "(方法名含 FormatVersion / VersionSplit,如 routeByFormatVersion),"
+                        + "按『报告格式版本』分流:32 键→guhe_v2 提取器、旧 21 键→原 v5 流程(T8/L1-3)。"
+                        + "当前 parseReport 仅做 指纹→专用解析器/LLM 兜底,无版本分流步骤。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:32 键不误判成路由错误(不落 unknown→LLM 兜底)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-1:L1 层应寻址 guhe_v2 专用解析器(32 键不误判路由错误)")
+    void l1Layer_addressesGuheV2DedicatedParser() {
+        boolean addressed = hasStringConstantContaining(loadClass(DISPATCH_SERVICE), GUHE_V2_PARSER_SCRIPT)
+                || hasStringConstantContaining(loadClass(FINGERPRINT_SERVICE), GUHE_V2_PARSER_SCRIPT);
+        assertTrue(addressed,
+                "[guhe.l1.versionSplit] L1 层(ReportParserDispatchService / ReportFingerprintService)"
+                        + "应寻址 guhe_v2 专用解析器(字符串常量值含 \"extract_guhe_v2\"),"
+                        + "使 32 键 guhe_v2 报告进入专用解析分支,**不落入** unknown→LLM 兜底"
+                        + "(T8 禁把 32 键误判成路由错误)。当前 guhe_v2 路由未落地,32 键报告将被误判。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:旧 21 键不强套 guhe_v2(保留原 v5 流程)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-2:L1 层应声明 21/32 键数边界(旧 21 键不强套 guhe_v2)")
+    void l1Layer_declaresLegacy21KeyBoundary() {
+        boolean has21 = hasIntConstant(loadClass(PARSED_REPORT_RESULT), LEGACY_KEYS_21)
+                || hasIntConstant(loadClass(DISPATCH_SERVICE), LEGACY_KEYS_21)
+                || hasIntConstant(loadClass(FINGERPRINT_SERVICE), LEGACY_KEYS_21)
+                || hasIntConstant(loadClass(PDF_PARSE_SERVICE), LEGACY_KEYS_21);
+        boolean has32 = hasIntConstant(loadClass(PARSED_REPORT_RESULT), GUHE_V2_KEYS_32)
+                || hasIntConstant(loadClass(DISPATCH_SERVICE), GUHE_V2_KEYS_32)
+                || hasIntConstant(loadClass(FINGERPRINT_SERVICE), GUHE_V2_KEYS_32)
+                || hasIntConstant(loadClass(PDF_PARSE_SERVICE), GUHE_V2_KEYS_32);
+        assertTrue(has21 && has32,
+                "[guhe.l1.versionSplit] L1 层应声明 21/32 键数边界(静态 int 常量 21 与 32 并存),"
+                        + "以区分旧 21 键与原 v5 流程、32 键走 guhe_v2(§2.1 旧 21 键 + 11 增量 = 32 键)。"
+                        + "当前无键数边界标记,旧 21 键无法与 guhe_v2 隔离,存在被强套 guhe_v2 风险"
+                        + "(T8 禁把旧 21 键强套 guhe_v2)。");
+    }
+
+    // ------------------------------------------------------------------
+    // 权限流(不适用):L1 内部解析/路由管道,无用户权限维度
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("权限流:本能力为 L1 接入归一化内部解析/路由管道,无用户权限维度,权限流不适用")
+    void permissionFlow_notApplicable() {
+        // 说明性用例:契约 AC 为「报告上传 → 格式版本 → 提取器分派」内部管道,无权限校验维度。
+        // 不产生断言失败;仅为三流覆盖标注「不适用」。
+        assertTrue(true, "权限流不适用:guhe.l1.versionSplit 为 L1 内部解析/路由管道,无用户权限维度。");
+    }
+
+    // ------------------------------------------------------------------
+    // 反射辅助
+    // ------------------------------------------------------------------
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    /** 是否存在静态 String 常量值等于 target(格式版本标记探针) */
+    private boolean hasStringConstant(Class<?> clazz, String target) {
+        if (clazz == null) return false;
+        for (Field f : clazz.getDeclaredFields()) {
+            if (f.getType() != String.class) continue;
+            if (!Modifier.isStatic(f.getModifiers()) || !Modifier.isFinal(f.getModifiers())) continue;
+            try {
+                f.setAccessible(true);
+                if (target.equals(f.get(null))) return true;
+            } catch (Exception ignored) {
+                // 常量读取失败 → 跳过
+            }
+        }
+        return false;
+    }
+
+    /** 是否存在静态 String 常量值包含 target(解析器寻址探针) */
+    private boolean hasStringConstantContaining(Class<?> clazz, String target) {
+        if (clazz == null) return false;
+        for (Field f : clazz.getDeclaredFields()) {
+            if (f.getType() != String.class) continue;
+            if (!Modifier.isStatic(f.getModifiers()) || !Modifier.isFinal(f.getModifiers())) continue;
+            try {
+                f.setAccessible(true);
+                Object v = f.get(null);
+                if (v instanceof String && ((String) v).contains(target)) return true;
+            } catch (Exception ignored) {
+                // 常量读取失败 → 跳过
+            }
+        }
+        return false;
+    }
+
+    /** 是否存在静态 int 常量值等于 target(键数边界探针) */
+    private boolean hasIntConstant(Class<?> clazz, int target) {
+        if (clazz == null) return false;
+        for (Field f : clazz.getDeclaredFields()) {
+            if (f.getType() != int.class && f.getType() != Integer.class) continue;
+            if (!Modifier.isStatic(f.getModifiers()) || !Modifier.isFinal(f.getModifiers())) continue;
+            try {
+                f.setAccessible(true);
+                Object v = f.get(null);
+                if (v instanceof Integer && ((Integer) v) == target) return true;
+            } catch (Exception ignored) {
+                // 常量读取失败 → 跳过
+            }
+        }
+        return false;
+    }
+
+    /** 是否存在方法名(忽略大小写)包含片段 */
+    private boolean hasMethodNamedLike(Class<?> clazz, String fragment) {
+        if (clazz == null) return false;
+        String needle = fragment.toLowerCase();
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().toLowerCase().contains(needle)) return true;
+        }
+        return false;
+    }
+}