Kaynağa Gözat

merge(S-cfc-94): guhe.kb.queryLowSidePatch 低侧判读补丁块查询 RED 契约测试 - 合入为并发推送后的 cfclub

Sisyphus Agent 4 gün önce
ebeveyn
işleme
b063f49c96

+ 262 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/GuheKbQueryLowSidePatchContractTest.java

@@ -0,0 +1,262 @@
+package com.etotem.cfc.unit;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.io.InputStream;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+import java.util.Scanner;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * 契约级单测:谷禾知识库「低侧判读补充」补丁块查询(能力键 {@code guhe.kb.queryLowSidePatch})。
+ *
+ * <p><b>需求/设计锚点(2026-11-08 浠艾福平台程序修改计划)</b></p>
+ * <ul>
+ *   <li>需求:§2.2 知识库 {@code +guhe2026.2} —— 新增子键
+ *       {@code 条件致病菌低侧判读补充(人工)}:判读铁律(U 型双向 / 参考下限 0 / 16S 粒度 /
+ *       不推荐补菌 / 关联非因果)+ 大肠杆菌偏低侧四机制 + 报告原文口径引用(个人值剥离)。</li>
+ *   <li>设计:§三 T1(L2-2)—— {@code 条件致病菌低侧判读补充(人工)} 作为<b>独立可查询子键</b>
+ *       (判读铁律 / 四机制 / 报告口径),供 L4 归因引擎与合规门禁直接取用。</li>
+ * </ul>
+ *
+ * <p><b>被校验的契约行为(GREEN 必须满足)</b></p>
+ * <ul>
+ *   <li>KB 服务 {@code UnifiedKnowledgeBaseService} 暴露 {@code queryLowSidePatch()},
+ *       返回 {@code 条件致病菌低侧判读补充(人工)} 补丁块(Map),供 L4/门禁直接取用。</li>
+ *   <li>classpath 种子 {@code /knowledge-base/统一知识库.json} 含 {@code 谷禾2026新版扩展知识}
+ *       扩展块,其下含 {@code 条件致病菌低侧判读补充(人工)} 子键。</li>
+ *   <li>补丁块内容覆盖:判读铁律五项(U 型双向 / 参考下限 0 / 16S 粒度 / 不推荐补菌 / 关联非因果)、
+ *       大肠杆菌偏低侧四机制、报告原文口径引用(个人值剥离)。</li>
+ * </ul>
+ *
+ * <p><b>平台现状(C 档待确认项自主探测)</b></p>
+ * <ul>
+ *   <li>KB 服务加载方式(待确认 #2):{@code UnifiedKnowledgeBaseService} ——
+ *       DB({@code knowledge_base_section})为查询源,classpath {@code 统一知识库.json} 种子兜底,
+ *       12 个顶层模块。故契约锚定该类 + 该种子文件。</li>
+ *   <li>低侧补丁块现状:种子文件与 {@code UnifiedKnowledgeBaseService} 中<b>均无</b>
+ *       {@code 条件致病菌低侧判读补充(人工)} / {@code queryLowSidePatch},实现未落地。</li>
+ * </ul>
+ *
+ * <p><b>测试范围</b>:纯契约级单测,无 Spring / 无 DB / 无外部服务依赖。行为契约经反射锚定
+ * 宿主类(避免 import 不存在方法导致编译失败),结构契约读 classpath 种子 JSON。</p>
+ *
+ * <p><b>RED 预期</b>:宿主 {@code UnifiedKnowledgeBaseService} 无 {@code queryLowSidePatch()}
+ * 方法、种子无 {@code 谷禾2026新版扩展知识}/{@code 条件致病菌低侧判读补充(人工)},
+ * 全部契约用例失败(红),断言原文为「RED[guhe.kb.queryLowSidePatch] …」。</p>
+ *
+ * <p><b>权限流说明</b>:本能力为后端内部知识库查询(L2 事实层供 L4/门禁取用),无用户权限维度,
+ * 「权限(适用时)」三流中的权限流不适用,以说明性用例标注。</p>
+ */
+class GuheKbQueryLowSidePatchContractTest {
+
+    /** 契约宿主:统一知识库服务(平台现状探测:DB 查询源 + classpath 种子兜底)。 */
+    private static final String HOST_CLASS = "com.etotem.cfc.service.UnifiedKnowledgeBaseService";
+
+    /** classpath 种子:统一知识库.json(KB 加载方式现状:UnifiedKnowledgeBaseService 读该文件)。 */
+    private static final String SEED_CLASSPATH = "/knowledge-base/统一知识库.json";
+
+    /** §2.2 扩展块顶层键(+guhe2026.2,11 子键)。 */
+    private static final String EXTENSION_KEY = "谷禾2026新版扩展知识";
+
+    /** §2.2 新增子键:低侧判读补充(人工)补丁块。 */
+    private static final String PATCH_KEY = "条件致病菌低侧判读补充(人工)";
+
+    /** §2.2 判读铁律(含空格变体,宽松匹配)。 */
+    private static final String[] IRON_RULES = {
+            "判读铁律", "U型双向", "U 型双向", "参考下限", "16S", "不推荐补菌", "关联非因果"
+    };
+
+    /** §2.2 大肠杆菌偏低侧四机制 + 报告原文口径引用(个人值剥离)。 */
+    private static final String[] PATCH_CONTENT = {
+            "大肠杆菌", "机制", "报告原文", "个人值剥离"
+    };
+
+    // ==================== 正常流:KB 服务应暴露 queryLowSidePatch 契约方法 ====================
+
+    @Test
+    @DisplayName("正常流-1:契约宿主 UnifiedKnowledgeBaseService 应存在")
+    void hostService_classExists() {
+        assertNotNull(loadClass(HOST_CLASS),
+                "[guhe.kb.queryLowSidePatch] 契约宿主 UnifiedKnowledgeBaseService 不存在:"
+                        + "L2-2 要求 KB 服务独立暴露低侧判读补丁块查询。");
+    }
+
+    @Test
+    @DisplayName("正常流-2:UnifiedKnowledgeBaseService 应暴露 queryLowSidePatch() 契约方法")
+    void hostService_exposesQueryLowSidePatch() {
+        Class<?> clazz = requireHostClass();
+        boolean found = false;
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().equals("queryLowSidePatch") && m.getParameterCount() == 0) {
+                found = true;
+                break;
+            }
+        }
+        assertTrue(found,
+                "RED[guhe.kb.queryLowSidePatch] UnifiedKnowledgeBaseService.queryLowSidePatch() "
+                        + "未实现:契约要求低侧判读补丁块作为独立可查询子键(§三 T1/L2-2)。");
+    }
+
+    @Test
+    @DisplayName("正常流-3:queryLowSidePatch() 应返回非空补丁块")
+    void queryLowSidePatch_returnsNonEmptyPatchBlock() {
+        Object block = queryLowSidePatch();
+        assertNotNull(block,
+                "[guhe.kb.queryLowSidePatch] queryLowSidePatch() 返回 null:"
+                        + "应返回条件致病菌低侧判读补充(人工)补丁块。");
+        assertTrue(flattenToString(block).length() > 0,
+                "[guhe.kb.queryLowSidePatch] queryLowSidePatch() 返回空块,无判读口径内容。");
+    }
+
+    @Test
+    @DisplayName("正常流-4:补丁块应含判读铁律五项(U型双向/参考下限0/16S粒度/不推荐补菌/关联非因果)")
+    void queryLowSidePatch_containsJudgmentIronRules() {
+        Object block = queryLowSidePatch();
+        String flat = flattenToString(block);
+        for (String rule : IRON_RULES) {
+            assertTrue(flat.contains(rule),
+                    "RED[guhe.kb.queryLowSidePatch] 补丁块缺少判读铁律「" + rule
+                            + "」:§2.2 要求判读铁律(U 型双向 / 参考下限 0 / 16S 粒度 / "
+                            + "不推荐补菌 / 关联非因果)。");
+        }
+    }
+
+    // ==================== 边界流:classpath 种子应含低侧补丁块 ====================
+
+    @Test
+    @DisplayName("边界流-1:统一知识库.json 应含谷禾2026新版扩展知识扩展块")
+    void seedJson_containsExtensionBlock() {
+        String seed = readSeed();
+        assertTrue(seed.contains(EXTENSION_KEY),
+                "RED[guhe.kb.queryLowSidePatch] 统一知识库.json 缺少「" + EXTENSION_KEY
+                        + "」扩展块:§2.2 要求 +guhe2026.2 版本感知(11 子键)。");
+    }
+
+    @Test
+    @DisplayName("边界流-2:扩展块应含条件致病菌低侧判读补充(人工)子键")
+    void seedJson_containsLowSidePatchSubKey() {
+        String seed = readSeed();
+        assertTrue(seed.contains(EXTENSION_KEY),
+                "RED[guhe.kb.queryLowSidePatch] 统一知识库.json 缺少「" + EXTENSION_KEY
+                        + "」扩展块(§2.2),无法定位低侧补丁子键。");
+        assertTrue(seed.contains(PATCH_KEY),
+                "RED[guhe.kb.queryLowSidePatch] 统一知识库.json 缺少「" + PATCH_KEY
+                        + "」子键:L2-2 要求低侧判读补充(人工)作为独立可查询子键。");
+    }
+
+    @Test
+    @DisplayName("边界流-3:低侧补丁块应含大肠杆菌四机制与报告原文口径(个人值剥离)")
+    void patchBlock_containsMechanismsAndReportCaliber() {
+        Object block = queryLowSidePatch();
+        String flat = flattenToString(block);
+        for (String content : PATCH_CONTENT) {
+            assertTrue(flat.contains(content),
+                    "RED[guhe.kb.queryLowSidePatch] 补丁块缺少「" + content
+                            + "」:§2.2 要求大肠杆菌偏低侧四机制 + 报告原文口径引用(个人值剥离)。");
+        }
+    }
+
+    // ==================== 权限流(不适用):内部 KB 查询,无用户权限维度 ====================
+
+    @Test
+    @DisplayName("权限流:本能力为后端内部知识库查询,无用户权限维度,权限流不适用")
+    void permissionFlow_notApplicable() {
+        // 说明性用例:契约 AC 为「L2 事实层 → L4 归因引擎/合规门禁取用」内部链路,无权限校验维度。
+        // 不产生断言失败;仅为三流覆盖标注「不适用」。
+        assertTrue(true, "权限流不适用:guhe.kb.queryLowSidePatch 为内部 KB 查询,无用户权限维度。");
+    }
+
+    // ==================== 反射/读取辅助 ====================
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    private Class<?> requireHostClass() {
+        Class<?> clazz = loadClass(HOST_CLASS);
+        if (clazz == null) {
+            fail("[guhe.kb.queryLowSidePatch] 契约宿主 UnifiedKnowledgeBaseService 不存在(实现未落地)。");
+        }
+        return clazz;
+    }
+
+    /** 契约方法调用:queryLowSidePatch()。方法缺失/调用失败即断言失败(红)。 */
+    private Object queryLowSidePatch() {
+        Class<?> clazz = requireHostClass();
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().equals("queryLowSidePatch") && m.getParameterCount() == 0) {
+                try {
+                    m.setAccessible(true);
+                    return m.invoke(requireHostInstance(clazz));
+                } catch (Exception e) {
+                    String causeMsg = e.getCause() != null ? e.getCause().getMessage() : e.getMessage();
+                    fail("RED[guhe.kb.queryLowSidePatch] UnifiedKnowledgeBaseService#queryLowSidePatch() "
+                            + "调用失败:" + causeMsg);
+                    throw new IllegalStateException("unreachable");
+                }
+            }
+        }
+        fail("RED[guhe.kb.queryLowSidePatch] UnifiedKnowledgeBaseService#queryLowSidePatch() 未实现"
+                + ":契约要求低侧判读补丁块作为独立可查询子键(§三 T1/L2-2)。");
+        throw new IllegalStateException("unreachable");
+    }
+
+    private Object requireHostInstance(Class<?> clazz) {
+        try {
+            return clazz.getDeclaredConstructor().newInstance();
+        } catch (Exception e) {
+            fail("契约宿主 UnifiedKnowledgeBaseService 实例化失败:" + e.getMessage());
+            throw new IllegalStateException("unreachable");
+        }
+    }
+
+    /** 递归展平 Map/List/String → 拼接字符串,用于关键字断言。 */
+    @SuppressWarnings("unchecked")
+    private String flattenToString(Object node) {
+        if (node == null) {
+            return "";
+        }
+        if (node instanceof Map) {
+            StringBuilder sb = new StringBuilder();
+            for (Object v : ((Map<String, Object>) node).values()) {
+                sb.append(flattenToString(v));
+            }
+            return sb.toString();
+        }
+        if (node instanceof List) {
+            StringBuilder sb = new StringBuilder();
+            for (Object v : (List<Object>) node) {
+                sb.append(flattenToString(v));
+            }
+            return sb.toString();
+        }
+        return String.valueOf(node);
+    }
+
+    private String readSeed() {
+        try (InputStream is = getClass().getClassLoader().getResourceAsStream(
+                SEED_CLASSPATH.startsWith("/") ? SEED_CLASSPATH.substring(1) : SEED_CLASSPATH)) {
+            if (is == null) {
+                return "";
+            }
+            try (Scanner scanner = new Scanner(is, StandardCharsets.UTF_8.name())) {
+                scanner.useDelimiter("\\A");
+                return scanner.hasNext() ? scanner.next() : "";
+            }
+        } catch (Exception e) {
+            return "";
+        }
+    }
+}