|
|
@@ -0,0 +1,214 @@
|
|
|
+package com.etotem.cfc.unit;
|
|
|
+
|
|
|
+import com.etotem.cfc.constant.AttributionLowSideRules;
|
|
|
+import com.etotem.cfc.service.attribution.AttributionComplianceGate;
|
|
|
+import com.etotem.cfc.service.attribution.model.IndicatorReading;
|
|
|
+import com.etotem.cfc.service.attribution.model.MemberAttribution;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.Collections;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.*;
|
|
|
+
|
|
|
+/**
|
|
|
+ * ATT-S2-B1 / G-1 低侧红线组:AttributionComplianceGate 纯规则门禁的单元测试(T5 P0)。
|
|
|
+ *
|
|
|
+ * <p>覆盖:
|
|
|
+ * <ul>
|
|
|
+ * <li>association_only 强制于所有偏低节点、不触碰偏高/正常/待补;</li>
|
|
|
+ * <li>禁写「缺 X→致病/需补充」桥接命中且给出白名单建议;</li>
|
|
|
+ * <li>禁把调整建议写成处方(剂量/用法/疗程)命中与不误报;</li>
|
|
|
+ * <li>措辞白名单常量完整。</li>
|
|
|
+ * </ul>
|
|
|
+ */
|
|
|
+class AttributionComplianceGateTest {
|
|
|
+
|
|
|
+ private final AttributionComplianceGate gate = new AttributionComplianceGate();
|
|
|
+
|
|
|
+ // ============================================================
|
|
|
+ // association_only 强制
|
|
|
+ // ============================================================
|
|
|
+ @Test
|
|
|
+ void 任意偏低归因节点都强制AssociationOnly() {
|
|
|
+ MemberAttribution a = buildMember(1L, "妈妈", Arrays.asList(
|
|
|
+ reading("血清铁", "偏低", "8"), // STATUS_LOW
|
|
|
+ reading("双歧杆菌", "偏低", "2.1%"), // STATUS_LOW
|
|
|
+ reading("罗伊氏乳杆菌", "偏高", "7.3%"), // STATUS_HIGH
|
|
|
+ reading("维生素D", "待补", "18") // STATUS_PENDING
|
|
|
+ ));
|
|
|
+
|
|
|
+ AttributionComplianceGate.GateReport report = gate.enforceLowSide(Collections.singletonList(a));
|
|
|
+
|
|
|
+ assertEquals(2, report.associationOnlyCount(), "应有 2 个偏低节点 associationOnly");
|
|
|
+ assertTrue(report.getNodes().stream().anyMatch(n -> n.getIndicator().contains("血清铁")));
|
|
|
+ assertTrue(report.getNodes().stream().anyMatch(n -> n.getIndicator().contains("双歧杆菌")));
|
|
|
+
|
|
|
+ for (IndicatorReading r : a.getReadings()) {
|
|
|
+ if ("偏低".equals(r.getStatus())) {
|
|
|
+ assertTrue(r.isAssociationOnly(), r.getDisplayName() + " 应被强制为 associationOnly=true");
|
|
|
+ } else {
|
|
|
+ assertFalse(r.isAssociationOnly(), r.getDisplayName() + " 不应被强制");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 无偏低节点时EnforceLowSide返回空报告且不报错() {
|
|
|
+ MemberAttribution a = buildMember(1L, "爸爸", Collections.singletonList(
|
|
|
+ reading("血红蛋白", "正常", "140")
|
|
|
+ ));
|
|
|
+ AttributionComplianceGate.GateReport report = gate.enforceLowSide(Collections.singletonList(a));
|
|
|
+ assertTrue(report.getNodes().isEmpty());
|
|
|
+ assertTrue(report.getViolations().isEmpty());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============================================================
|
|
|
+ // 禁写「缺 X → 致病/需补充」桥接
|
|
|
+ // ============================================================
|
|
|
+ @Test
|
|
|
+ void 禁写缺X致病桥接_命中并出违规() {
|
|
|
+ String hit = "缺大肠杆菌→需补充该菌,否则易致肠炎感染";
|
|
|
+ List<AttributionComplianceGate.Violation> v = gate.scan(hit);
|
|
|
+ assertFalse(v.isEmpty(), "应命中禁写桥接禁令");
|
|
|
+ assertViolationsContainRule(v, AttributionLowSideRules.RULE_NO_CAUSAL_BRIDGE);
|
|
|
+ assertNotNull(v.get(0).getSnippet());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 禁写缺X致病桥接_箭头用各种写法也命中() {
|
|
|
+ for (String src : Arrays.asList(
|
|
|
+ "菌群偏低导致肠屏障受损",
|
|
|
+ "不足可引起致病,引发感染"
|
|
|
+ )) {
|
|
|
+ List<AttributionComplianceGate.Violation> v = gate.scan(src);
|
|
|
+ assertFalse(v.isEmpty(), "应命中 '" + src + "'");
|
|
|
+ assertViolationsContainRule(v, AttributionLowSideRules.RULE_NO_CAUSAL_BRIDGE);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 禁写缺X致病桥接_关联措辞不误报() {
|
|
|
+ // 措辞白名单允许的表述
|
|
|
+ for (String safe : Arrays.asList(
|
|
|
+ "与肠道环境相关联",
|
|
|
+ "报告提示可能与饮食结构有关",
|
|
|
+ "可能与压力负荷有关",
|
|
|
+ "偏低与慢性炎症相关(仅描述关联)",
|
|
|
+ "报告提示营养状态与菌群多样性相关"
|
|
|
+ )) {
|
|
|
+ List<AttributionComplianceGate.Violation> v = gate.scan(safe);
|
|
|
+ assertTrue(v.isEmpty(), "误报: " + safe);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============================================================
|
|
|
+ // 禁把调整建议写成处方
|
|
|
+ // ============================================================
|
|
|
+ @Test
|
|
|
+ void 禁把调整建议写成处方_命中() {
|
|
|
+ String hit = "每日 5mg 补充维生素D3,连服 7 天;剂量 200IU/次";
|
|
|
+ List<AttributionComplianceGate.Violation> v = gate.scan(hit);
|
|
|
+ assertFalse(v.isEmpty(), "应命中禁处方禁令");
|
|
|
+ assertViolationsContainRule(v, AttributionLowSideRules.RULE_NO_PRESCRIPTION);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 禁把调整建议写成处方_不误报儿童医生定() {
|
|
|
+ // 计划侧常见的「儿童剂量由医生定」「按产品说明」等不应触发
|
|
|
+ for (String safe : Arrays.asList(
|
|
|
+ "儿童剂量由医生定",
|
|
|
+ "成人 5g/天起,儿童遵医嘱",
|
|
|
+ "按产品说明服用",
|
|
|
+ "不与抗生素同服",
|
|
|
+ "益生菌按说明使用"
|
|
|
+ )) {
|
|
|
+ List<AttributionComplianceGate.Violation> v = gate.scan(safe);
|
|
|
+ assertTrue(v.isEmpty(), "误报: " + safe);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============================================================
|
|
|
+ // 白名单与常量
|
|
|
+ // ============================================================
|
|
|
+ @Test
|
|
|
+ void 措辞白名单非空且含规定三项() {
|
|
|
+ List<String> list = AttributionLowSideRules.WORDING_WHITELIST;
|
|
|
+ assertFalse(list.isEmpty());
|
|
|
+ assertTrue(list.contains("相关联"), "白名单须含「相关联」");
|
|
|
+ assertTrue(list.contains("报告提示"), "白名单须含「报告提示」");
|
|
|
+ assertTrue(list.contains("可能与…有关"), "白名单须含「可能与…有关」");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 分组码与规则码常量存在() {
|
|
|
+ assertEquals("G-1", AttributionLowSideRules.GROUP_CODE);
|
|
|
+ assertEquals("association_only", AttributionLowSideRules.RULE_ASSOCIATION_ONLY);
|
|
|
+ assertEquals("no_causal_bridge", AttributionLowSideRules.RULE_NO_CAUSAL_BRIDGE);
|
|
|
+ assertEquals("no_prescription", AttributionLowSideRules.RULE_NO_PRESCRIPTION);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============================================================
|
|
|
+ // merge 语义
|
|
|
+ // ============================================================
|
|
|
+ @Test
|
|
|
+ void merge_追加违规保留基础节点() {
|
|
|
+ MemberAttribution base = buildMember(1L, "女儿", Collections.singletonList(reading("情绪商数", "偏低", "35")));
|
|
|
+ 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(), "merge 应保留基础节点");
|
|
|
+ assertEquals(baseReport.associationOnlyCount(), merged.associationOnlyCount(), "merge 关联计数一致");
|
|
|
+ assertEquals(baseReport.getViolations().size() + extra.size(),
|
|
|
+ merged.getViolations().size(), "merge 应追加违规");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void merge_baseNull_extraNonNull_只取extra() {
|
|
|
+ List<AttributionComplianceGate.Violation> extra = gate.scan("每日100mg维C");
|
|
|
+ AttributionComplianceGate.GateReport merged = AttributionComplianceGate.merge(null, extra);
|
|
|
+ assertNotNull(merged);
|
|
|
+ assertTrue(merged.getNodes().isEmpty());
|
|
|
+ assertFalse(merged.getViolations().isEmpty());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---- helpers ----
|
|
|
+
|
|
|
+ private IndicatorReading reading(String display, String status, String value) {
|
|
|
+ IndicatorReading r = new IndicatorReading();
|
|
|
+ r.setCode(display);
|
|
|
+ r.setDisplayName(display);
|
|
|
+ r.setValue(value);
|
|
|
+ r.setStatus(status);
|
|
|
+ return r;
|
|
|
+ }
|
|
|
+
|
|
|
+ private 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);
|
|
|
+ if (r.isAbnormal() && "偏高".equals(r.getStatus()) && (r.getCode() + " " + r.getDisplayName()).toLowerCase().contains("reuteri")) {
|
|
|
+ r.setRetractable(true);
|
|
|
+ a.setRetractableCount(a.getRetractableCount() + 1);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return a;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static void assertViolationsContainRule(List<AttributionComplianceGate.Violation> vs, String rule) {
|
|
|
+ assertTrue(vs.stream().anyMatch(v -> rule.equals(v.getRule())),
|
|
|
+ "违规列表缺少规则码「" + rule + "」:" + vs);
|
|
|
+ }
|
|
|
+}
|