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