|
@@ -0,0 +1,281 @@
|
|
|
|
|
+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.DisplayName;
|
|
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
|
|
+
|
|
|
|
|
+import java.lang.reflect.Field;
|
|
|
|
|
+import java.lang.reflect.Method;
|
|
|
|
|
+import java.util.ArrayList;
|
|
|
|
|
+import java.util.Collections;
|
|
|
|
|
+import java.util.List;
|
|
|
|
|
+
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.fail;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 契约级单测:合规门禁「16S 粒度拦截 / 菌株级推荐阻断」(能力键 {@code guhe.gate.strainBlock})。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>契约来源(2026-11-08《浠艾福平台程序修改计划(guhe_v2 兼容 · 知识库低侧口径 · 专家同步)》§三任务分解 T6,P0,
|
|
|
|
|
+ * 合规门禁·横切 · G-2 菌株级拦截组):
|
|
|
|
|
+ * <blockquote>
|
|
|
|
|
+ * T6 16S 粒度拦截(G-2):<b>{@code granularity != strain} 时禁生成含菌株号的商品推荐</b>
|
|
|
|
|
+ * (叠加 §5.2 16S 粒度声明 —— 16S 只到菌属/种,不确知具体菌株号)。
|
|
|
|
|
+ * </blockquote></p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>契约语义</b>:本能力键是 <b>合规门禁 G-2(菌株级拦截组)</b> —— 在「非菌株级」粒度
|
|
|
|
|
+ * (16S/菌属层,默认粒度)下,归因/推荐产物不得出现「菌株名 + 菌株号」(如 长双歧杆菌 BB536、
|
|
|
|
|
+ * 乳双歧杆菌 HN019、鼠李糖乳杆菌 LGG、BB-12 等);一旦出现,门禁应识别为合规违规
|
|
|
|
|
+ * (G-2 菌株级拦截)并暴露,而非放行到商品推荐。菌株级粒度(granularity==strain)不受限。
|
|
|
|
|
+ * 与已落地的 T5 G-1 低侧红线组({@code association_only} / {@code no_causal_bridge} /
|
|
|
|
|
+ * {@code no_prescription})同构,但针对「16S 粒度 × 菌株号商品推荐」这一独立红线。</p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>实现现状(src/main 探测,2026-11-08)</b>:合规门禁框架与 G-1 低侧红线组已落地
|
|
|
|
|
+ * ({@code AttributionComplianceGate.enforceLowSide/scan/merge} + {@code AttributionLowSideRules}
|
|
|
|
|
+ * 三规则码 + 措辞白名单 + 禁写模式)。但 <b>T6 G-2 菌株级拦截未落地</b>:
|
|
|
|
|
+ * {@code AttributionLowSideRules} 无独立「菌株级拦截」规则码、{@code AttributionComplianceGate}
|
|
|
|
|
+ * 无按粒度拦菌株号推荐的门禁入口 {@code enforceStrainBlock}、L5 {@code scan} 只回扫
|
|
|
|
|
+ * 「缺X桥接 + 处方化」不识别菌株号、归因模型({@code IndicatorReading}/{@code MemberAttribution})
|
|
|
|
|
+ * 无粒度(granularity)维度。故本测试以「反射结构 + 纯规则行为」双通道探测实现缺失:能编译、
|
|
|
|
|
+ * 能运行、断言失败(红),失败断言原文清晰可贴。</p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>GREEN 升级指引</b>:实现者在 {@code AttributionLowSideRules} 增加
|
|
|
|
|
+ * {@code RULE_NO_STRAIN_RECOMMENDATION="no_strain_recommendation"} 与「非菌株级粒度禁菌株号推荐」
|
|
|
|
|
+ * 口径;在归因模型暴露粒度维度({@code IndicatorReading.granularity} / {@code isStrainGranularity()},
|
|
|
|
|
+ * 默认菌属级 = 非菌株级);在 {@code AttributionComplianceGate} 增加
|
|
|
|
|
+ * {@code enforceStrainBlock(List<MemberAttribution>)} 门禁入口并把菌株号推荐识别并入 {@code scan};
|
|
|
|
|
+ * 届时本测试自动转绿。</p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>权限流说明</b>:本能力为后端内部 L4/L5 合规门禁规则,无用户权限维度,权限流不适用,
|
|
|
|
|
+ * 以说明性用例标注。</p>
|
|
|
|
|
+ */
|
|
|
|
|
+class GuheGateStrainBlockContractTest {
|
|
|
|
|
+
|
|
|
|
|
+ /** T6 期望的独立规则码(区别于 T5 的 association_only / no_causal_bridge / no_prescription) */
|
|
|
|
|
+ private static final String RULE_NO_STRAIN_RECOMMENDATION = "no_strain_recommendation";
|
|
|
|
|
+
|
|
|
|
|
+ /** T6 期望的 G-2 门禁入口方法名(L4 出结论前按粒度拦菌株号推荐) */
|
|
|
|
|
+ private static final String GATE_METHOD = "enforceStrainBlock";
|
|
|
|
|
+
|
|
|
|
|
+ /** T6 期望的粒度维度:字段名 / 常量值 / 节点标记方法名 */
|
|
|
|
|
+ private static final String GRANULARITY_FIELD = "granularity";
|
|
|
|
|
+ private static final String GRANULARITY_STRAIN_VALUE = "strain";
|
|
|
|
|
+ private static final String GRANULARITY_FLAG_METHOD = "isStrainGranularity";
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 菌株号探测(16S 粒度下不应出现在推荐中的「菌株名 + 菌株号」模式):
|
|
|
|
|
+ * 菌名(双歧杆菌/乳杆菌/LGG 等)紧随字母数字菌株号(BB536/HN019/BB-12)。
|
|
|
|
|
+ */
|
|
|
|
|
+ private static final java.util.regex.Pattern STRAIN_NUMBER_PATTERN = java.util.regex.Pattern.compile(
|
|
|
|
|
+ "(双歧杆菌|乳杆菌|乳酸菌|益生菌|布拉氏酵母菌|鼠李糖乳杆菌|罗伊氏乳杆菌)"
|
|
|
|
|
+ + "[^。;,,\\n]{0,6}[A-Z0-9]{2,}[A-Z0-9\\-]{0,8}");
|
|
|
|
|
+
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+ // 正常流:G-2 菌株级拦截规则码 + 粒度维度 + 门禁入口
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-1:门禁规则层应暴露 T6「菌株级拦截」独立规则码")
|
|
|
|
|
+ void strainBlockRules_exposesStrainBlockRuleCode() {
|
|
|
|
|
+ String value = findConstantValue(AttributionLowSideRules.class, RULE_NO_STRAIN_RECOMMENDATION);
|
|
|
|
|
+ if (value == null) {
|
|
|
|
|
+ value = findConstantValue(AttributionComplianceGate.class, RULE_NO_STRAIN_RECOMMENDATION);
|
|
|
|
|
+ }
|
|
|
|
|
+ assertNotNull(value,
|
|
|
|
|
+ "[guhe.gate.strainBlock] 规则层(AttributionLowSideRules / AttributionComplianceGate)缺少 \""
|
|
|
|
|
+ + RULE_NO_STRAIN_RECOMMENDATION + "\" 规则码常量:T6(G-2)要求独立「16S 粒度拦截 / "
|
|
|
|
|
+ + "菌株级推荐阻断」规则码,区别于 T5 的 association_only/no_causal_bridge/no_prescription。"
|
|
|
|
|
+ + "当前仅落地 G-1 三规则码,未落地 T6 菌株级拦截。");
|
|
|
|
|
+ assertTrue(RULE_NO_STRAIN_RECOMMENDATION.equals(value),
|
|
|
|
|
+ "[guhe.gate.strainBlock] 菌株级拦截规则码应取值为 \"" + RULE_NO_STRAIN_RECOMMENDATION
|
|
|
|
|
+ + "\",实际为 \"" + value + "\"。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-2:归因模型应暴露粒度(granularity)维度且默认非菌株级")
|
|
|
|
|
+ void attributionModel_exposesGranularityDimension() {
|
|
|
|
|
+ boolean hasGranularityField = hasField(IndicatorReading.class, GRANULARITY_FIELD)
|
|
|
|
|
+ || hasField(MemberAttribution.class, GRANULARITY_FIELD);
|
|
|
|
|
+ boolean hasStrainConstant =
|
|
|
|
|
+ !allConstantValues(IndicatorReading.class, GRANULARITY_STRAIN_VALUE).isEmpty()
|
|
|
|
|
+ || !allConstantValues(MemberAttribution.class, GRANULARITY_STRAIN_VALUE).isEmpty();
|
|
|
|
|
+ boolean hasStrainFlagMethod = hasMethod(IndicatorReading.class, GRANULARITY_FLAG_METHOD)
|
|
|
|
|
+ || hasMethod(MemberAttribution.class, GRANULARITY_FLAG_METHOD);
|
|
|
|
|
+
|
|
|
|
|
+ assertTrue(hasGranularityField || hasStrainConstant || hasStrainFlagMethod,
|
|
|
|
|
+ "[guhe.gate.strainBlock] 归因模型(IndicatorReading/MemberAttribution)未暴露粒度(granularity)"
|
|
|
|
|
+ + "维度:T6(G-2)按「granularity != strain」拦菌株号推荐,需在读数/归因结论上区分"
|
|
|
|
|
+ + "「菌株级(strain)」与「菌属级/16S(非 strain,默认)」粒度。当前模型无 granularity 字段、"
|
|
|
|
|
+ + "无 strain 粒度常量、无 isStrainGranularity() 标记 —— 粒度维度未落地。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-3:合规门禁应暴露 enforceStrainBlock 入口(L4 出结论前按粒度拦菌株号推荐)")
|
|
|
|
|
+ void complianceGate_exposesEnforceStrainBlockEntry() {
|
|
|
|
|
+ assertTrue(hasMethod(AttributionComplianceGate.class, GATE_METHOD),
|
|
|
|
|
+ "[guhe.gate.strainBlock] AttributionComplianceGate 缺少 " + GATE_METHOD
|
|
|
|
|
+ + "(...) 门禁入口:T6(G-2)要求在 L4 出结论前按粒度拦「含菌株号的商品推荐」,"
|
|
|
|
|
+ + "与已落地的 enforceLowSide(G-1)同构。当前门禁仅 enforceLowSide + scan,"
|
|
|
|
|
+ + "未落地 T6 菌株级拦截入口。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+ // 边界流:16S 粒度下应拦「含菌株号的商品推荐」;scan 应回扫菌株号
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("边界流-1:16S 粒度下含菌株号的商品推荐应被 G-2 拦为违规")
|
|
|
|
|
+ void enforceStrainBlock_16SGranularityWithStrainNumberProduct_flagsViolation() {
|
|
|
|
|
+ // 菌属级(16S,非 strain)粒度 + 含菌株号的商品推荐 → 应产 G-2 违规
|
|
|
|
|
+ MemberAttribution attribution = genusLevelAttributionWithStrainProduct();
|
|
|
|
|
+ List<MemberAttribution> attributions = Collections.singletonList(attribution);
|
|
|
|
|
+
|
|
|
|
|
+ boolean entryAvailable = hasMethod(AttributionComplianceGate.class, GATE_METHOD);
|
|
|
|
|
+ if (!entryAvailable) {
|
|
|
|
|
+ fail("[guhe.gate.strainBlock] 16S 粒度下含菌株号的商品推荐未被拦(AttributionComplianceGate 缺少 "
|
|
|
|
|
+ + GATE_METHOD + " 入口):T6(G-2)要求 granularity != strain 时禁生成含菌株号的商品推荐,"
|
|
|
|
|
+ + "「推荐含长双歧杆菌 BB536 的产品」应在门禁被识别为 G-2 菌株级拦截违规。当前未落地。");
|
|
|
|
|
+ }
|
|
|
|
|
+ // 入口已落地:反射调用,取回 GateReport,断言违规清单非空且规则码为菌株级拦截
|
|
|
|
|
+ try {
|
|
|
|
|
+ AttributionComplianceGate gate = new AttributionComplianceGate();
|
|
|
|
|
+ Method m = AttributionComplianceGate.class.getDeclaredMethod(GATE_METHOD, List.class);
|
|
|
|
|
+ Object report = m.invoke(gate, attributions);
|
|
|
|
|
+ assertNotNull(report, "[guhe.gate.strainBlock] enforceStrainBlock 不应返回 null。");
|
|
|
|
|
+ Method violationsM = report.getClass().getDeclaredMethod("getViolations");
|
|
|
|
|
+ List<?> violations = (List<?>) violationsM.invoke(report);
|
|
|
|
|
+ assertNotNull(violations, "[guhe.gate.strainBlock] 违规清单不应为 null。");
|
|
|
|
|
+ boolean hit = false;
|
|
|
|
|
+ for (Object v : violations) {
|
|
|
|
|
+ Method ruleM = v.getClass().getDeclaredMethod("getRule");
|
|
|
|
|
+ if (RULE_NO_STRAIN_RECOMMENDATION.equals(String.valueOf(ruleM.invoke(v)))) {
|
|
|
|
|
+ hit = true;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ assertTrue(hit,
|
|
|
|
|
+ "[guhe.gate.strainBlock] 16S 粒度下「含菌株号的商品推荐」未被标记为 " + RULE_NO_STRAIN_RECOMMENDATION
|
|
|
|
|
+ + " 违规:T6(G-2)粒度拦截未对菌株号推荐生效。");
|
|
|
|
|
+ } catch (ReflectiveOperationException e) {
|
|
|
|
|
+ fail("[guhe.gate.strainBlock] 调用 enforceStrainBlock 失败:" + e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("边界流-2:L5 scan 应回扫「含菌株号的商品推荐」为 G-2 违规(当前仅 G-1 两类)")
|
|
|
|
|
+ void scan_strainNumberProductRecommendation_flagsStrainBlockViolation() {
|
|
|
|
|
+ AttributionComplianceGate gate = new AttributionComplianceGate();
|
|
|
|
|
+ // 16S 粒度下含具体菌株号的商品推荐文案(不应出现 BB536 等菌株号)
|
|
|
|
|
+ String recommendation =
|
|
|
|
|
+ "应对方向:推荐含长双歧杆菌 BB536 的益生元产品,帮助改善肠道环境。";
|
|
|
|
|
+ assertTrue(STRAIN_NUMBER_PATTERN.matcher(recommendation).find(),
|
|
|
|
|
+ "[guhe.gate.strainBlock] 测试夹具自检:推荐文案应含菌株号(长双歧杆菌 BB536),否则夹具失效。");
|
|
|
|
|
+
|
|
|
|
|
+ List<AttributionComplianceGate.Violation> violations = gate.scan(recommendation);
|
|
|
|
|
+ boolean hit = false;
|
|
|
|
|
+ for (AttributionComplianceGate.Violation v : violations) {
|
|
|
|
|
+ if (RULE_NO_STRAIN_RECOMMENDATION.equals(v.getRule())) {
|
|
|
|
|
+ hit = true;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ assertTrue(hit,
|
|
|
|
|
+ "[guhe.gate.strainBlock] L5 scan 未将「含菌株号的商品推荐」回扫为 " + RULE_NO_STRAIN_RECOMMENDATION
|
|
|
|
|
+ + " 违规:T6(G-2)要求 granularity != strain 时禁菌株号推荐,scan 应识别「长双歧杆菌 BB536」"
|
|
|
|
|
+ + "等菌株号。当前 scan 仅回扫 G-1 的「缺X桥接 + 处方化」,未落地菌株号识别(实际命中="
|
|
|
|
|
+ + violations.size() + " 条)。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+ // 权限流(不适用):后端内部 L4/L5 合规门禁规则,无用户权限维度
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("权限流:本能力为后端内部 L4/L5 合规门禁规则,无用户权限维度,权限流不适用")
|
|
|
|
|
+ void permissionFlow_notApplicable() {
|
|
|
|
|
+ // 说明性用例:契约 AC 为「L4/L5 门禁 → 菌株号商品推荐拦截」内部规则,无权限校验维度。
|
|
|
|
|
+ assertTrue(true, "权限流不适用:guhe.gate.strainBlock 为后端内部合规门禁规则,无用户权限维度。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+ // 反射与数据构造辅助
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ /** 在类(含指定候选类)静态字段中查找名称==name 的 String 常量值,找不到返回 null */
|
|
|
|
|
+ private static String findConstantValue(Class<?> clazz, String name) {
|
|
|
|
|
+ for (Field f : safeDeclaredFields(clazz)) {
|
|
|
|
|
+ if (f.getName().equals(name)) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ Object v = f.get(null);
|
|
|
|
|
+ return v == null ? null : String.valueOf(v);
|
|
|
|
|
+ } catch (IllegalAccessException ignored) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 取类中所有值==value 的静态 String 常量名 */
|
|
|
|
|
+ private static List<String> allConstantValues(Class<?> clazz, String value) {
|
|
|
|
|
+ List<String> out = new ArrayList<>();
|
|
|
|
|
+ for (Field f : safeDeclaredFields(clazz)) {
|
|
|
|
|
+ if (f.getType() == String.class && java.lang.reflect.Modifier.isStatic(f.getModifiers())) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (value.equals(String.valueOf(f.get(null)))) {
|
|
|
|
|
+ out.add(f.getName());
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (IllegalAccessException ignored) {
|
|
|
|
|
+ // 忽略
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return out;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static boolean hasField(Class<?> clazz, String name) {
|
|
|
|
|
+ for (Field f : safeDeclaredFields(clazz)) {
|
|
|
|
|
+ if (f.getName().equals(name)) {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static boolean hasMethod(Class<?> clazz, String name) {
|
|
|
|
|
+ for (Method m : clazz.getDeclaredMethods()) {
|
|
|
|
|
+ if (m.getName().equals(name)) {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static Field[] safeDeclaredFields(Class<?> clazz) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ return clazz.getDeclaredFields();
|
|
|
|
|
+ } catch (Throwable t) {
|
|
|
|
|
+ return new Field[0];
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 菌属级(16S,非 strain)粒度的归因结论,携带一条「含菌株号的商品推荐」应对方向。
|
|
|
|
|
+ * 16S 只到菌属/种,不应出现 BB536 等具体菌株号(§5.2 16S 粒度声明)。
|
|
|
|
|
+ */
|
|
|
|
|
+ private static MemberAttribution genusLevelAttributionWithStrainProduct() {
|
|
|
|
|
+ MemberAttribution a = new MemberAttribution();
|
|
|
|
|
+ a.setMemberId(1L);
|
|
|
|
|
+ a.setName("妈妈");
|
|
|
|
|
+ a.setRole("mom");
|
|
|
|
|
+ a.setRoleLabel("妈妈");
|
|
|
|
|
+ a.getActions().add("推荐含长双歧杆菌 BB536 的益生元产品");
|
|
|
|
|
+ a.getActions().add("建议补充膳食纤维,调整饮食结构");
|
|
|
|
|
+ return a;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|