|
|
@@ -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;
|
|
|
+ }
|
|
|
+}
|