|
|
@@ -0,0 +1,293 @@
|
|
|
+package com.etotem.cfc.unit;
|
|
|
+
|
|
|
+import com.fasterxml.jackson.core.type.TypeReference;
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+
|
|
|
+import java.io.InputStream;
|
|
|
+import java.lang.reflect.Method;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 契约级单测:谷禾知识库既有键新字段暴露(能力键 {@code guhe.kb.fieldExpose},L2-3/T10)。
|
|
|
+ *
|
|
|
+ * <p><b>需求/设计锚点</b>({@code docs/superpowers/plans/2026-11-08-浠艾福平台程序修改计划.md}):
|
|
|
+ * <ul>
|
|
|
+ * <li>§三 任务分解 · P1 · <b>T10 既有键新字段暴露(L2-3)</b>:菌属功能库 [40]/[72]/[73]
|
|
|
+ * {@code 偏高影响}/{@code 偏低影响}、菌种综合信息「埃希氏菌属」{@code 偏低侧影响}、
|
|
|
+ * 艾德范思大肠杆菌 {@code 健康定植说明} <b>入 L2 查询返回</b>。</li>
|
|
|
+ * <li>§2.2 既有键补齐(只填空、不覆盖,幂等):菌属功能库 [40] 埃希氏菌属 / [72] 大肠杆菌 /
|
|
|
+ * [73] 脆弱拟杆菌(8 字段)、菌种综合信息「埃希氏菌属」{@code 偏低侧影响}、
|
|
|
+ * 艾德范思大肠杆菌 {@code 健康定植说明}。</li>
|
|
|
+ * </ul></p>
|
|
|
+ *
|
|
|
+ * <p><b>C 档平台探测(2026-11-08 自主核实,不假定包路径)</b>:
|
|
|
+ * <ul>
|
|
|
+ * <li><b>#2 KB 服务加载方式</b>:平台已封装服务层 ——
|
|
|
+ * {@code com.etotem.cfc.service.UnifiedKnowledgeBaseService}(12 个顶层模块,classpath
|
|
|
+ * 种子 {@code /knowledge-base/统一知识库.json} 兜底;查询入口 {@code getSection} /
|
|
|
+ * {@code lookupBacteria}(菌属功能库,学名主键 + 属级回退)/ {@code lookupSpeciesInfo}
|
|
|
+ * (菌种综合信息)/ {@code lookupIndicator} / {@code lookupNutrient})。</li>
|
|
|
+ * <li><b>#1 Adapter 形态</b>、<b>#3 L4 引擎</b>、<b>#4 合规门禁挂载点</b>:与本键(L2-3 字段暴露)
|
|
|
+ * 无直接耦合,不进入本契约。</li>
|
|
|
+ * </ul></p>
|
|
|
+ *
|
|
|
+ * <p><b>实现现状(RED 依据)</b>:宿主 {@code UnifiedKnowledgeBaseService} 类已存在,但<b>契约要求
|
|
|
+ * 的新字段数据未落地</b>——种子 {@code 统一知识库.json} 中:
|
|
|
+ * <ul>
|
|
|
+ * <li>菌属功能库 {@code Escherichia}(埃希氏菌属)/ {@code Escherichia coli}(大肠杆菌)/
|
|
|
+ * {@code Bacteroides fragilis}(脆弱拟杆菌)的 {@code 偏高影响}/{@code 偏低影响} 键<b>存在但为空字符串</b>;</li>
|
|
|
+ * <li>菌种综合信息「埃希氏菌属 Escherichia」<b>缺少</b> {@code 偏低侧影响} 键;</li>
|
|
|
+ * <li>「艾德范思」来源知识及 {@code 健康定植说明} 键<b>全库缺失</b>。</li>
|
|
|
+ * </ul>
|
|
|
+ * 因此本测试以「L2 查询返回必须暴露上述新字段(非空)」为契约断言,当前实现未满足 → 断言失败(红);
|
|
|
+ * GREEN 阶段由实现方回填种子数据(只填空、不覆盖),服务查询即自动暴露。</p>
|
|
|
+ *
|
|
|
+ * <p><b>测试范围</b>:纯契约级单测 —— 无 Spring 容器 / 无 DB / 无真实微信,仅读 classpath 种子
|
|
|
+ * JSON(与宿主服务加载同源),反射锚定宿主类(避免 import 编译耦合)。</p>
|
|
|
+ *
|
|
|
+ * <p><b>权限流</b>:本能力为后端内部知识库查询(L2 字段暴露),无用户权限维度,权限流不适用,
|
|
|
+ * 以说明性用例标注。</p>
|
|
|
+ *
|
|
|
+ * <p><b>RED 预期</b>:正常流 3 用例在「字段非空」断言处失败(红)——{@code 偏高影响}/{@code 偏低影响}
|
|
|
+ * 为空、{@code 偏低侧影响} 与 {@code 健康定植说明} 缺失;边界流「只填空、不覆盖」守卫生效、
|
|
|
+ * 宿主结构锚点存在(二者当前通过,作为契约不变式/锚点)。</p>
|
|
|
+ */
|
|
|
+class GuheKbFieldExposeContractTest {
|
|
|
+
|
|
|
+ /** L2 宿主(统一知识库服务,12 模块)——契约查询返回承载者。 */
|
|
|
+ private static final String HOST_CLASS = "com.etotem.cfc.service.UnifiedKnowledgeBaseService";
|
|
|
+
|
|
|
+ /** classpath 种子文件(宿主加载源,与 docs 同源)。 */
|
|
|
+ private static final String KB_SEED_CLASSPATH = "knowledge-base/统一知识库.json";
|
|
|
+
|
|
|
+ /** 菌属功能库目标:{学名, 中文名} —— [40]埃希氏菌属 / [72]大肠杆菌 / [73]脆弱拟杆菌(§2.2)。 */
|
|
|
+ private static final String[][] TARGET_BACTERIA = {
|
|
|
+ {"Escherichia", "埃希氏菌属"},
|
|
|
+ {"Escherichia coli", "大肠杆菌"},
|
|
|
+ {"Bacteroides fragilis", "脆弱拟杆菌"}
|
|
|
+ };
|
|
|
+
|
|
|
+ /** 菌种综合信息目标菌种键(§2.2)。 */
|
|
|
+ private static final String SPECIES_INFO_KEY = "埃希氏菌属 Escherichia";
|
|
|
+
|
|
|
+ /** 艾德范思品牌来源标记(§2.2 艾德范思大肠杆菌 健康定植说明)。 */
|
|
|
+ private static final String AIDEFANSI_BRAND = "艾德范思";
|
|
|
+
|
|
|
+ // ==================== 正常流:L2 查询返回必须暴露新字段(T10 L2-3) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-1:菌属功能库 [40]/[72]/[73] 应暴露非空 偏高影响/偏低影响")
|
|
|
+ void bacteriaGenus_highLowImpact_exposed() {
|
|
|
+ Map<String, Object> flora = section("菌属功能库");
|
|
|
+ assertNotNull(flora, "[guhe.kb.fieldExpose] 菌属功能库 模块缺失(L2 查询返回承载者)。");
|
|
|
+ for (String[] target : TARGET_BACTERIA) {
|
|
|
+ Map<String, Object> entry = findBacteria(flora, target[0], target[1]);
|
|
|
+ assertNotNull(entry,
|
|
|
+ "[guhe.kb.fieldExpose] 菌属功能库缺少目标菌属: " + target[1]
|
|
|
+ + "(学名 " + target[0] + ",契约 [40]/[72]/[73])。");
|
|
|
+ String high = text(entry.get("偏高影响"));
|
|
|
+ String low = text(entry.get("偏低影响"));
|
|
|
+ assertTrue(high != null && !high.trim().isEmpty(),
|
|
|
+ "[guhe.kb.fieldExpose] 菌属功能库[" + target[1] + "] 偏高影响 应为非空"
|
|
|
+ + "(契约 §2.2 既有键补齐,T10 L2-3 入 L2 查询返回);当前=" + high);
|
|
|
+ assertTrue(low != null && !low.trim().isEmpty(),
|
|
|
+ "[guhe.kb.fieldExpose] 菌属功能库[" + target[1] + "] 偏低影响 应为非空"
|
|
|
+ + "(契约 §2.2 既有键补齐,T10 L2-3 入 L2 查询返回);当前=" + low);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-2:菌种综合信息「埃希氏菌属」应暴露非空 偏低侧影响")
|
|
|
+ void speciesInfo_escherichia_lowSideImpact_exposed() {
|
|
|
+ Map<String, Object> speciesInfo = section("菌种综合信息");
|
|
|
+ assertNotNull(speciesInfo, "[guhe.kb.fieldExpose] 菌种综合信息 模块缺失。");
|
|
|
+ Object raw = speciesInfo.get("菌种");
|
|
|
+ assertNotNull(raw, "[guhe.kb.fieldExpose] 菌种综合信息 缺少 菌种 节点。");
|
|
|
+ Map<?, ?> species = (Map<?, ?>) raw;
|
|
|
+ Object entryObj = species.get(SPECIES_INFO_KEY);
|
|
|
+ assertNotNull(entryObj,
|
|
|
+ "[guhe.kb.fieldExpose] 菌种综合信息缺少目标菌种: " + SPECIES_INFO_KEY + "。");
|
|
|
+ Map<?, ?> entry = (Map<?, ?>) entryObj;
|
|
|
+ String lowSide = text(entry.get("偏低侧影响"));
|
|
|
+ assertTrue(lowSide != null && !lowSide.trim().isEmpty(),
|
|
|
+ "[guhe.kb.fieldExpose] 菌种综合信息「埃希氏菌属」偏低侧影响 应为非空"
|
|
|
+ + "(契约 §2.2,T10 L2-3 入 L2 查询返回);当前=" + lowSide);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-3:艾德范思大肠杆菌应暴露非空 健康定植说明")
|
|
|
+ void aidefansiEcoli_colonizationNote_exposed() {
|
|
|
+ Map<String, Object> root = kbRoot();
|
|
|
+ assertTrue(containsBrand(root, AIDEFANSI_BRAND),
|
|
|
+ "[guhe.kb.fieldExpose] 平台知识库缺少「艾德范思」来源知识"
|
|
|
+ + "(契约 §2.2 艾德范思大肠杆菌 健康定植说明);艾德范思品牌未落地。");
|
|
|
+ String note = findNonEmptyStringValue(root, "健康定植说明");
|
|
|
+ assertTrue(note != null && !note.trim().isEmpty(),
|
|
|
+ "[guhe.kb.fieldExpose] 艾德范思大肠杆菌 健康定植说明 应为非空"
|
|
|
+ + "(契约 §2.2,T10 L2-3 入 L2 查询返回);当前缺失。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 边界流:只填空、不覆盖(幂等) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("边界流-1:既有键补齐只填空、不覆盖(既有 功能说明 不得被回填清空)")
|
|
|
+ void backfill_additive_doesNotClobberExisting() {
|
|
|
+ Map<String, Object> flora = section("菌属功能库");
|
|
|
+ Map<String, Object> escherichia = findBacteria(flora, "Escherichia", "埃希氏菌属");
|
|
|
+ assertNotNull(escherichia, "菌属功能库缺少 Escherichia。");
|
|
|
+ String func = text(escherichia.get("功能说明"));
|
|
|
+ assertTrue(func != null && !func.trim().isEmpty(),
|
|
|
+ "[guhe.kb.fieldExpose] 既有 功能说明 不得被回填覆盖"
|
|
|
+ + "(契约 §2.2 只填空、不覆盖,幂等);功能说明=" + func);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 宿主契约锚点:L2 查询承载者存在 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("宿主锚点:UnifiedKnowledgeBaseService 应存在并提供 L2 查询入口")
|
|
|
+ void l2Host_serviceAndQuery_exist() {
|
|
|
+ Class<?> host = loadClass(HOST_CLASS);
|
|
|
+ assertNotNull(host,
|
|
|
+ "[guhe.kb.fieldExpose] L2 宿主 UnifiedKnowledgeBaseService 不存在(T10 L2-3 查询返回承载者)。");
|
|
|
+ assertTrue(hasMethod(host, "getSection"), "应提供 getSection(模块级查询)。");
|
|
|
+ assertTrue(hasMethod(host, "lookupBacteria"), "应提供 lookupBacteria(菌属功能库查询)。");
|
|
|
+ assertTrue(hasMethod(host, "lookupSpeciesInfo"), "应提供 lookupSpeciesInfo(菌种综合信息查询)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 权限流(不适用):内部知识库查询,无用户权限维度 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("权限流:本能力为后端内部知识库查询(L2 字段暴露),权限流不适用")
|
|
|
+ void permissionFlow_notApplicable() {
|
|
|
+ // 说明性用例:契约 AC 为「L2 查询返回暴露新字段」内部链路,无用户权限校验维度。
|
|
|
+ assertTrue(true, "权限流不适用:guhe.kb.fieldExpose 为内部知识库查询,无用户权限维度。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 数据读取与解析(无 Spring / 无 DB) ====================
|
|
|
+
|
|
|
+ /** 读 classpath 种子 KB 根(与宿主加载同源)。 */
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ private Map<String, Object> kbRoot() {
|
|
|
+ try (InputStream is = getClass().getClassLoader().getResourceAsStream(KB_SEED_CLASSPATH)) {
|
|
|
+ assertNotNull(is, "classpath 种子不存在: " + KB_SEED_CLASSPATH);
|
|
|
+ return new ObjectMapper().readValue(is, new TypeReference<Map<String, Object>>() {});
|
|
|
+ } catch (Exception e) {
|
|
|
+ throw new IllegalStateException("解析种子KB失败: " + e.getMessage(), e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 取指定顶层模块(sectionMap.get(moduleKey))。 */
|
|
|
+ private Map<String, Object> section(String moduleKey) {
|
|
|
+ Map<String, Object> root = kbRoot();
|
|
|
+ Object obj = root.get(moduleKey);
|
|
|
+ if (obj instanceof Map) {
|
|
|
+ return (Map<String, Object>) obj;
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 在 菌属功能库.菌属 数组中按 学名/中文名 定位条目。 */
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ private Map<String, Object> findBacteria(Map<String, Object> flora, String latin, String cnName) {
|
|
|
+ Object raw = flora.get("菌属");
|
|
|
+ if (!(raw instanceof List)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ for (Object item : (List<Object>) raw) {
|
|
|
+ if (!(item instanceof Map)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ Map<String, Object> entry = (Map<String, Object>) item;
|
|
|
+ if (latin.equals(entry.get("学名")) || cnName.equals(entry.get("中文名"))) {
|
|
|
+ return entry;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 深度遍历:任一 字符串值/键 包含品牌标记。 */
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ private boolean containsBrand(Object node, String brand) {
|
|
|
+ if (node == null) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ if (node instanceof Map) {
|
|
|
+ for (Map.Entry<?, ?> e : ((Map<?, ?>) node).entrySet()) {
|
|
|
+ if (String.valueOf(e.getKey()).contains(brand)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ if (containsBrand(e.getValue(), brand)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } else if (node instanceof List) {
|
|
|
+ for (Object item : (List<Object>) node) {
|
|
|
+ if (containsBrand(item, brand)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } else if (node instanceof String) {
|
|
|
+ return ((String) node).contains(brand);
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 深度遍历:返回首个指定键的非空字符串值。 */
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ private String findNonEmptyStringValue(Object node, String fieldKey) {
|
|
|
+ if (node == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ if (node instanceof Map) {
|
|
|
+ for (Map.Entry<?, ?> e : ((Map<?, ?>) node).entrySet()) {
|
|
|
+ if (fieldKey.equals(String.valueOf(e.getKey()))) {
|
|
|
+ String v = text(e.getValue());
|
|
|
+ if (v != null && !v.trim().isEmpty()) {
|
|
|
+ return v;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ String found = findNonEmptyStringValue(e.getValue(), fieldKey);
|
|
|
+ if (found != null) {
|
|
|
+ return found;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } else if (node instanceof List) {
|
|
|
+ for (Object item : (List<Object>) node) {
|
|
|
+ String found = findNonEmptyStringValue(item, fieldKey);
|
|
|
+ if (found != null) {
|
|
|
+ return found;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 反射辅助 ====================
|
|
|
+
|
|
|
+ private Class<?> loadClass(String name) {
|
|
|
+ try {
|
|
|
+ return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
|
|
|
+ } catch (ClassNotFoundException e) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean hasMethod(Class<?> clazz, String methodName) {
|
|
|
+ for (Method m : clazz.getDeclaredMethods()) {
|
|
|
+ if (m.getName().equals(methodName)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ private String text(Object v) {
|
|
|
+ return v == null ? null : String.valueOf(v);
|
|
|
+ }
|
|
|
+}
|