Просмотр исходного кода

test(attribution): S-cfc-92 guhe.gate.lowSideRedline 契约测试(实现已存在 a13bb8192,验证转登记)

Sisyphus Agent 4 дней назад
Родитель
Сommit
b1397ae583

+ 312 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/GuheGateLowSideRedlineContractTest.java

@@ -0,0 +1,312 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.constant.AttributionLowSideRules;
+import com.etotem.cfc.service.attribution.AttributionComplianceGate;
+import com.etotem.cfc.service.attribution.AttributionReportBuilder;
+import com.etotem.cfc.service.attribution.model.IndicatorReading;
+import com.etotem.cfc.service.attribution.model.MemberAttribution;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 契约级单测:谷禾 v2 低侧红线组(能力键 {@code guhe.gate.lowSideRedline},T5/G-1,P0)。
+ *
+ * <p>契约来源(2026-11-08《浠艾福平台程序修改计划(guhe_v2 兼容 · 知识库低侧口径 · 专家同步)》§三任务分解 T5):
+ * <blockquote>
+ *   T5 低侧红线组(G-1):新增合规规则——{@code association_only == true};禁写「缺 X→致病/需补充」;
+ *   禁把调整建议写成处方;措辞白名单「相关联/报告提示/可能与…有关」。
+ *   <br>验收:任意「偏低」归因节点 {@code association_only==true};存量无「缺大肠杆菌→补菌」措辞;
+ *   低侧机制证据全部标 PUB/KB(该子项归 T11 证据码隔离 G-3,本节不另断言)。</blockquote></p>
+ *
+ * <p><b>契约语义</b>:本能力键是「合规门禁(横切)」的低侧红线组——在 L4 出结论前对任意
+ * 「偏低」归因节点强制 {@code association_only == true}(低侧机制禁因果),并在 L5 出文档前
+ * 反向回扫「缺 X→致病/需补充」桥接与处方化建议,命中即收集为合规违规(不阻断,E4 弱提示),
+ * 由报告侧 {@code sections.complianceGate} 暴露。挂载点(C 档待确认项 #4)经自主探测落于
+ * {@link AttributionAnalysisServiceImpl#analyze}(L4 enforce + L5 scan)。</p>
+ *
+ * <p><b>实现现状(worktree src/main 探测,2026-11-09)</b>:T5/G-1 门禁已落地
+ * ({@code AttributionLowSideRules} / {@code AttributionComplianceGate} / 报告
+ * {@code sections.complianceGate} / {@link IndicatorReading#associationOnly} 已并入主链
+ * commit a13bb8192)。本契约测试据此断言契约行为;若实测断言全过(绿),按任务纪律注明
+ * 「实现已存在」并交编排侧转测试登记,不私改实现。</p>
+ *
+ * <p><b>GREEN 升级指引(若未来实现回退/缺失)</b>:在 {@code AttributionLowSideRules} 保留
+ * {@code GROUP_CODE="G-1"} 与三规则码 + 措辞白名单;{@code AttributionComplianceGate} 提供
+ * {@code enforceLowSide(attributions)}(偏低强制 association_only)与 {@code scan(text)}
+ * (缺X桥接/处方回扫);{@code AttributionReportBuilder} 暴露 {@code sections.complianceGate}
+ * 后本测试自动转绿。</p>
+ *
+ * <p><b>权限流说明</b>:本能力为后端内部合规门禁(L4 出结论前 / L5 出文档前),无用户权限
+ * 维度,权限流不适用,以说明性用例标注。</p>
+ */
+class GuheGateLowSideRedlineContractTest {
+
+    private final AttributionComplianceGate gate = new AttributionComplianceGate();
+
+    // ------------------------------------------------------------------
+    // 正常流:规则层常量(G-1 分组 + 三规则码 + 措辞白名单)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-1:AttributionLowSideRules 应暴露 G-1 分组码与三规则码")
+    void lowSideRules_exposesGroupAndRuleCodes() {
+        assertEquals("G-1", AttributionLowSideRules.GROUP_CODE,
+                "[guhe.gate.lowSideRedline] 低侧红线组分组码应为 G-1(§三任务分解 T5)。");
+        assertNotNull(AttributionLowSideRules.GROUP_TITLE,
+                "[guhe.gate.lowSideRedline] GROUP_TITLE 不应为 null。");
+        assertEquals("association_only", AttributionLowSideRules.RULE_ASSOCIATION_ONLY,
+                "[guhe.gate.lowSideRedline] 规则码 association_only(偏低节点禁因果)。");
+        assertEquals("no_causal_bridge", AttributionLowSideRules.RULE_NO_CAUSAL_BRIDGE,
+                "[guhe.gate.lowSideRedline] 规则码 no_causal_bridge(禁缺X→致病/需补充)。");
+        assertEquals("no_prescription", AttributionLowSideRules.RULE_NO_PRESCRIPTION,
+                "[guhe.gate.lowSideRedline] 规则码 no_prescription(禁处方化建议)。");
+    }
+
+    @Test
+    @DisplayName("正常流-2:措辞白名单应含「相关联/报告提示/可能与…有关」")
+    void lowSideRules_wordingWhitelistCoversThreePhrases() {
+        List<String> whitelist = AttributionLowSideRules.WORDING_WHITELIST;
+        assertNotNull(whitelist, "[guhe.gate.lowSideRedline] WORDING_WHITELIST 不应为 null。");
+        assertFalse(whitelist.isEmpty(), "[guhe.gate.lowSideRedline] 措辞白名单不能为空。");
+        assertTrue(whitelist.contains("相关联"), "白名单须含「相关联」");
+        assertTrue(whitelist.contains("报告提示"), "白名单须含「报告提示」");
+        assertTrue(whitelist.contains("可能与…有关"), "白名单须含「可能与…有关」");
+    }
+
+    // ------------------------------------------------------------------
+    // 正常流:enforceLowSide 对任意「偏低」归因节点强制 association_only
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-3:任意偏低归因节点强制 association_only=true(偏高/正常/待补不强制)")
+    void enforceLowSide_forcesAssociationOnlyOnEveryLowNode() {
+        MemberAttribution a = buildMember(1L, "妈妈", Arrays.asList(
+                reading("大肠杆菌", IndicatorReading.STATUS_LOW, "0.5%", "P3"),
+                reading("双歧杆菌", IndicatorReading.STATUS_LOW, "2.1%", "P10"),
+                reading("罗伊氏乳杆菌", IndicatorReading.STATUS_HIGH, "7.3%", "P90"),
+                reading("维生素D", IndicatorReading.STATUS_NORMAL, "45", null),
+                reading("血清铁", IndicatorReading.STATUS_PENDING, "8", null)
+        ));
+
+        AttributionComplianceGate.GateReport report =
+                gate.enforceLowSide(Collections.singletonList(a));
+
+        assertEquals(2, report.associationOnlyCount(),
+                "[guhe.gate.lowSideRedline] 2 个偏低节点应全部强制 associationOnly(验收:任意偏低节点)。");
+        assertTrue(report.getNodes().stream().anyMatch(n -> n.getIndicator().contains("大肠杆菌")));
+        assertTrue(report.getNodes().stream().anyMatch(n -> n.getIndicator().contains("双歧杆菌")));
+        for (AttributionComplianceGate.LowSideNode n : report.getNodes()) {
+            assertTrue(n.isAssociationOnly(),
+                    "[guhe.gate.lowSideRedline] 低侧节点 " + n.getIndicator() + " 应恒为仅关联(associationOnly)。");
+        }
+        for (IndicatorReading r : a.getReadings()) {
+            if (IndicatorReading.STATUS_LOW.equals(r.getStatus())) {
+                assertTrue(r.isAssociationOnly(),
+                        r.getDisplayName() + " 应被强制 associationOnly=true");
+            } else {
+                assertFalse(r.isAssociationOnly(),
+                        r.getDisplayName() + " 不应被强制 associationOnly");
+            }
+        }
+    }
+
+    @Test
+    @DisplayName("正常流-4:无偏低节点时 enforceLowSide 返回空报告且不误报")
+    void enforceLowSide_noLowNode_emptyReport() {
+        MemberAttribution a = buildMember(1L, "爸爸", Collections.singletonList(
+                reading("血红蛋白", IndicatorReading.STATUS_NORMAL, "140", null)
+        ));
+        AttributionComplianceGate.GateReport report =
+                gate.enforceLowSide(Collections.singletonList(a));
+        assertTrue(report.getNodes().isEmpty(), "[guhe.gate.lowSideRedline] 无偏低节点时不应产出低侧节点。");
+        assertTrue(report.getViolations().isEmpty(), "[guhe.gate.lowSideRedline] 无偏低节点时不应有违规。");
+    }
+
+    // ------------------------------------------------------------------
+    // 正常流:scan 回扫「缺 X→致病/需补充」桥接(no_causal_bridge)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-5:scan 命中「缺 X→致病/需补充」因果桥接 → no_causal_bridge 违规")
+    void scan_causalBridge_hitsNoCausalBridge() {
+        List<AttributionComplianceGate.Violation> vs = gate.scan(
+                "缺大肠杆菌→需补充该菌,否则易致肠炎感染");
+        assertFalse(vs.isEmpty(), "[guhe.gate.lowSideRedline] 缺X→需补充桥接应命中(T5 禁写)。");
+        assertViolationsContainRule(vs, AttributionLowSideRules.RULE_NO_CAUSAL_BRIDGE);
+        assertNotNull(vs.get(0).getSnippet(), "[guhe.gate.lowSideRedline] 违规应带命中片段。");
+        assertNotNull(vs.get(0).getSuggestion(), "[guhe.gate.lowSideRedline] 违规应带白名单改写建议。");
+    }
+
+    @Test
+    @DisplayName("正常流-6:scan 命中「缺 X 补 X」产品桥接(报告缺X→买含X菌株产品)")
+    void scan_causalBridge_hitsSupplementProductBridge() {
+        List<AttributionComplianceGate.Violation> vs = gate.scan(
+                "报告缺大肠杆菌→建议购买含大肠杆菌菌株的产品");
+        assertFalse(vs.isEmpty(),
+                "[guhe.gate.lowSideRedline] 报告缺X→买含X菌株产品桥接应命中(叠加 §5.2 16S 粒度声明)。");
+        assertViolationsContainRule(vs, AttributionLowSideRules.RULE_NO_CAUSAL_BRIDGE);
+    }
+
+    // ------------------------------------------------------------------
+    // 正常流:scan 回扫处方化建议(no_prescription)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-7:scan 命中处方化建议(剂量/用法用量/疗程)→ no_prescription 违规")
+    void scan_prescription_hitsNoPrescription() {
+        List<AttributionComplianceGate.Violation> vs = gate.scan(
+                "建议每日 5mg 补充维生素D3,连服 7 天,剂量 200IU/天");
+        assertFalse(vs.isEmpty(), "[guhe.gate.lowSideRedline] 处方化建议应命中(T5 禁把调整建议写成处方)。");
+        assertViolationsContainRule(vs, AttributionLowSideRules.RULE_NO_PRESCRIPTION);
+    }
+
+    @Test
+    @DisplayName("正常流-8:白名单关联措辞不误报(scan 无违规)")
+    void scan_whitelistWording_noFalsePositive() {
+        for (String safe : Arrays.asList(
+                "与肠道环境相关联",
+                "报告提示可能与饮食结构有关",
+                "可能与压力负荷有关",
+                "偏低与慢性炎症相关(仅描述关联)",
+                "报告提示营养状态与菌群多样性相关",
+                "儿童剂量由医生定",
+                "按产品说明服用"
+        )) {
+            List<AttributionComplianceGate.Violation> vs = gate.scan(safe);
+            assertTrue(vs.isEmpty(), "[guhe.gate.lowSideRedline] 白名单措辞不应误报:" + safe);
+        }
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:merge 语义(base 节点保留 + 追加违规)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-1:merge 追加违规并保留基础低侧节点")
+    void merge_appendsViolationsKeepsBaseNodes() {
+        MemberAttribution base = buildMember(1L, "女儿", Collections.singletonList(
+                reading("大肠杆菌", IndicatorReading.STATUS_LOW, "0.3%", "P1")
+        ));
+        AttributionComplianceGate.GateReport baseReport = gate.enforceLowSide(Collections.singletonList(base));
+        List<AttributionComplianceGate.Violation> extra = gate.scan("缺锌导致缺钙致病");
+        AttributionComplianceGate.GateReport merged = AttributionComplianceGate.merge(baseReport, extra);
+
+        assertEquals(baseReport.getNodes().size(), merged.getNodes().size(),
+                "[guhe.gate.lowSideRedline] merge 应保留基础低侧节点。");
+        assertEquals(baseReport.associationOnlyCount(), merged.associationOnlyCount(),
+                "[guhe.gate.lowSideRedline] merge 后 association_only 计数不变。");
+        assertEquals(baseReport.getViolations().size() + extra.size(), merged.getViolations().size(),
+                "[guhe.gate.lowSideRedline] merge 应追加违规。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:门禁挂载点(L4 出结论前 enforce + L5 出文档前 scan)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-2:AttributionAnalysisServiceImpl 应挂载 AttributionComplianceGate")
+    void analysisService_mountsComplianceGate() {
+        Class<?> svc = loadClass("com.etotem.cfc.service.impl.AttributionAnalysisServiceImpl");
+        assertNotNull(svc, "[guhe.gate.lowSideRedline] AttributionAnalysisServiceImpl 类应存在。");
+        boolean wired = false;
+        for (Field f : svc.getDeclaredFields()) {
+            if (AttributionComplianceGate.class.getName().equals(f.getType().getName())) {
+                wired = true;
+                break;
+            }
+        }
+        assertTrue(wired,
+                "[guhe.gate.lowSideRedline] AttributionAnalysisServiceImpl 应注入 AttributionComplianceGate "
+                        + "(L4 出结论前 enforce + L5 出文档前 scan,C 档挂载点经探测落此)。");
+    }
+
+    @Test
+    @DisplayName("边界流-3:AttributionReportBuilder 应暴露带 GateReport 的 buildReportJson 重载(sections.complianceGate)")
+    void reportBuilder_exposesGateReportOverload() {
+        boolean hasOverload = false;
+        for (Method m : AttributionReportBuilder.class.getDeclaredMethods()) {
+            if (!"buildReportJson".equals(m.getName())) {
+                continue;
+            }
+            Class<?>[] params = m.getParameterTypes();
+            if (params.length >= 8
+                    && AttributionComplianceGate.GateReport.class == params[params.length - 1]) {
+                hasOverload = true;
+                break;
+            }
+        }
+        assertTrue(hasOverload,
+                "[guhe.gate.lowSideRedline] AttributionReportBuilder 应提供带 "
+                        + "AttributionComplianceGate.GateReport 的 buildReportJson 重载,"
+                        + "供报告侧暴露 sections.complianceGate(低侧节点/违规/白名单)。");
+    }
+
+    // ------------------------------------------------------------------
+    // 权限流(不适用):后端内部合规门禁,无用户权限维度
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("权限流:本能力为后端内部合规门禁,无用户权限维度,权限流不适用")
+    void permissionFlow_notApplicable() {
+        // 说明性用例:契约 AC 为「L4 出结论前 / L5 出文档前」内部规则,无权限校验维度。
+        assertTrue(true, "权限流不适用:guhe.gate.lowSideRedline 为后端内部合规门禁,无用户权限维度。");
+    }
+
+    // ------------------------------------------------------------------
+    // 反射与数据构造辅助
+    // ------------------------------------------------------------------
+
+    private static void assertViolationsContainRule(List<AttributionComplianceGate.Violation> vs, String rule) {
+        assertTrue(vs.stream().anyMatch(v -> rule.equals(v.getRule())),
+                "[guhe.gate.lowSideRedline] 违规列表缺少规则码「" + rule + "」:" + vs);
+    }
+
+    private static Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    private static IndicatorReading reading(String display, String status, String value, String percentile) {
+        IndicatorReading r = new IndicatorReading();
+        r.setCode(display);
+        r.setDisplayName(display);
+        r.setValue(value);
+        r.setPercentile(percentile);
+        r.setStatus(status);
+        return r;
+    }
+
+    private static MemberAttribution buildMember(Long memberId, String name, List<IndicatorReading> readings) {
+        MemberAttribution a = new MemberAttribution();
+        a.setMemberId(memberId);
+        a.setName(name);
+        a.setRole("mom");
+        a.setRoleLabel("妈妈");
+        a.setReadings(new ArrayList<>(readings));
+        a.setDimensionEvidence(new java.util.LinkedHashMap<>());
+        a.setFiveDim(new java.util.LinkedHashMap<>());
+        a.setWeakDimensions(new ArrayList<>());
+        a.setActions(new ArrayList<>());
+        for (IndicatorReading r : readings) {
+            if (r.isAbnormal()) {
+                a.setAbnormalCount(a.getAbnormalCount() + 1);
+            }
+        }
+        return a;
+    }
+}