|
|
@@ -0,0 +1,325 @@
|
|
|
+package com.etotem.cfc.unit;
|
|
|
+
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+
|
|
|
+import java.io.InputStream;
|
|
|
+import java.lang.reflect.Method;
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Scanner;
|
|
|
+
|
|
|
+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.digest},单元 team,任务 S-cfc-47,TDD-RED)。
|
|
|
+ *
|
|
|
+ * <p><b>需求/设计锚点(规格 v0.7)</b>:
|
|
|
+ * {@code docs/superpowers/specs/2026-11-07-referral-team-profit-sharing.md}</p>
|
|
|
+ * <ul>
|
|
|
+ * <li>需求:§12-T8(星级简报推送 P2)—— 每月 1 日重评后发微信订阅消息(升/降/持平 + 行动建议);
|
|
|
+ * 受影响范围=§4.3.1 ③④(已拍板 #13:按推荐全做,简报 P2)</li>
|
|
|
+ * <li>设计:§4.3.1 ③ 月度星级简报 ——
|
|
|
+ * 升星:恭喜 + 新星级 + 解锁分佣份数 + 权益(微信订阅消息);
|
|
|
+ * 降星:提示 + 原因(滚动窗口滑出)+ 需补多少分回原级;
|
|
|
+ * 持平:距下一星差多少 + 本月行动建议(复用 §4.3.1 ② 换算);
|
|
|
+ * 数据源:{@code team_star_snapshot} 本月 vs 上月对比(§7.6 表 + {@code uk_user_period} 唯一键)。</li>
|
|
|
+ * <li>设计:§4.3.1 ④ 星级权益可见 —— 星级徽章(团队页展示)、培训/专家课优先、带教与领队资格、
|
|
|
+ * 年度表彰;每个星级页展示「本档权益 / 下一档权益 / 达成条件」。</li>
|
|
|
+ * <li>数据:§7.6 新表 {@code team_star_snapshot}(user_id / team_id / star_level 1-6 / score /
|
|
|
+ * direct_rev_cent / team_rev_cent / referral_consume_cent / period 'YYYYMM',
|
|
|
+ * UNIQUE KEY {@code uk_user_period(user_id, period)})。</li>
|
|
|
+ * <li>推送口径:每月 1 日 00:30 定时重评后触发(§4.3 / §12-T8),复用微信订阅消息模板。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>宿主契约(TDD-RED 锚点)</b>:{@code com.etotem.cfc.service.StarDigestService} ——
|
|
|
+ * 月度星级简报生成组件(本文档属能力键 {@code team.star.digest} 的新增契约,当前 src/main
|
|
|
+ * 未实现该组件,全库 0 引用)。本测试通过反射锚定宿主契约方法(避免直接 import 不存在类导致
|
|
|
+ * 测试编译失败),运行时宿主类缺失即断言失败(红),暴露「实现未落地」这一真实缺陷;
|
|
|
+ * 实现落地后转为验证真实行为(GREEN)。</p>
|
|
|
+ *
|
|
|
+ * <p><b>契约方法签名(实现方按此落地,约 param 个数的宽松反射匹配)</b></p>
|
|
|
+ * <ul>
|
|
|
+ * <li>{@code int digestTrend(int currentStarLevel, int previousStarLevel)}
|
|
|
+ * —— 升/降/持平分类:+1=升星 / 0=持平 / -1=降星(§4.3.1 ③);previous=0(首期无上月快照)
|
|
|
+ * 按持平处理,不虚报升/降</li>
|
|
|
+ * <li>{@code String digestMonthly(long userId, String period)}
|
|
|
+ * —— 月度简报正文入口(本月 vs 上月 {@code team_star_snapshot} 对比后按三态出稿,§12-T8)</li>
|
|
|
+ * <li>{@code String upgradeMessage(long userId, int newStarLevel)}
|
|
|
+ * —— 升星稿件:恭喜 + 新星级 + 解锁分佣份数(每层按星级拿份数 = 新星级份)+ 星级权益</li>
|
|
|
+ * <li>{@code String downgradeMessage(long userId, int oldStarLevel, int currentStarLevel, int currentScore)}
|
|
|
+ * —— 降星稿件:提示 + 原因(滚动窗口滑出)+ 需补多少分回原级</li>
|
|
|
+ * <li>{@code String flatMessage(long userId, int currentStarLevel, int currentScore)}
|
|
|
+ * —— 持平稿件:距下一档差多少 + 本月行动建议(复用②换算);六星(顶流)无下一档不再虚构差距</li>
|
|
|
+ * <li>{@code List<String> starBenefits(int starLevel)}
|
|
|
+ * —— 该星级的可感知权益清单(星级徽章 / 培训与专家课优先 / 带教与领队资格 / 年度表彰,§4.3.1 ④),
|
|
|
+ * 高星权益只多不少</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>测试范围</b>:纯契约级单测(JUnit + 反射 / 读 schema.sql),无 Spring / 无 DB / 无真实微信,
|
|
|
+ * 无外部服务依赖。</p>
|
|
|
+ *
|
|
|
+ * <p><b>权限流</b>:简报为<b>服务端主动推送</b>(每月 1 日重评后发微信订阅消息),
|
|
|
+ * 收件人即成员本人,无用户鉴权分支,权限流不适用(说明性用例标注)。</p>
|
|
|
+ *
|
|
|
+ * <p><b>RED 预期</b>:宿主 {@code StarDigestService} 未实现、{@code team_star_snapshot} 表
|
|
|
+ * 未落地 schema.sql —— 行为与结构类用例在反射/断言处失败(红),断言原文含「[team.star.digest]」前缀,
|
|
|
+ * 便于贴日志回归。</p>
|
|
|
+ */
|
|
|
+class TeamStarDigestContractTest {
|
|
|
+
|
|
|
+ /** 规格设计宿主类(TDD-RED 契约锚点,当前未实现)。 */
|
|
|
+ private static final String HOST_CLASS = "com.etotem.cfc.service.StarDigestService";
|
|
|
+
|
|
|
+ /** §7.6 月度星级快照表名。 */
|
|
|
+ private static final String SNAPSHOT_TABLE = "team_star_snapshot";
|
|
|
+
|
|
|
+ /** §7.6 唯一键(user_id, period):本月 vs 上月对比基础。 */
|
|
|
+ private static final String SNAPSHOT_UNIQUE_KEY = "uk_user_period";
|
|
|
+
|
|
|
+ /** 契约测试用户(仅作占位入参)。 */
|
|
|
+ private static final long USER_ID = 88L;
|
|
|
+
|
|
|
+ // ==================== 正常流:升/降/持平 三态分类(§4.3.1 ③) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 正常流1_宿主StarDigestService_应存在() {
|
|
|
+ assertNotNull(loadClass(HOST_CLASS),
|
|
|
+ "[team.star.digest] StarDigestService 类不存在:契约要求月度星级简报组件"
|
|
|
+ + "(每月 1 日重评后发微信订阅消息:升/降/持平 + 行动建议,§12-T8 / §4.3.1 ③④)。实现未落地。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 正常流2_升星_趋势分类为升() {
|
|
|
+ Object host = contractHost();
|
|
|
+ assertEquals(1, callInt(host, "digestTrend", 3, 2),
|
|
|
+ "[team.star.digest] 当月 3 星 vs 上月 2 星应分类为升星(+1);升星简报:恭喜 + 新星级 + 解锁份数 + 权益(§4.3.1 ③)");
|
|
|
+ assertEquals(1, callInt(host, "digestTrend", 6, 5),
|
|
|
+ "[team.star.digest] 6星 vs 5星 应分类为升星(+1)");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 正常流3_降星_趋势分类为降() {
|
|
|
+ Object host = contractHost();
|
|
|
+ assertEquals(-1, callInt(host, "digestTrend", 2, 3),
|
|
|
+ "[team.star.digest] 当月 2 星 vs 上月 3 星应分类为降星(-1);降星简报:提示 + 原因(滚动窗口滑出)+ 补分回原级(§4.3.1 ③)");
|
|
|
+ assertEquals(-1, callInt(host, "digestTrend", 4, 6),
|
|
|
+ "[team.star.digest] 6星降至4星 应分类为降星(-1)");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 正常流4_持平_趋势分类为持平() {
|
|
|
+ Object host = contractHost();
|
|
|
+ assertEquals(0, callInt(host, "digestTrend", 3, 3),
|
|
|
+ "[team.star.digest] 当月与上月同星应分类为持平(0);持平简报:距下一星差多少 + 本月行动建议(§4.3.1 ③)");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 正常流:三态简报稿件(§4.3.1 ③) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 正常流5_升星简报_含恭喜新星级解锁份数与权益() {
|
|
|
+ Object host = contractHost();
|
|
|
+ String msg = callString(host, "upgradeMessage", USER_ID, 4);
|
|
|
+ assertNotNull(msg, "[team.star.digest] 升星简报不能为空");
|
|
|
+ assertTrue(msg.contains("恭喜"),
|
|
|
+ "[team.star.digest] 升星简报应含「恭喜」(§4.3.1 ③ 升星:恭喜 + 新星级 + 解锁分佣份数 + 权益)");
|
|
|
+ assertTrue(msg.contains("4") && msg.contains("星"),
|
|
|
+ "[team.star.digest] 升星简报应含新星级(4星)");
|
|
|
+ assertTrue(msg.contains("份"),
|
|
|
+ "[team.star.digest] 升星简报应含解锁分佣份数(每层按星级拿份数:4★=4份,§4.3 八份制)");
|
|
|
+ assertTrue(msg.contains("权益"),
|
|
|
+ "[team.star.digest] 升星简报应含星级权益(§4.3.1 ④ 权益可见)");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 正常流6_降星简报_含滚动窗口滑出原因与补分回原级() {
|
|
|
+ Object host = contractHost();
|
|
|
+ String msg = callString(host, "downgradeMessage", USER_ID, 4, 2, 50000);
|
|
|
+ assertNotNull(msg, "[team.star.digest] 降星简报不能为空");
|
|
|
+ assertTrue(msg.contains("滑出"),
|
|
|
+ "[team.star.digest] 降星简报应含原因「滚动窗口滑出」(§4.3.1 ③ 降星:原因(滚动窗口滑出))");
|
|
|
+ assertTrue(msg.contains("补"),
|
|
|
+ "[team.star.digest] 降星简报应提示需补多少分回原级(§4.3.1 ③)");
|
|
|
+ assertFalse(msg.contains("恭喜"),
|
|
|
+ "[team.star.digest] 降星简报不应误报升星");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 正常流7_持平简报_含距下一档差距与本月行动建议() {
|
|
|
+ Object host = contractHost();
|
|
|
+ String msg = callString(host, "flatMessage", USER_ID, 3, 72000);
|
|
|
+ assertNotNull(msg, "[team.star.digest] 持平简报不能为空");
|
|
|
+ assertTrue(msg.contains("距"),
|
|
|
+ "[team.star.digest] 持平简报应含距下一档差距(§4.3.1 ③ 持平:距下一星差多少)");
|
|
|
+ assertTrue(msg.contains("行动"),
|
|
|
+ "[team.star.digest] 持平简报应含本月行动建议(§4.3.1 ③,复用②差距→行动换算)");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 正常流:星级权益可见(§4.3.1 ④) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 正常流8_各星级权益清单_非空且含星级徽章() {
|
|
|
+ Object host = contractHost();
|
|
|
+ for (int level = 1; level <= 6; level++) {
|
|
|
+ List<String> benefits = callStringList(host, "starBenefits", level);
|
|
|
+ assertNotNull(benefits,
|
|
|
+ "[team.star.digest] " + level + "★ 权益清单不能为 null(§4.3.1 ④)");
|
|
|
+ assertFalse(benefits.isEmpty(),
|
|
|
+ "[team.star.digest] " + level + "★ 权益清单不能为空:星级必须绑定可感知权益");
|
|
|
+ assertTrue(benefits.contains("星级徽章"),
|
|
|
+ "[team.star.digest] " + level + "★ 权益应含「星级徽章」(团队页展示,§4.3.1 ④)");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 正常流9_高星权益_只多不少() {
|
|
|
+ Object host = contractHost();
|
|
|
+ List<String> low = callStringList(host, "starBenefits", 1);
|
|
|
+ List<String> high = callStringList(host, "starBenefits", 6);
|
|
|
+ assertTrue(high.size() >= low.size(),
|
|
|
+ "[team.star.digest] 权益随星级增长只多不少:6★ 权益数(" + high.size()
|
|
|
+ + ") 应 ≥ 1★ 权益数(" + low.size() + ")(星级「有用」,§4.3.1 ④)");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 边界流 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 边界流1_六星顶格_持平简报不再虚构下一档差距() {
|
|
|
+ Object host = contractHost();
|
|
|
+ assertEquals(0, callInt(host, "digestTrend", 6, 6), "6★ 对 6★ 应为持平");
|
|
|
+ String topMsg = callString(host, "flatMessage", USER_ID, 6, 250000);
|
|
|
+ assertFalse(topMsg.contains("下一星"),
|
|
|
+ "[team.star.digest] 六星(顶流团队长)已顶格,持平简报不应虚构「下一星」差距(§4.3 六星=最高档)");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 边界流2_首期无上月快照_按持平处理不虚报升降() {
|
|
|
+ Object host = contractHost();
|
|
|
+ assertEquals(0, callInt(host, "digestTrend", 3, 0),
|
|
|
+ "[team.star.digest] 首期无上月快照(previous=0)应无升降结论(持平),不虚报升/降"
|
|
|
+ + "(数据源=team_star_snapshot 本月 vs 上月对比,§4.3.1 ③)");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 结构/数据契约(§12-T8 触发与 §7.6 表) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 结构契约_月度简报入口digestMonthly_应存在() {
|
|
|
+ Object host = contractHost();
|
|
|
+ boolean hasEntry = false;
|
|
|
+ for (Method m : host.getClass().getDeclaredMethods()) {
|
|
|
+ if (m.getName().equals("digestMonthly") && m.getParameterCount() == 2) {
|
|
|
+ hasEntry = true;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ assertTrue(hasEntry,
|
|
|
+ "[team.star.digest] StarDigestService 应提供 digestMonthly(long userId, String period) 简报入口"
|
|
|
+ + "(每月 1 日重评后触发推送,本月 vs 上月 team_star_snapshot 对比,§12-T8)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 数据契约_team_star_snapshot表与uk_user_period_应落地schemaSql() {
|
|
|
+ String schema = readSchemaSql();
|
|
|
+ assertTrue(schema.contains(SNAPSHOT_TABLE),
|
|
|
+ "[team.star.digest] schema.sql 缺少 " + SNAPSHOT_TABLE + " 建表(§7.6 月度星级快照,"
|
|
|
+ + "简报数据源:本月 vs 上月对比)。");
|
|
|
+ assertTrue(schema.contains(SNAPSHOT_UNIQUE_KEY),
|
|
|
+ "[team.star.digest] " + SNAPSHOT_TABLE + " 应含 " + SNAPSHOT_UNIQUE_KEY + " 唯一键"
|
|
|
+ + "(user_id, period),供月度简报取本月/上月对比(§7.6)。");
|
|
|
+ assertTrue(schema.contains("star_level"),
|
|
|
+ "[team.star.digest] " + SNAPSHOT_TABLE + " 应含 star_level 列(1-6,趋势分类依据,§7.6)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 权限流(不适用说明) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 权限流_服务端主动推送_无用户权限维度() {
|
|
|
+ // 说明性用例:月度星级简报为服务端定时主动推送(每月 1 日重评后触发),收件人即成员本人,
|
|
|
+ // 契约 AC 无用户鉴权分支,权限流不适用;不产生断言失败,仅为三流覆盖标注「不适用」。
|
|
|
+ assertTrue(true, "权限流不适用:team.star.digest 为服务端主动推送,无用户权限维度。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 反射辅助(锚定宿主契约,避免编译期依赖不存在类) ====================
|
|
|
+
|
|
|
+ private Class<?> loadClass(String name) {
|
|
|
+ try {
|
|
|
+ return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
|
|
|
+ } catch (ClassNotFoundException e) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private Object contractHost() {
|
|
|
+ try {
|
|
|
+ Class<?> clazz = Class.forName(HOST_CLASS);
|
|
|
+ return clazz.getDeclaredConstructor().newInstance();
|
|
|
+ } catch (ClassNotFoundException e) {
|
|
|
+ fail("RED[team.star.digest] 契约宿主 StarDigestService 未实现(实现未落地):" + e.getMessage());
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
+ } catch (Exception e) {
|
|
|
+ fail("契约宿主 StarDigestService 实例化失败:" + 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.digest] 契约方法 " + HOST_CLASS + "#" + method + " 未实现");
|
|
|
+ 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 String callString(Object host, String method, Object... args) {
|
|
|
+ Object r = call(host, method, args);
|
|
|
+ if (r instanceof String) {
|
|
|
+ return (String) r;
|
|
|
+ }
|
|
|
+ fail("契约方法 " + method + " 应返回字符串,实际 " + r);
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
+ }
|
|
|
+
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ private List<String> callStringList(Object host, String method, Object... args) {
|
|
|
+ Object r = call(host, method, args);
|
|
|
+ if (r instanceof List) {
|
|
|
+ return (List<String>) r;
|
|
|
+ }
|
|
|
+ fail("契约方法 " + method + " 应返回 List,实际 " + r);
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
+ }
|
|
|
+
|
|
|
+ private String readSchemaSql() {
|
|
|
+ try (InputStream is = getClass().getClassLoader().getResourceAsStream("schema.sql")) {
|
|
|
+ if (is == null) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ try (Scanner scanner = new Scanner(is, StandardCharsets.UTF_8.name())) {
|
|
|
+ scanner.useDelimiter("\\A");
|
|
|
+ return scanner.hasNext() ? scanner.next() : "";
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|