Explorar o código

test(guhe.p3.lowSideAggregate): S-cfc-88 add RED contract tests for P3 cross-case aggregate (T15 low-side reading aggregate & same-strategy retrieval)

Sisyphus Agent hai 4 días
pai
achega
65eaf03d72

+ 363 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/GuheLowSideAggregateContractTest.java

@@ -0,0 +1,363 @@
+package com.etotem.cfc.unit;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.io.File;
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.net.JarURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Enumeration;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.jar.JarFile;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * 契约级单测:P3 跨案例聚合(能力键 {@code guhe.p3.lowSideAggregate},T15)。
+ *
+ * <p>契约来源(2026-11-08-浠艾福平台程序修改计划.md §三-P3 / §三-T15 / §五 边界声明):</p>
+ * <ul>
+ *   <li><b>T15 跨案例聚合</b>:低侧口径并入既有 P3 聚合口径,可检索「同条件致病菌低侧」应对方案;
+ *       验收:跨案例可按低侧判读口径聚合同策略。</li>
+ *   <li><b>§三-L4 归因引擎 ③</b>:条件致病菌「偏低」按四机制解释、全部标「关联非因果」;
+ *       参考下限 0 的菌不标「偏低超标」;⛔ 禁「缺 X → 买含 X 菌株产品」桥接。</li>
+ *   <li><b>§2.2 知识库</b>:新增子键「条件致病菌低侧判读补充(人工)」为聚合口径的事实锚点。</li>
+ *   <li><b>§五</b>:所有低侧机制表述为「关联非因果」,不构成健康评估/诊断/干预建议。</li>
+ * </ul>
+ *
+ * <p><b>实现现状(C 档探测,src/main 现状核实)</b>:L4 归因引擎({@code FiveDimAttributionEngine})、
+ * 低侧红线组({@code AttributionLowSideRules} + {@code AttributionComplianceGate})已合入主链;
+ * 但 <b>P3 跨案例聚合未落地</b>——{@code com.etotem.cfc.service} 及其
+ * {@code attribution} 子包无类名含 {@code CaseAggregate} / {@code CrossCase} / {@code CaseStrategy} /
+ * {@code LowSideAggregate} 的服务类,{@code schema.sql} 无跨案例聚合承载表,
+ * {@code constant} 包无「条件致病菌低侧判读」聚合口径常量。
+ * 因此本测试以「包内类名扫描 + 方法签名探测 + classpath 资源探测 + 反射常量探测」验证契约缺失:
+ * 能编译通过、能运行、断言失败(红),失败断言原文清晰可贴。</p>
+ *
+ * <p><b>RED 预期</b>:正常流/边界流用例均应失败(红)——聚合服务类、聚合检索入口、聚合承载表、
+ * 判读口径常量、同策略聚类语义均未落地。</p>
+ *
+ * <p><b>GREEN 升级指引</b>:实现者落地跨案例聚合后本测试自动转绿——<ol>
+ * <li>在 {@code com.etotem.cfc.service}(或 {@code attribution} 子包)新增聚合服务类
+ *     (类名含 {@code CaseAggregate} / {@code CrossCase} / {@code CaseStrategy} / {@code LowSideAggregate}
+ *     任一即可);</li>
+ * <li>该类提供按「条件致病菌低侧判读」口径聚合跨案例、检索应对方案的入口方法
+ *     (方法名含 {@code aggregate} 且 {@code lowSide},或含 {@code aggregate} 且 {@code strategy},
+ *     或含 {@code aggregate} 且 {@code case});聚合结果按判读口径聚类为「同策略」;</li>
+ * <li>落地聚合承载表({@code schema.sql} 表名含 {@code attribution_case})与
+ *     「条件致病菌低侧判读补充(人工)」口径常量({@code constant} 包 String 常量含
+ *     低侧判读口径键名);</li>
+ * </ol>
+ * 届时可将结构探测升级为行为级验证(mock 聚合服务断言低侧案例聚合同策略)。</p>
+ *
+ * <p><b>权限流说明</b>:本能力为 P3 沉淀与协同层的跨案例聚合(后端内部归因资产层),
+ * 无用户权限维度,「权限(适用时)」三流中的权限流不适用,以说明性用例标注。</p>
+ */
+class GuheLowSideAggregateContractTest {
+
+    /** 能力键 */
+    private static final String CAP_KEY = "guhe.p3.lowSideAggregate";
+
+    /** 聚合服务类名关键词(实现未落地 → 类名由实现者定,任一台词即满足契约) */
+    private static final List<String> AGGREGATE_CLASS_KEYWORDS = Arrays.asList(
+            "CaseAggregate", "CrossCase", "CaseStrategy", "LowSideAggregate");
+
+    /** 聚合检索入口方法名关键词(聚合 + 低侧/策略/案例) */
+    private static final List<String> AGGREGATE_METHOD_KEYWORDS = Arrays.asList(
+            "aggregate", "Aggregate");
+
+    /** 低侧判读口径关键词(方法名/参数/常量层) */
+    private static final List<String> LOW_SIDE_KEYWORDS = Arrays.asList(
+            "lowSide", "LowSide", "低侧");
+
+    /** 同策略聚类关键词(T15:聚合同策略) */
+    private static final List<String> STRATEGY_KEYWORDS = Arrays.asList(
+            "strategy", "Strategy", "策略");
+
+    /** 判读口径键名(§2.2 KB 新增子键,作为聚合口径的事实锚点) */
+    private static final String LOW_SIDE_READING_KEY = "条件致病菌低侧判读补充(人工)";
+
+    /** 需扫描的候选包(不得假定实现包路径,扫全部候选包) */
+    private static final List<String> SCAN_PACKAGES = Arrays.asList(
+            "com.etotem.cfc.service",
+            "com.etotem.cfc.service.attribution",
+            "com.etotem.cfc.constant",
+            "com.etotem.cfc.entity");
+
+    // ------------------------------------------------------------------
+    // 正常流:跨案例聚合服务类应存在(T15 聚合入口)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-1:attribution 服务层应存在跨案例聚合服务类")
+    void aggregateService_classExists() {
+        String fqcn = findClassInPackages(AGGREGATE_CLASS_KEYWORDS);
+        assertTrue(fqcn != null,
+                "[" + CAP_KEY + "] service/attribution 等候选包无跨案例聚合服务类"
+                        + "(类名应含 CaseAggregate/CrossCase/CaseStrategy/LowSideAggregate 任一)。"
+                        + "T15:低侧口径并入既有 P3 聚合口径,可检索「同条件致病菌低侧」应对方案。"
+                        + "当前跨案例聚合实现未落地。");
+    }
+
+    // ------------------------------------------------------------------
+    // 正常流:按低侧判读口径聚合 + 检索应对方案(T15 核心行为)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-2:聚合服务应提供按低侧判读口径聚合检索应对方案的入口方法")
+    void aggregateService_hasAggregateEntryMethod() {
+        Class<?> clazz = loadAggregateServiceClass();
+        if (clazz == null) {
+            fail("[" + CAP_KEY + "] 跨案例聚合服务类不存在,无法验证聚合入口方法(T15)。"
+                    + "当前实现未落地。");
+        }
+        boolean hasAggregate = false;
+        for (Method m : clazz.getDeclaredMethods()) {
+            String name = m.getName();
+            boolean agg = containsAny(name, AGGREGATE_METHOD_KEYWORDS);
+            boolean lowSide = containsAny(name, LOW_SIDE_KEYWORDS);
+            boolean strategy = containsAny(name, STRATEGY_KEYWORDS);
+            boolean caseKw = name.toLowerCase().contains("case");
+            if (agg && (lowSide || strategy || caseKw)) {
+                hasAggregate = true;
+                break;
+            }
+        }
+        assertTrue(hasAggregate,
+                "[" + CAP_KEY + "] 跨案例聚合服务类 " + clazz.getSimpleName()
+                        + " 应提供按「条件致病菌低侧判读」口径聚合跨案例、检索应对方案的入口方法"
+                        + "(方法名含 aggregate 且 lowSide/strategy/case)。"
+                        + "T15:可检索「同条件致病菌低侧」应对方案;跨案例可按低侧判读口径聚合同策略。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:聚合承载表(T15 数据层)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-1:schema.sql 应有跨案例聚合承载表(表名含 attribution_case)")
+    void schemaSql_hasCrossCaseAggregateTable() {
+        String schema = readSchemaSql();
+        assertTrue(schema.contains("attribution_case"),
+                "[" + CAP_KEY + "] schema.sql 缺少跨案例聚合承载表(表名应含 attribution_case,"
+                        + "如 attribution_case_strategies),用于沉淀跨案例「同条件致病菌低侧」应对方案。"
+                        + "T15:低侧口径并入既有 P3 聚合口径。当前聚合承载表未落地。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:判读口径常量(聚合口径的事实锚点,§2.2)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-2:constant 包应暴露「条件致病菌低侧判读补充(人工)」口径常量")
+    void constantPackage_exposesLowSideReadingKey() {
+        boolean found = false;
+        for (String pkg : SCAN_PACKAGES) {
+            for (String fqcn : scanClassNames(pkg)) {
+                Class<?> clazz = loadClass(fqcn);
+                if (clazz == null) {
+                    continue;
+                }
+                if (hasStringConstantContaining(clazz, "条件致病菌低侧")
+                        || hasStringConstantContaining(clazz, LOW_SIDE_READING_KEY)) {
+                    found = true;
+                    break;
+                }
+            }
+            if (found) {
+                break;
+            }
+        }
+        assertTrue(found,
+                "[" + CAP_KEY + "] constant 等候选包应暴露「条件致病菌低侧判读补充(人工)」"
+                        + "判读口径常量(§2.2 KB 新增子键),作为跨案例聚合口径的事实锚点。"
+                        + "T15:低侧口径并入既有 P3 聚合口径。当前判读口径常量未落地。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:聚合同策略语义(T15 验收)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-3:聚合结果应支持「同条件致病菌低侧→同策略」聚类(验收)")
+    void aggregateResult_supportsSameReadingSameStrategy() {
+        Class<?> clazz = loadAggregateServiceClass();
+        if (clazz == null) {
+            fail("[" + CAP_KEY + "] 跨案例聚合服务类不存在,无法验证同策略聚类语义(T15)。"
+                    + "当前实现未落地。");
+        }
+        boolean hasStrategySemantics = false;
+        for (Method m : clazz.getDeclaredMethods()) {
+            String name = m.getName();
+            boolean agg = containsAny(name, AGGREGATE_METHOD_KEYWORDS);
+            boolean strategy = containsAny(name, STRATEGY_KEYWORDS);
+            if (agg && strategy) {
+                hasStrategySemantics = true;
+                break;
+            }
+        }
+        // 兜底:方法返回类型名含 strategy(聚合结果按判读口径聚类为同策略)
+        if (!hasStrategySemantics) {
+            for (Method m : clazz.getDeclaredMethods()) {
+                String ret = m.getReturnType().getSimpleName();
+                if (containsAny(ret, STRATEGY_KEYWORDS) || ret.toLowerCase().contains("aggregate")) {
+                    Class<?>[] params = m.getParameterTypes();
+                    for (Class<?> p : params) {
+                        if (containsAny(p.getSimpleName(), LOW_SIDE_KEYWORDS)
+                                || containsAny(p.getSimpleName(), STRATEGY_KEYWORDS)) {
+                            return; // 返回/参数已表达聚合口径或策略 → 契约满足(说明性绿)
+                        }
+                    }
+                    hasStrategySemantics = true;
+                    break;
+                }
+            }
+        }
+        assertTrue(hasStrategySemantics,
+                "[" + CAP_KEY + "] 跨案例聚合服务类 " + clazz.getSimpleName()
+                        + " 应表达「同条件致病菌低侧 → 同策略」聚类语义"
+                        + "(方法名含 aggregate+strategy,或返回/参数类型表达聚合口径与策略)。"
+                        + "T15 验收:跨案例可按低侧判读口径聚合同策略。当前未落地。");
+    }
+
+    // ------------------------------------------------------------------
+    // 权限流(不适用):P3 沉淀与协同层,无用户权限维度
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("权限流:本能力为 P3 沉淀与协同层的跨案例聚合,无用户权限维度,权限流不适用")
+    void permissionFlow_notApplicable() {
+        // 说明性用例:契约 AC 为「低侧口径并入既有 P3 聚合口径 → 跨案例可检索同低侧应对方案」,
+        // 属后端内部归因资产层,不产生用户可见权限校验维度。
+        // 不产生断言失败;仅为三流覆盖标注「不适用」。
+        assertTrue(true, "权限流不适用:guhe.p3.lowSideAggregate 为 P3 沉淀与协同层跨案例聚合,无权权限维度。");
+    }
+
+    // ------------------------------------------------------------------
+    // 反射/资源辅助(纯 JUnit5 + 反射,无 Spring / DB / Mockito)
+    // ------------------------------------------------------------------
+
+    /** 在候选包内查找类名含任一关键词的类(返回 FQCN;找不到返回 null) */
+    private String findClassInPackages(List<String> keywords) {
+        for (String pkg : SCAN_PACKAGES) {
+            for (String fqcn : scanClassNames(pkg)) {
+                String simple = fqcn.substring(fqcn.lastIndexOf('.') + 1);
+                for (String kw : keywords) {
+                    if (simple.contains(kw)) {
+                        return fqcn;
+                    }
+                }
+            }
+        }
+        return null;
+    }
+
+    /** 加载聚合服务类(找不到返回 null) */
+    private Class<?> loadAggregateServiceClass() {
+        String fqcn = findClassInPackages(AGGREGATE_CLASS_KEYWORDS);
+        return fqcn == null ? null : loadClass(fqcn);
+    }
+
+    private boolean containsAny(String text, List<String> keywords) {
+        if (text == null) return false;
+        for (String kw : keywords) {
+            if (text.contains(kw)) return true;
+        }
+        return false;
+    }
+
+    /** 是否存在静态 String 常量值包含 target(口径键探针) */
+    private boolean hasStringConstantContaining(Class<?> clazz, String target) {
+        try {
+            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) {
+                    // 常量读取失败 → 跳过
+                }
+            }
+        } catch (Throwable ignored) {
+            // 类初始化异常 → 跳过该类的常量扫描
+        }
+        return false;
+    }
+
+    /** 扫描包内所有类全限定名(classpath 资源;支持 file: 目录与 jar: 两种形态) */
+    private List<String> scanClassNames(String pkg) {
+        List<String> names = new ArrayList<String>();
+        try {
+            String path = pkg.replace('.', '/');
+            ClassLoader cl = Thread.currentThread().getContextClassLoader();
+            Enumeration<URL> resources = cl.getResources(path);
+            while (resources.hasMoreElements()) {
+                URL url = resources.nextElement();
+                String protocol = url.getProtocol();
+                if ("file".equals(protocol)) {
+                    File dir = new File(url.toURI());
+                    File[] files = dir.listFiles();
+                    if (files == null) continue;
+                    for (File f : files) {
+                        if (f.isFile() && f.getName().endsWith(".class")) {
+                            names.add(pkg + "." + f.getName().substring(0, f.getName().length() - 6));
+                        }
+                    }
+                } else if ("jar".equals(protocol)) {
+                    JarURLConnection conn = (JarURLConnection) url.openConnection();
+                    try (JarFile jar = conn.getJarFile()) {
+                        java.util.Enumeration<java.util.jar.JarEntry> entries = jar.entries();
+                        while (entries.hasMoreElements()) {
+                            java.util.jar.JarEntry e = entries.nextElement();
+                            String n = e.getName();
+                            if (n.startsWith(path + "/") && n.endsWith(".class") && n.indexOf('/', path.length() + 1) < 0) {
+                                names.add(n.substring(0, n.length() - 6).replace('/', '.'));
+                            }
+                        }
+                    }
+                }
+            }
+        } catch (Exception ignored) {
+            // 包扫描失败 → 返回已收集列表(空),结构探测按缺失处理
+        }
+        Set<String> unique = new HashSet<String>(names);
+        return new ArrayList<String>(unique);
+    }
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    private String readSchemaSql() {
+        try (InputStream is = getClass().getClassLoader().getResourceAsStream("schema.sql")) {
+            if (is == null) {
+                return "";
+            }
+            java.util.Scanner scanner = new java.util.Scanner(is, StandardCharsets.UTF_8.name());
+            try {
+                scanner.useDelimiter("\\A");
+                return scanner.hasNext() ? scanner.next() : "";
+            } finally {
+                scanner.close();
+            }
+        } catch (Exception e) {
+            return "";
+        }
+    }
+}