Procházet zdrojové kódy

merge(task/S-cfc-49): RED 契约级单测并入主链

Agnes Agent před 5 dny
rodič
revize
360b18d21

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

@@ -0,0 +1,311 @@
+package com.etotem.cfc.unit;
+
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Method;
+import java.util.List;
+import java.util.Map;
+
+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;
+
+/**
+ * 契约级单测:团队星级倍率权重(能力键 {@code team.star.multiplier},单元 team)。
+ *
+ * <p><b>需求/设计锚点(规格 v0.7)</b></p>
+ * <ul>
+ *   <li>需求:{@code docs/superpowers/specs/2026-11-07-referral-team-profit-sharing.md}
+ *       §12-T10(星级倍率与里程碑)+ §11-#14(已拍板)+ §10#5(合规红线)</li>
+ *   <li>设计:§4.7.2 杠杆 2/3/4 —— 杠杆 2 星级倍率权重(6星=3.0,恒≤T×80% 只重分不增量)
+ *       + 杠杆 3 带教里程碑奖(星耀导师)+ 杠杆 4 订单完成实时显示本单分润</li>
+ *   <li>数学校验(§4.7.2 示例,¥500 咨询,8 份池 40.8 元 = 4080 分):
+ *       服务者 6★(W=18) + 引荐人 1★(W=1) + 第 3 层 1★(W=1),总 W=20 → 6★ 得
+ *       {@code 4080×18/20 = 3672} 分;引荐人 {@code 204};第 3 层 {@code 204};
+ *       Σ=4080 = 8 份池 ✓(只重分不增量,不超 T 预算)</li>
+ * </ul>
+ *
+ * <p><b>被校验的契约行为</b></p>
+ * <ul>
+ *   <li>杠杆 2 倍率表:{@code team_profit.star_multiplier} 默认
+ *       1★=1.0 / 2★=1.0 / 3★=1.2 / 4★=1.5 / 5★=2.0 / 6★=3.0;权重 {@code W = 星级 × 倍率}</li>
+ *   <li>杠杆 2 分配:8 份池按星级权重比例分配,资金池恒 = T×80%(只重分不增量)</li>
+ *   <li>杠杆 3 星耀导师:带出 1 名 5★ → 团队奖档位自动 +1;带出 2 名 5★ → 比例上限 100% + 年度星耀会候选人</li>
+ *   <li>杠杆 4 实时可见:订单完成即时返回本单团队分润(pending 状态即可见)</li>
+ * </ul>
+ *
+ * <p><b>宿主契约(TDD-RED 锚点)</b>:{@code com.etotem.cfc.service.TeamProfitService} ——
+ * 规格 §12-T2/T10 规划的服务类,当前(2026-11-08)<b>尚未实现</b>。本测试通过反射锚定该
+ * 宿主契约方法(避免直接 import 不存在类导致编译失败),运行时宿主类缺失即断言失败(红),
+ * 暴露"实现未落地"这一真实缺陷;实现落地后转为验证真实行为(GREEN)。</p>
+ *
+ * <p><b>契约方法签名(实现方按此落地)</b></p>
+ * <ul>
+ *   <li>{@code double starMultiplier(int starLevel)} —— 返回该星级倍率系数(1-6)</li>
+ *   <li>{@code double starWeight(int starLevel)} —— 返回权重 W = 星级 × 倍率</li>
+ *   <li>{@code Map<Integer,Double> starMultiplierTable()} —— 返回 6 档默认倍率表(1★..6★)</li>
+ *   <li>{@code List<Long> allocateEightShareByWeight(int[] starLevels, long eightSharePoolCent)}
+ *       —— 按星级权重分配 8 份池,返回每人分得(分);数组长度 = 服务链层数(1-3),
+ *       0 表示该层无人(份额回流,不私发)</li>
+ *   <li>{@code int mentorMilestoneTierBonus(int fiveStarMentees)} —— 带出 5★ 成员数 → 团队奖档位偏移</li>
+ *   <li>{@code boolean mentorMilestoneCapAndCandidates(int fiveStarMentees)}
+ *       —— 带出 ≥2 名 5★ → 比例上限 100% + 年度星耀会候选人</li>
+ *   <li>{@code long orderSharePendingCent(long orderId, long userId)} —— 订单完成本单团队分润(分,pending 可见)</li>
+ * </ul>
+ *
+ * <p><b>测试范围</b>:纯契约级单测,无 Spring / 无 DB / 无真实微信,无外部服务依赖。</p>
+ * <p><b>权限流</b>:本能力面向平台服务团队(规划师/测评师),不涉及外部引荐链鉴权分支,
+ * 无独立角色权限断言(与规格 §4.5 归属口径一致),故权限流不适用。</p>
+ *
+ * <p><b>RED 预期</b>:宿主 {@code TeamProfitService} 未实现,全部用例在反射探测处失败(红),
+ * 断言原文为「契约宿主 TeamProfitService 未实现」。</p>
+ */
+class TeamStarMultiplierContractTest {
+
+    /** 规格设计宿主类(未实现)。 */
+    private static final String HOST_CLASS = "com.etotem.cfc.service.TeamProfitService";
+
+    // ==================== 契约常量(规格 §4.7.2 默认值) ====================
+
+    /** 8 份池示例:¥500 咨询,D=17000 分、T=D×30%=5100 分、8 份池 = T×80% = 4080 分。 */
+    private static final long EIGHT_SHARE_POOL_CENT = 4080L;
+
+    /** 数学校验:6★+1★+1★ 期望份额(分)。 */
+    private static final long EXPECT_6STAR_CENT = 3672L;
+    private static final long EXPECT_1STAR_CENT = 204L;
+
+    // ==================== 杠杆 2:星级倍率表(§4.7.2) ====================
+
+    @Test
+    void starMultiplier_defaultTable_sixTiersMatchSpec() {
+        Object host = contractHost();
+        Map<Integer, Double> table = callMap(host, "starMultiplierTable");
+        assertNotNull(table, "starMultiplierTable() 应返回 6 档倍率表");
+        assertEquals(6, table.size(), "倍率表应为 6 档(1★..6★)");
+        assertEquals(1.0, table.get(1), 1e-9, "1★ 倍率应=1.0");
+        assertEquals(1.0, table.get(2), 1e-9, "2★ 倍率应=1.0(1-2星 1.0)");
+        assertEquals(1.2, table.get(3), 1e-9, "3★ 倍率应=1.2");
+        assertEquals(1.5, table.get(4), 1e-9, "4★ 倍率应=1.5");
+        assertEquals(2.0, table.get(5), 1e-9, "5★ 倍率应=2.0");
+        assertEquals(3.0, table.get(6), 1e-9, "6★ 倍率应=3.0(顶流团队长,杠杆2 核心)");
+    }
+
+    @Test
+    void starMultiplier_sixStar_isThreePointZero() {
+        Object host = contractHost();
+        double m = callDouble(host, "starMultiplier", 6);
+        assertEquals(3.0, m, 1e-9, "6★ 倍率必须为 3.0(规格 §4.7.2 杠杆2)");
+    }
+
+    @Test
+    void starMultiplier_lowStar_noBoost() {
+        Object host = contractHost();
+        assertEquals(1.0, callDouble(host, "starMultiplier", 1), 1e-9, "1★ 无倾斜");
+        assertEquals(1.0, callDouble(host, "starMultiplier", 2), 1e-9, "2★ 无倾斜");
+    }
+
+    @Test
+    void starWeight_equalsStarTimesMultiplier() {
+        Object host = contractHost();
+        // W(6★) = 6 × 3.0 = 18;W(1★) = 1 × 1.0 = 1(§4.7.2 数学校验)
+        assertEquals(18.0, callDouble(host, "starWeight", 6), 1e-9, "6★ 权重 W=18");
+        assertEquals(1.0, callDouble(host, "starWeight", 1), 1e-9, "1★ 权重 W=1");
+        assertEquals(3.6, callDouble(host, "starWeight", 3), 1e-9, "3★ 权重 W=3.6");
+    }
+
+    // ==================== 杠杆 2:8 份池按权重分配 + 只重分不增量 ====================
+
+    @Test
+    void allocateEightShare_specMath_6_1_1_shares() {
+        Object host = contractHost();
+        // 数学校验:6★(W=18) + 1★(W=1) + 1★(W=1),总 W=20
+        List<Long> shares = callListLong(host, "allocateEightShareByWeight",
+                new int[]{6, 1, 1}, EIGHT_SHARE_POOL_CENT);
+        assertNotNull(shares, "应返回 3 层各自分得份额");
+        assertEquals(3, shares.size(), "服务链 3 层");
+        assertEquals(EXPECT_6STAR_CENT, shares.get(0).longValue(),
+                "6★ 应得 4080×18/20=3672 分(原固定份数 30.6 元 +20%)");
+        assertEquals(EXPECT_1STAR_CENT, shares.get(1).longValue(),
+                "引荐人 1★ 应得 4080×1/20=204 分");
+        assertEquals(EXPECT_1STAR_CENT, shares.get(2).longValue(),
+                "第3层 1★ 应得 4080×1/20=204 分");
+    }
+
+    @Test
+    void allocateEightShare_conservation_sumEqualsPool() {
+        Object host = contractHost();
+        List<Long> shares = callListLong(host, "allocateEightShareByWeight",
+                new int[]{6, 1, 1}, EIGHT_SHARE_POOL_CENT);
+        long sum = sum(shares);
+        assertEquals(EIGHT_SHARE_POOL_CENT, sum,
+                "只重分不增量:Σ份额必须恒 = 8 份池(T×80%),不超 T 预算");
+    }
+
+    @Test
+    void allocateEightShare_conservation_highStarPool() {
+        Object host = contractHost();
+        // 边界:全 6★ 组合,Σ 仍 = 8 份池
+        List<Long> shares = callListLong(host, "allocateEightShareByWeight",
+                new int[]{6, 6, 6}, EIGHT_SHARE_POOL_CENT);
+        assertEquals(EIGHT_SHARE_POOL_CENT, sum(shares), "全6★ Σ=8份池");
+        // 边界:全 1★ 组合,Σ 仍 = 8 份池
+        List<Long> sharesLow = callListLong(host, "allocateEightShareByWeight",
+                new int[]{1, 1, 1}, EIGHT_SHARE_POOL_CENT);
+        assertEquals(EIGHT_SHARE_POOL_CENT, sum(sharesLow), "全1★ Σ=8份池");
+    }
+
+    @Test
+    void allocateEightShare_reflow_emptyLayerNeverPrivatelyGranted() {
+        Object host = contractHost();
+        // 边界:第 2/3 层无人(0 表示该层无人)→ 份额回流,不私发;Σ ≤ 8 份池(只重分不增量)
+        List<Long> shares = callListLong(host, "allocateEightShareByWeight",
+                new int[]{6, 0, 0}, EIGHT_SHARE_POOL_CENT);
+        assertNotNull(shares, "应返回 3 层结果(无人层为 0)");
+        long sum = sum(shares);
+        assertTrue(sum <= EIGHT_SHARE_POOL_CENT,
+                "无人层份额必须回流(不私发),Σ=" + sum + " 应 ≤ 8份池 " + EIGHT_SHARE_POOL_CENT);
+    }
+
+    // ==================== 杠杆 3:带教里程碑奖(星耀导师) ====================
+
+    @Test
+    void mentorMilestone_oneFiveStar_tierPlusOne() {
+        Object host = contractHost();
+        int bonus = callInt(host, "mentorMilestoneTierBonus", 1);
+        assertEquals(1, bonus,
+                "带出 1 名 5★ → 团队奖档位自动 +1(TA1→TA2),§4.7.2 杠杆3");
+    }
+
+    @Test
+    void mentorMilestone_twoFiveStar_cap100AndCandidate() {
+        Object host = contractHost();
+        assertTrue(callBoolean(host, "mentorMilestoneCapAndCandidates", 2),
+                "带出 2 名 5★ → 团队奖比例上限 100% + 年度星耀会候选人");
+    }
+
+    @Test
+    void mentorMilestone_zeroFiveStar_noMilestone() {
+        Object host = contractHost();
+        assertEquals(0, callInt(host, "mentorMilestoneTierBonus", 0),
+                "无 5★ 带出 → 无档位加成");
+        assertFalse(callBoolean(host, "mentorMilestoneCapAndCandidates", 0),
+                "无 5★ 带出 → 不触发 100% 上限 / 星耀会");
+    }
+
+    // ==================== 杠杆 4:订单完成实时显示本单分润 ====================
+
+    @Test
+    void orderShare_pending_visibleAtCompletion() {
+        Object host = contractHost();
+        long share = callLong(host, "orderSharePendingCent", 1001L, 88L);
+        assertTrue(share > 0, "订单完成即时显示本单团队分润(pending 可见),share=" + share);
+    }
+
+    @Test
+    void orderShare_zeroPool_zeroShare() {
+        Object host = contractHost();
+        // 边界:D ≤ 0(低于保本线)→ 本单不分润,显示 0
+        long share = callLong(host, "orderSharePendingCent", 1002L, 88L);
+        assertTrue(share >= 0, "D≤0 时本单分润为 0(只记录不发放),share=" + share);
+    }
+
+    // ==================== 反射辅助(锚定宿主契约,避免编译期依赖不存在类) ====================
+
+    private static long sum(List<Long> list) {
+        long s = 0;
+        for (Long v : list) {
+            if (v != null) {
+                s += v;
+            }
+        }
+        return s;
+    }
+
+    private Object contractHost() {
+        try {
+            Class<?> clazz = Class.forName(HOST_CLASS);
+            return clazz.getDeclaredConstructor().newInstance();
+        } catch (ClassNotFoundException e) {
+            fail("RED[team.star.multiplier] 契约宿主 TeamProfitService 未实现(实现未落地):"
+                    + e.getMessage());
+            throw new IllegalStateException("unreachable");
+        } catch (Exception e) {
+            fail("契约宿主 TeamProfitService 实例化失败:" + e.getMessage());
+            throw new IllegalStateException("unreachable");
+        }
+    }
+
+    /** 按方法名 + 参数个数匹配并调用(宽松适配基本/包装类型),契约方法缺失即断言失败。 */
+    private 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("契约方法 " + HOST_CLASS + "#" + method + " 调用失败:" + e.getMessage());
+                    throw new IllegalStateException("unreachable");
+                }
+            }
+        }
+        fail("RED[team.star.multiplier] 契约方法 " + HOST_CLASS + "#" + method + " 未实现");
+        throw new IllegalStateException("unreachable");
+    }
+
+    private double callDouble(Object host, String method, Object... args) {
+        Object r = call(host, method, args);
+        if (r instanceof Number) {
+            return ((Number) r).doubleValue();
+        }
+        fail("契约方法 " + method + " 应返回数值,实际 " + r);
+        throw new IllegalStateException("unreachable");
+    }
+
+    private int callInt(Object host, String method, Object... args) {
+        Object r = call(host, method, args);
+        if (r instanceof Number) {
+            return ((Number) r).intValue();
+        }
+        fail("契约方法 " + method + " 应返回整数,实际 " + r);
+        throw new IllegalStateException("unreachable");
+    }
+
+    private long callLong(Object host, String method, Object... args) {
+        Object r = call(host, method, args);
+        if (r instanceof Number) {
+            return ((Number) r).longValue();
+        }
+        fail("契约方法 " + method + " 应返回长整数,实际 " + r);
+        throw new IllegalStateException("unreachable");
+    }
+
+    private boolean callBoolean(Object host, String method, Object... args) {
+        Object r = call(host, method, args);
+        if (r instanceof Boolean) {
+            return (Boolean) r;
+        }
+        fail("契约方法 " + method + " 应返回布尔,实际 " + r);
+        throw new IllegalStateException("unreachable");
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<Integer, Double> callMap(Object host, String method) {
+        Object r = call(host, method);
+        if (r instanceof Map) {
+            return (Map<Integer, Double>) r;
+        }
+        fail("契约方法 " + method + " 应返回 Map,实际 " + r);
+        throw new IllegalStateException("unreachable");
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<Long> callListLong(Object host, String method, Object... args) {
+        Object r = call(host, method, args);
+        if (r instanceof List) {
+            return (List<Long>) r;
+        }
+        fail("契约方法 " + method + " 应返回 List,实际 " + r);
+        throw new IllegalStateException("unreachable");
+    }
+}