|
@@ -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");
|
|
|
|
|
+ }
|
|
|
|
|
+}
|