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