Pārlūkot izejas kodu

merge(S-cfc-81): sync cfclub (S-cfc-91 concurrent push) into task/S-cfc-81 before push

Sisyphus Agent 4 dienas atpakaļ
vecāks
revīzija
d5c656cf31

+ 311 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/GuheEvidenceIsolateContractTest.java

@@ -0,0 +1,311 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.constant.AttributionLowSideRules;
+import com.etotem.cfc.service.attribution.AttributionComplianceGate;
+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.Arrays;
+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;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * 契约级单测:谷禾 v2 低侧口径·证据码隔离(能力键 {@code guhe.gate.evidenceIsolate})。
+ *
+ * <p>契约来源(2026-11-08《浠艾福平台程序修改计划(guhe_v2 兼容 · 知识库低侧口径 · 专家同步)》§三任务分解 T11,P1):
+ * <blockquote>
+ *   T11 证据码隔离(G-3):<b>低侧机制证据标 PUB/KB;COM 只作存在性记录;未核实编号标「未核实」</b>。
+ *   <br>§一 合规门禁(横切)③:证据码隔离(<b>COM 不进推荐依据</b>)。
+ * </blockquote></p>
+ *
+ * <p><b>契约语义</b>:本能力是<b>合规门禁 G-3 证据码隔离</b>——归因结论 / 推荐的每条证据都必须被举证「证据码」,
+ * 证据码隔离规则限定证据进推荐依据的口径:
+ * <ul>
+ *   <li>{@code PUB}(公开文献)/{@code KB}(知识库)→ 可进推荐依据(低侧机制证据标 PUB/KB);</li>
+ *   <li>{@code COM}(存在性记录)→ <b>只作存在性记录,绝不能进推荐依据</b>;</li>
+ *   <li>未核实的编号 → 统一标「未核实」,不得充当推荐依据。</li>
+ * </ul></p>
+ *
+ * <p><b>实现现状(src/main 探测,2026-11-08)</b>:横切合规门禁框架已落地——
+ * G-1 低侧红线组({@link AttributionLowSideRules} / {@link AttributionComplianceGate})已合入主链
+ * ({@code attribution_only} / {@code no_causal_bridge} / {@code no_prescription}),
+ * 但 <b>G-3 证据码隔离未落地</b>:{@link AttributionLowSideRules} 无 {@code RULE_EVIDENCE_ISOLATE} /
+ * {@code EVIDENCE_PUB} / {@code EVIDENCE_KB} / {@code EVIDENCE_COM} / {@code EVIDENCE_UNVERIFIED} 常量,
+ * {@link AttributionComplianceGate} 无 {@code tagEvidence} / {@code canEnterRecommendationBasis} /
+ * {@code isExistenceRecordOnly} / {@code isolateForRecommendation} 方法(全库 0 引用)。
+ * 因此本测试以「反射常量探测 + 反射方法契约探测」双通道验证契约缺失:能编译、能运行、断言失败(红),
+ * 且失败断言原文清晰可贴。</p>
+ *
+ * <p><b>RED 预期</b>:常量用例与门禁方法用例均应失败(红)——G-3 证据码隔离未落地。</p>
+ *
+ * <p><b>GREEN 升级指引</b>:实现者在 {@link AttributionLowSideRules} 增加
+ * {@code RULE_EVIDENCE_ISOLATE="evidence_isolate"}、{@code EVIDENCE_PUB="PUB"}、
+ * {@code EVIDENCE_KB="KB"}、{@code EVIDENCE_COM="COM"}、{@code EVIDENCE_UNVERIFIED="未核实"} 常量,
+ * 并在 {@link AttributionComplianceGate} 暴露以下确定性方法后,本测试自动转绿:
+ * <ul>
+ *   <li>{@code String tagEvidence(String evidenceCode)} —— 证据码规范化:PUB/KB/COM 原样保留;空/null/未知编号 → "未核实"</li>
+ *   <li>{@code boolean canEnterRecommendationBasis(String evidenceCode)} —— 仅 PUB/KB 可进推荐依据;COM/未核实/未知 → false</li>
+ *   <li>{@code boolean isExistenceRecordOnly(String evidenceCode)} —— 仅 COM 为存在性记录</li>
+ *   <li>{@code List<String> isolateForRecommendation(List<String> evidenceCodes)} —— 过滤仅保留 PUB/KB(保序,剔除 COM 与「未核实」)</li>
+ * </ul></p>
+ *
+ * <p><b>权限流说明</b>:本能力为后端内部横切合规门禁(L4 出结论前 / L5 出文档前),无用户权限维度,
+ * 权限流不适用,以说明性用例标注。</p>
+ */
+class GuheEvidenceIsolateContractTest {
+
+    /** G-3 期望的规则码(区别于 G-1 的 association_only / no_causal_bridge / no_prescription) */
+    private static final String RULE_EVIDENCE_ISOLATE = "evidence_isolate";
+
+    /** 证据码:公开文献(可进推荐依据) */
+    private static final String EVIDENCE_PUB = "PUB";
+
+    /** 证据码:知识库(可进推荐依据) */
+    private static final String EVIDENCE_KB = "KB";
+
+    /** 证据码:存在性记录(只作存在性记录,不进推荐依据) */
+    private static final String EVIDENCE_COM = "COM";
+
+    /** 未核实标注(未核实编号统一标此值) */
+    private static final String EVIDENCE_UNVERIFIED = "未核实";
+
+    // ------------------------------------------------------------------
+    // 正常流:规则层应暴露 G-3 证据码隔离常量
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-1:AttributionLowSideRules 应暴露 RULE_EVIDENCE_ISOLATE 规则码")
+    void lowSideRules_exposesEvidenceIsolateRuleCode() {
+        Field f = declaredField(AttributionLowSideRules.class, "RULE_EVIDENCE_ISOLATE");
+        assertNotNull(f,
+                "[guhe.gate.evidenceIsolate] AttributionLowSideRules 缺少 RULE_EVIDENCE_ISOLATE 规则码:"
+                        + "T11(G-3)要求独立「证据码隔离」合规规则——低侧机制证据标 PUB/KB;COM 只作存在性记录;"
+                        + "未核实编号标「未核实」。当前仅落地 G-1 三规则(association_only / no_causal_bridge / no_prescription),"
+                        + "未区分「G-3 证据码隔离」。");
+        try {
+            Object v = f.get(null);
+            assertNotNull(v, "[guhe.gate.evidenceIsolate] RULE_EVIDENCE_ISOLATE 常量值不应为 null。");
+            assertTrue(RULE_EVIDENCE_ISOLATE.equals(String.valueOf(v)),
+                    "[guhe.gate.evidenceIsolate] RULE_EVIDENCE_ISOLATE 应取值为 \"" + RULE_EVIDENCE_ISOLATE
+                            + "\",实际为 \"" + v + "\"。");
+        } catch (IllegalAccessException e) {
+            assertTrue(false, "[guhe.gate.evidenceIsolate] 读取 RULE_EVIDENCE_ISOLATE 失败:" + e.getMessage());
+        }
+    }
+
+    @Test
+    @DisplayName("正常流-2:AttributionLowSideRules 应暴露可进推荐依据的证据码 PUB/KB")
+    void lowSideRules_exposesRecommendationBasisEvidenceCodes() {
+        assertConstantEquals("EVIDENCE_PUB", EVIDENCE_PUB);
+        assertConstantEquals("EVIDENCE_KB", EVIDENCE_KB);
+    }
+
+    @Test
+    @DisplayName("正常流-3:AttributionLowSideRules 应暴露存在性记录证据码 COM 与「未核实」标注")
+    void lowSideRules_exposesComAndUnverifiedMarkers() {
+        assertConstantEquals("EVIDENCE_COM", EVIDENCE_COM);
+        assertConstantEquals("EVIDENCE_UNVERIFIED", EVIDENCE_UNVERIFIED);
+    }
+
+    // ------------------------------------------------------------------
+    // 正常流:门禁应暴露证据码隔离方法(tagEvidence / canEnterRecommendationBasis)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-4:AttributionComplianceGate 应暴露 tagEvidence(String) 证据码规范化")
+    void gate_exposesTagEvidence() {
+        assertMethodExists("tagEvidence", 1);
+    }
+
+    @Test
+    @DisplayName("正常流-5:PUB/KB 低侧机制证据经 tagEvidence 应原样保留")
+    void tagEvidence_pubKb_unchanged() {
+        Object gate = new AttributionComplianceGate();
+        assertEquals(EVIDENCE_PUB, call(gate, "tagEvidence", EVIDENCE_PUB),
+                "PUB 证据码应原样保留(低侧机制证据标 PUB/KB)");
+        assertEquals(EVIDENCE_KB, call(gate, "tagEvidence", EVIDENCE_KB),
+                "KB 证据码应原样保留(低侧机制证据标 PUB/KB)");
+    }
+
+    @Test
+    @DisplayName("正常流-6:PUB/KB 证据码应可进推荐依据")
+    void canEnterRecommendationBasis_pubKb_true() {
+        Object gate = new AttributionComplianceGate();
+        assertTrue(callBoolean(gate, "canEnterRecommendationBasis", EVIDENCE_PUB),
+                "PUB 证据码可进推荐依据");
+        assertTrue(callBoolean(gate, "canEnterRecommendationBasis", EVIDENCE_KB),
+                "KB 证据码可进推荐依据");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:未核实编号标「未核实」且不得进推荐依据
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-1:未知/空/未核实证据编号应统一标「未核实」")
+    void tagEvidence_unknownOrBlank_markedUnverified() {
+        Object gate = new AttributionComplianceGate();
+        assertEquals(EVIDENCE_UNVERIFIED, call(gate, "tagEvidence", "COM-870"),
+                "未核实的编号(如 COM-870)应统一标「未核实」");
+        assertEquals(EVIDENCE_UNVERIFIED, call(gate, "tagEvidence", ""),
+                "空证据码应标「未核实」");
+        assertEquals(EVIDENCE_UNVERIFIED, call(gate, "tagEvidence", null),
+                "null 证据码应标「未核实」");
+        assertEquals(EVIDENCE_UNVERIFIED, call(gate, "tagEvidence", "未知-编号"),
+                "未知证据码应标「未核实」");
+    }
+
+    @Test
+    @DisplayName("边界流-2:未核实编号不得进推荐依据")
+    void canEnterRecommendationBasis_unverified_false() {
+        Object gate = new AttributionComplianceGate();
+        assertFalse(callBoolean(gate, "canEnterRecommendationBasis", EVIDENCE_UNVERIFIED),
+                "「未核实」必须排除出推荐依据(未核实编号标「未核实」后不得充当依据)");
+        assertFalse(callBoolean(gate, "canEnterRecommendationBasis", "COM-870"),
+                "未核实的编号(如 COM-870)不得进推荐依据");
+        assertFalse(callBoolean(gate, "canEnterRecommendationBasis", ""),
+                "空证据码不得进推荐依据");
+        assertFalse(callBoolean(gate, "canEnterRecommendationBasis", null),
+                "null 证据码不得进推荐依据");
+    }
+
+    // ------------------------------------------------------------------
+    // 合规流:COM 只作存在性记录、不进推荐依据(G-3 核心红线)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("合规流-1:COM 证据码不得进推荐依据")
+    void canEnterRecommendationBasis_com_false() {
+        Object gate = new AttributionComplianceGate();
+        assertFalse(callBoolean(gate, "canEnterRecommendationBasis", EVIDENCE_COM),
+                "COM 只作存在性记录,必须排除出推荐依据(§一 合规门禁③:COM 不进推荐依据)");
+    }
+
+    @Test
+    @DisplayName("合规流-2:COM 证据码应判定为存在性记录,PUB 不是")
+    void isExistenceRecordOnly_com_true_pubFalse() {
+        Object gate = new AttributionComplianceGate();
+        assertTrue(callBoolean(gate, "isExistenceRecordOnly", EVIDENCE_COM),
+                "COM 应判定为存在性记录(只作存在性记录,不进推荐依据)");
+        assertFalse(callBoolean(gate, "isExistenceRecordOnly", EVIDENCE_PUB),
+                "PUB 不是存在性记录(可进推荐依据)");
+        assertFalse(callBoolean(gate, "isExistenceRecordOnly", EVIDENCE_KB),
+                "KB 不是存在性记录(可进推荐依据)");
+    }
+
+    @Test
+    @DisplayName("合规流-3:isolateForRecommendation 应过滤仅保留 PUB/KB(剔除 COM 与「未核实」)")
+    void isolateForRecommendation_filtersComAndUnverified() {
+        Object gate = new AttributionComplianceGate();
+        List<String> filtered = callListString(gate, "isolateForRecommendation",
+                Arrays.asList(EVIDENCE_PUB, EVIDENCE_COM, EVIDENCE_KB, EVIDENCE_UNVERIFIED, "COM-870"));
+        assertNotNull(filtered, "isolateForRecommendation 不应返回 null");
+        assertEquals(Arrays.asList(EVIDENCE_PUB, EVIDENCE_KB), filtered,
+                "推荐依据证据码应仅含 PUB/KB(保序);COM 与「未核实」必须被隔离,"
+                        + "实际为 " + filtered);
+    }
+
+    // ------------------------------------------------------------------
+    // 权限流(不适用):后端内部横切合规门禁,无用户权限维度
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("权限流:本能力为后端内部横切合规门禁,无用户权限维度,权限流不适用")
+    void permissionFlow_notApplicable() {
+        // 说明性用例:契约 AC 为「归因证据码 → 合规门禁隔离」内部规则,无权限校验维度。
+        // 不产生断言失败;仅为三流覆盖标注「不适用」。
+        assertTrue(true, "权限流不适用:guhe.gate.evidenceIsolate 为后端内部横切合规门禁,无用户权限维度。");
+    }
+
+    // ------------------------------------------------------------------
+    // 反射辅助
+    // ------------------------------------------------------------------
+
+    /** 按字段名探测并读取 public static final 常量,缺失即以清晰原文失败(红)。 */
+    private static void assertConstantEquals(String fieldName, String expected) {
+        Field f = declaredField(AttributionLowSideRules.class, fieldName);
+        assertNotNull(f,
+                "[guhe.gate.evidenceIsolate] AttributionLowSideRules 缺少 " + fieldName
+                        + " 常量:T11(G-3)要求证据码隔离常量(可进推荐依据的 PUB/KB、存在性记录 COM、"
+                        + "「未核实」标注),当前未落地。");
+        try {
+            Object v = f.get(null);
+            assertNotNull(v, "[guhe.gate.evidenceIsolate] " + fieldName + " 常量值不应为 null。");
+            assertTrue(expected.equals(String.valueOf(v)),
+                    "[guhe.gate.evidenceIsolate] " + fieldName + " 应取值为 \"" + expected
+                            + "\",实际为 \"" + v + "\"。");
+        } catch (IllegalAccessException e) {
+            assertTrue(false, "[guhe.gate.evidenceIsolate] 读取 " + fieldName + " 失败:" + e.getMessage());
+        }
+    }
+
+    /** 按方法名 + 参数个数探测门禁方法是否暴露(缺失即失败(红),GREEN 落地后继续走行为断言)。 */
+    private static void assertMethodExists(String methodName, int paramCount) {
+        boolean found = false;
+        for (Method m : AttributionComplianceGate.class.getDeclaredMethods()) {
+            if (m.getName().equals(methodName) && m.getParameterCount() == paramCount) {
+                found = true;
+                break;
+            }
+        }
+        assertTrue(found,
+                "[guhe.gate.evidenceIsolate] AttributionComplianceGate 缺少 " + methodName
+                        + "(" + paramCount + " 参) 方法:T11(G-3)证据码隔离契约要求门禁暴露该确定性方法,"
+                        + "当前未落地(仅 G-1 的 enforceLowSide / scan / merge 已实现)。");
+    }
+
+    /** 按方法名 + 参数个数匹配并调用(宽松适配基本/包装类型),契约方法缺失即断言失败。 */
+    private static Object call(Object host, String method, Object... args) {
+        for (Method m : host.getClass().getMethods()) {
+            if (m.getName().equals(method) && m.getParameterCount() == args.length) {
+                try {
+                    return m.invoke(host, args);
+                } catch (Exception e) {
+                    fail("[guhe.gate.evidenceIsolate] 契约方法 "
+                            + host.getClass().getSimpleName() + "#" + method + "(" + args.length + " 参) 调用失败:"
+                            + e.getMessage());
+                    throw new IllegalStateException("unreachable");
+                }
+            }
+        }
+        fail("[guhe.gate.evidenceIsolate] 契约方法 " + host.getClass().getSimpleName() + "#"
+                + method + " 未实现(G-3 证据码隔离未落地,T11)");
+        throw new IllegalStateException("unreachable");
+    }
+
+    private static boolean callBoolean(Object host, String method, Object... args) {
+        Object r = call(host, method, args);
+        if (r instanceof Boolean) {
+            return (Boolean) r;
+        }
+        fail("[guhe.gate.evidenceIsolate] 契约方法 " + method + " 应返回布尔,实际 " + r);
+        throw new IllegalStateException("unreachable");
+    }
+
+    @SuppressWarnings("unchecked")
+    private static List<String> callListString(Object host, String method, Object... args) {
+        Object r = call(host, method, args);
+        if (r instanceof List) {
+            return (List<String>) r;
+        }
+        fail("[guhe.gate.evidenceIsolate] 契约方法 " + method + " 应返回 List,实际 " + r);
+        throw new IllegalStateException("unreachable");
+    }
+
+    private static Field declaredField(Class<?> clazz, String name) {
+        for (Field f : clazz.getDeclaredFields()) {
+            if (f.getName().equals(name)) {
+                f.setAccessible(true);
+                return f;
+            }
+        }
+        return null;
+    }
+}