|
@@ -0,0 +1,452 @@
|
|
|
|
|
+package com.etotem.cfc.unit;
|
|
|
|
|
+
|
|
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
|
|
+
|
|
|
|
|
+import java.io.InputStream;
|
|
|
|
|
+import java.lang.reflect.Field;
|
|
|
|
|
+import java.lang.reflect.Method;
|
|
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
|
|
+import java.util.Arrays;
|
|
|
|
|
+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;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 契约级单测:AI 成长教练·主动成长提醒与方案主动跟进(能力键 {@code ai.coach.proactive})。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>需求/设计溯源</b></p>
|
|
|
|
|
+ * <ul>
|
|
|
|
|
+ * <li>场景:{@code tests/user-scenarios/应用场景总览.md} <b>SC-B-02</b>(主动成长提醒:
|
|
|
|
|
+ * 按用药/饮水/作息/测评到期/打卡主动提醒,流程 配置→触发→推送;边界 免打扰生效、重复去重、仅本家庭)
|
|
|
|
|
+ * 与 <b>SC-B-12</b>(同步规划师建议主动跟进:方案生效后教练主动跟进打卡/进度,
|
|
|
|
|
+ * 流程 同步→节奏推送→停滞提示;边界 未激活不触发、可暂停)。</li>
|
|
|
|
|
+ * <li>派生用例:{@code tests/test-cases/butler.md} <b>TC-B-02</b>(正常,推送经
|
|
|
|
|
+ * {@code /api/notify/*} + 孩子端只读)、<b>TC-B-02-E</b>(免打扰 22:00-08:00 不推、关键安全提醒仍推、
|
|
|
|
|
+ * 重复 3 次去重仅 1 条、推送通道故障落站内信兜底)、<b>TC-B-02-P</b>(家长为 F9 孩子配置 403、
|
|
|
|
|
+ * 孩子无配置权 403、查询他人提醒 403)、<b>TC-B-12</b>(激活同步、节奏推送、停滞 3 天提示、可暂停)、
|
|
|
|
|
+ * <b>TC-B-12-E</b>(未激活不触发、重同步不残留、引用已删任务收敛、推送失败落站内信)。</li>
|
|
|
|
|
+ * <li>接口:{@code docs/superpowers/api/API_REFERENCE.md} <b>§4.15</b>({@code /api/stats/*} 与
|
|
|
|
|
+ * {@code /api/notification/list|read});提醒投递落点为已具备的站内通知通道
|
|
|
|
|
+ * ({@code UserNotificationController} + {@code service/notification/InAppNotificationChannel}
|
|
|
|
|
+ * → {@code user_notifications} 表),免打扰/去重/仅本家庭在「主动提醒调度」侧实现。</li>
|
|
|
|
|
+ * </ul>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>实现现状</b>:本分支 src/main 中「主动提醒调度」(用药/饮水/作息/测评到期/打卡)、
|
|
|
|
|
+ * 「方案主动跟进」两套契约宿主均未落地(全库 0 引用,schema.sql 无对应建表)。因此本测试以
|
|
|
|
|
+ * 「反射结构契约 + schema.sql 快照」探测实现缺失:能编译通过、能运行、断言失败(红),
|
|
|
|
|
+ * 且失败断言原文清晰可贴。已具备的站内通知通道({@code UserNotificationController} /
|
|
|
|
|
+ * {@code InAppNotificationChannel})在本测试中以「落点已具备」说明性用例标注,不作缺失断言。</p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>GREEN 升级指引</b>:实现者创建 {@code ProactiveReminderService}(含 {@code deliverReminder}
|
|
|
|
|
+ * 投递入口、{@code isInDoNotDisturb} 免打扰、{@code isDuplicate} 去重)、{@code ProactiveReminderScheduledTask}
|
|
|
|
|
+ * (定时触发)、{@code CoachFollowUpService}(方案激活同步/暂停/停滞探测),建 {@code proactive_reminder} /
|
|
|
|
|
+ * {@code proactive_reminder_log} / {@code coach_follow_up_config} 表与实体后,本测试自动转绿;
|
|
|
|
|
+ * 届时可将结构探测升级为行为级 mock 验证({@code verify(...)})。</p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>权限流说明</b>:TC-B-02-P 的「仅本家庭」由 {@code proactive_reminder.family_id} 承载
|
|
|
|
|
+ * (提醒记录必须携带 familyId 才能在投递/查询侧做家庭域校验),故权限流以「实体携带 family_id 字段」
|
|
|
|
|
+ * 结构锚定,不写独立 403 行为用例(服务宿主缺失,行为流属 GREEN 阶段)。</p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>测试范围</b>:纯契约级单测,无 Spring / 无 DB / 无外部服务依赖(纯反射 + schema.sql 读取)。</p>
|
|
|
|
|
+ */
|
|
|
|
|
+class CoachProactiveContractTest {
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 契约宿主类(实现未落地,Class.forName 探测) ====================
|
|
|
|
|
+
|
|
|
|
|
+ /** SC-B-02:主动提醒调度服务宿主。 */
|
|
|
|
|
+ private static final String HOST_REMINDER_SERVICE = "com.etotem.cfc.service.ProactiveReminderService";
|
|
|
|
|
+
|
|
|
|
|
+ /** SC-B-02:主动提醒定时任务宿主(遵循 task/ 包 XxxScheduledTask 命名约定)。 */
|
|
|
|
|
+ private static final String HOST_REMINDER_TASK = "com.etotem.cfc.task.ProactiveReminderScheduledTask";
|
|
|
|
|
+
|
|
|
|
|
+ /** SC-B-12:方案主动跟进服务宿主。 */
|
|
|
|
|
+ private static final String HOST_FOLLOWUP_SERVICE = "com.etotem.cfc.service.CoachFollowUpService";
|
|
|
|
|
+
|
|
|
|
|
+ /** SC-B-02:提醒配置实体。 */
|
|
|
|
|
+ private static final String ENTITY_REMINDER = "com.etotem.cfc.entity.ProactiveReminder";
|
|
|
|
|
+
|
|
|
|
|
+ /** SC-B-02:提醒投递日志实体(去重)。 */
|
|
|
|
|
+ private static final String ENTITY_REMINDER_LOG = "com.etotem.cfc.entity.ProactiveReminderLog";
|
|
|
|
|
+
|
|
|
|
|
+ /** SC-B-12:方案跟进配置实体。 */
|
|
|
|
|
+ private static final String ENTITY_FOLLOWUP = "com.etotem.cfc.entity.CoachFollowUpConfig";
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 契约常量 ====================
|
|
|
|
|
+
|
|
|
|
|
+ /** 免打扰窗口(TC-B-02-E):22:00 - 08:00。 */
|
|
|
|
|
+ private static final int DND_START_HOUR = 22;
|
|
|
|
|
+ private static final int DND_END_HOUR = 8;
|
|
|
|
|
+
|
|
|
|
|
+ /** 五类主动提醒(SC-B-02:用药/饮水/作息/测评到期/打卡)。 */
|
|
|
|
|
+ private static final String[] REMINDER_TYPES = {
|
|
|
|
|
+ "medication", "water", "sleep", "assessment_due", "checkin"
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ /** 关键安全提醒白名单(免打扰仍推送,TC-B-02-E):用药。 */
|
|
|
|
|
+ private static final String CRITICAL_TYPE_MEDICATION = "medication";
|
|
|
|
|
+
|
|
|
|
|
+ /** 停滞判定阈值(TC-B-12):3 天未打卡进度 → 主动提示家长。 */
|
|
|
|
|
+ private static final int STAGNATION_DAYS = 3;
|
|
|
|
|
+
|
|
|
|
|
+ /** 三张新表。 */
|
|
|
|
|
+ private static final String[] NEW_TABLES = {
|
|
|
|
|
+ "proactive_reminder", "proactive_reminder_log", "coach_follow_up_config"
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== SC-B-02 正常流:配置→触发→推送 ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("SC-B-02 正常-1:ProactiveReminder 提醒配置实体应存在(表 proactive_reminder)")
|
|
|
|
|
+ void reminderConfig_entityExists() {
|
|
|
|
|
+ assertContractClass(ENTITY_REMINDER, "ai.coach.proactive",
|
|
|
|
|
+ "ProactiveReminder 实体未实现:SC-B-02 要求「配置→触发→推送」,"
|
|
|
|
|
+ + "需 proactive_reminder 表承载用药/饮水/作息/测评到期/打卡提醒配置。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("SC-B-02 正常-2:提醒配置实体应携带 familyId(仅本家庭数据基础)")
|
|
|
|
|
+ void reminderConfig_entityCarriesFamilyScope() {
|
|
|
|
|
+ assertContractField(ENTITY_REMINDER, "familyId", "ai.coach.proactive",
|
|
|
|
|
+ "ProactiveReminder 应含 familyId 字段(proactive_reminder.family_id):"
|
|
|
|
|
+ + "提醒记录必须绑定家庭,才能实现「仅本家庭」域校验(TC-B-02-P)。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("SC-B-02 正常-3:ProactiveReminderService 调度服务应存在")
|
|
|
|
|
+ void reminderService_classExists() {
|
|
|
|
|
+ assertContractClass(HOST_REMINDER_SERVICE, "ai.coach.proactive",
|
|
|
|
|
+ "ProactiveReminderService 未实现:SC-B-02 主动提醒调度(配置/触发/推送)需服务宿主。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("SC-B-02 正常-4:调度服务应提供 deliverReminder 投递入口(推送)")
|
|
|
|
|
+ void reminderService_hasDeliverReminder() {
|
|
|
|
|
+ assertContractMethod(HOST_REMINDER_SERVICE, "deliverReminder", 8,
|
|
|
|
|
+ "ProactiveReminderService 应提供 deliverReminder(userId,familyId,type,refType,refId,title,content)"
|
|
|
|
|
+ + " 投递入口,将到点提醒经站内通知通道(InAppNotificationChannel → user_notifications)推送。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("SC-B-02 正常-5:调度服务应提供 triggerDueReminders 到点触发入口")
|
|
|
|
|
+ void reminderService_hasTriggerDue() {
|
|
|
|
|
+ assertContractMethod(HOST_REMINDER_SERVICE, "triggerDueReminders", 1,
|
|
|
|
|
+ "ProactiveReminderService 应提供 triggerDueReminders(nowEpochMs) 到点触发入口:"
|
|
|
|
|
+ + "遍历已配置提醒项,命中当前时点即投递。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("SC-B-02 正常-6:ProactiveReminderScheduledTask 定时任务宿主应存在")
|
|
|
|
|
+ void reminderTask_classExists() {
|
|
|
|
|
+ assertContractClass(HOST_REMINDER_TASK, "ai.coach.proactive",
|
|
|
|
|
+ "ProactiveReminderScheduledTask 未实现:主动提醒需定时调度宿主"
|
|
|
|
|
+ + "(遵循 task/ 包 XxxScheduledTask 命名约定)。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("SC-B-02 正常-7:定时任务应提供 runBatch 批次执行入口")
|
|
|
|
|
+ void reminderTask_hasRunBatch() {
|
|
|
|
|
+ assertContractMethod(HOST_REMINDER_TASK, "runBatch", 0,
|
|
|
|
|
+ "ProactiveReminderScheduledTask 应提供 runBatch() 批次执行入口"
|
|
|
|
|
+ + "(@Scheduled 触发,返回处理条数)。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("SC-B-02 正常-8:五类提醒类型应齐全(用药/饮水/作息/测评到期/打卡)")
|
|
|
|
|
+ void reminderTypes_coverFiveSpecTypes() {
|
|
|
|
|
+ Object host = contractHost(HOST_REMINDER_SERVICE, "ai.coach.proactive");
|
|
|
|
|
+ String[] types = callStringArray(host, "reminderTypes");
|
|
|
|
|
+ for (String t : REMINDER_TYPES) {
|
|
|
|
|
+ assertTrue(Arrays.asList(types).contains(t),
|
|
|
|
|
+ "reminderTypes() 应包含「" + t + "」(用药/饮水/作息/测评到期/打卡,SC-B-02),"
|
|
|
|
|
+ + "实际=" + Arrays.toString(types));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== SC-B-02 边界流:免打扰 / 去重 / 仅本家庭 ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("SC-B-02 边界-1:免打扰窗口应为 22:00 - 08:00(夜间)")
|
|
|
|
|
+ void dnd_window_nightly22to08() {
|
|
|
|
|
+ Object host = contractHost(HOST_REMINDER_SERVICE, "ai.coach.proactive");
|
|
|
|
|
+ assertEquals(DND_START_HOUR, callInt(host, "dndStartHour"),
|
|
|
|
|
+ "免打扰开始小时必须为 22(TC-B-02-E:免打扰 22:00-08:00)");
|
|
|
|
|
+ assertEquals(DND_END_HOUR, callInt(host, "dndEndHour"),
|
|
|
|
|
+ "免打扰结束小时必须为 8(TC-B-02-E:免打扰 22:00-08:00)");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("SC-B-02 边界-2:免打扰时段普通提醒应不推送(次晨合并投递)")
|
|
|
|
|
+ void dnd_suppressesNormalReminder() {
|
|
|
|
|
+ Object host = contractHost(HOST_REMINDER_SERVICE, "ai.coach.proactive");
|
|
|
|
|
+ // 23:00 在免打扰窗口(22:00-08:00)内,非关键普通提醒 → 不推
|
|
|
|
|
+ assertTrue(callBoolean(host, "isInDoNotDisturb", 23),
|
|
|
|
|
+ "23 点应在免打扰窗口内(22:00-08:00),普通提醒不推送(TC-B-02-E)");
|
|
|
|
|
+ assertTrue(callBoolean(host, "isInDoNotDisturb", 2),
|
|
|
|
|
+ "凌晨 2 点应在免打扰窗口内(22:00-08:00),普通提醒不推送(TC-B-02-E)");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("SC-B-02 边界-3:免打扰时段关键安全提醒仍应推送(白名单不生效)")
|
|
|
|
|
+ void dnd_stillPushesCriticalSafetyReminder() {
|
|
|
|
|
+ Object host = contractHost(HOST_REMINDER_SERVICE, "ai.coach.proactive");
|
|
|
|
|
+ // 23:00 免打扰窗口内、关键安全提醒(用药)→ 仍推
|
|
|
|
|
+ boolean pushed = callBoolean(host, "shouldPushNow", CRITICAL_TYPE_MEDICATION, true, true);
|
|
|
|
|
+ assertTrue(pushed,
|
|
|
|
|
+ "免打扰时段内关键安全提醒(用药)仍须推送(白名单不生效于免打扰,TC-B-02-E)");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("SC-B-02 边界-4:同一提醒重复触发应去重仅投递 1 条(isDuplicate)")
|
|
|
|
|
+ void dedup_sameReminderRepeated_deliversOnlyOnce() {
|
|
|
|
|
+ Object host = contractHost(HOST_REMINDER_SERVICE, "ai.coach.proactive");
|
|
|
|
|
+ long dayStart = 1_700_000_000_000L;
|
|
|
|
|
+ assertTrue(callBoolean(host, "isDuplicate", 10L, "medication", "task", 900L, dayStart),
|
|
|
|
|
+ "同一提醒(同 userId/type/refType/refId)当日重复触发应判定为重复(去重,TC-B-02-E:"
|
|
|
|
|
+ + "重复 3 次仅投递 1 条)");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("SC-B-02 边界-5:去重投递日志实体应存在(表 proactive_reminder_log)")
|
|
|
|
|
+ void dedup_logEntityExists() {
|
|
|
|
|
+ assertContractClass(ENTITY_REMINDER_LOG, "ai.coach.proactive",
|
|
|
|
|
+ "ProactiveReminderLog 实体未实现:去重需 proactive_reminder_log 记录当日已投递"
|
|
|
|
|
+ + "(userId/type/refType/refId/deliveredAt)判定重复(TC-B-02-E)。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("SC-B-02 边界-6:三张新表应落地 schema.sql(proactive_reminder/_log/coach_follow_up_config)")
|
|
|
|
|
+ void schemaSql_containsThreeNewTables() {
|
|
|
|
|
+ String schema = readSchemaSql();
|
|
|
|
|
+ for (String table : NEW_TABLES) {
|
|
|
|
|
+ assertTrue(schema.contains(table),
|
|
|
|
|
+ "schema.sql 缺少 " + table + " 建表(SC-B-02 提醒配置/去重日志、SC-B-12 方案跟进)。");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("SC-B-02 边界-7:投递提醒必须返回投递条数(≥0,推送落站内信)")
|
|
|
|
|
+ void deliverReminder_returnsDeliveredCount() {
|
|
|
|
|
+ Object host = contractHost(HOST_REMINDER_SERVICE, "ai.coach.proactive");
|
|
|
|
|
+ int delivered = callInt(host, "deliverReminder",
|
|
|
|
|
+ 10L, 100L, "water", "task", 900L, "饮水提醒", "到点饮水啦");
|
|
|
|
|
+ assertTrue(delivered >= 0,
|
|
|
|
|
+ "deliverReminder 应返回本次投递条数(落站内通知,≥0),delivered=" + delivered);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("SC-B-02 边界-8:到点触发应返回触发条数(≥0)")
|
|
|
|
|
+ void triggerDue_returnsTriggeredCount() {
|
|
|
|
|
+ Object host = contractHost(HOST_REMINDER_SERVICE, "ai.coach.proactive");
|
|
|
|
|
+ int triggered = callInt(host, "triggerDueReminders", 1_700_000_000_000L);
|
|
|
|
|
+ assertTrue(triggered >= 0,
|
|
|
|
|
+ "triggerDueReminders(nowEpochMs) 应返回到点触发条数(≥0),triggered=" + triggered);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== SC-B-12 正常流:同步规划师建议主动跟进 ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("SC-B-12 正常-1:CoachFollowUpService 方案跟进服务应存在")
|
|
|
|
|
+ void followUpService_classExists() {
|
|
|
|
|
+ assertContractClass(HOST_FOLLOWUP_SERVICE, "ai.coach.proactive",
|
|
|
|
|
+ "CoachFollowUpService 未实现:SC-B-12 要求方案激活后教练主动跟进打卡/进度。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("SC-B-12 正常-2:方案激活应触发教练同步(syncPlanFollowUp)")
|
|
|
|
|
+ void followUp_syncOnPlanActivation() {
|
|
|
|
|
+ Object host = contractHost(HOST_FOLLOWUP_SERVICE, "ai.coach.proactive");
|
|
|
|
|
+ int synced = callInt(host, "syncPlanFollowUp", 555L);
|
|
|
|
|
+ assertTrue(synced >= 1,
|
|
|
|
|
+ "方案激活事件必须触发教练同步(同步任务清单与方案任务一一对应,SC-B-12),synced=" + synced);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("SC-B-12 正常-3:跟进配置实体应存在(表 coach_follow_up_config)")
|
|
|
|
|
+ void followUpConfig_entityExists() {
|
|
|
|
|
+ assertContractClass(ENTITY_FOLLOWUP, "ai.coach.proactive",
|
|
|
|
|
+ "CoachFollowUpConfig 实体未实现:跟进开关/暂停态需 coach_follow_up_config 承载"
|
|
|
|
|
+ + "(active/paused,SC-B-12 可暂停)。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("SC-B-12 正常-4:跟进配置应携带 familyId(仅本家庭方案域校验)")
|
|
|
|
|
+ void followUpConfig_carriesFamilyScope() {
|
|
|
|
|
+ assertContractField(ENTITY_FOLLOWUP, "familyId", "ai.coach.proactive",
|
|
|
|
|
+ "CoachFollowUpConfig 应含 familyId 字段(coach_follow_up_config.family_id):"
|
|
|
|
|
+ + "跟进仅绑定本家庭方案(TC-B-12-P 非本家庭不可见不可改)。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== SC-B-12 边界流:未激活不触发 / 可暂停 / 停滞提示 ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("SC-B-12 边界-1:未激活方案不应触发跟进(isEligibleForFollowUp)")
|
|
|
|
|
+ void followUp_notActivated_noTrigger() {
|
|
|
|
|
+ Object host = contractHost(HOST_FOLLOWUP_SERVICE, "ai.coach.proactive");
|
|
|
|
|
+ assertFalse(callBoolean(host, "isEligibleForFollowUp", 555L),
|
|
|
|
|
+ "方案未激活(非 active)时不得触发跟进/推送(TC-B-12-E:未激活不触发)。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("SC-B-12 边界-2:跟进应可暂停/恢复(setPaused)")
|
|
|
|
|
+ void followUp_pausable_setPaused() {
|
|
|
|
|
+ Object host = contractHost(HOST_FOLLOWUP_SERVICE, "ai.coach.proactive");
|
|
|
|
|
+ callVoid(host, "setPaused", 555L, true);
|
|
|
|
|
+ assertTrue(callBoolean(host, "isPaused", 555L),
|
|
|
|
|
+ "暂停后跟进不再推送、可随时恢复(TC-B-12:家长暂停教练跟进,可恢复)。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("SC-B-12 边界-3:停滞 3 天应主动提示家长(detectStagnation)")
|
|
|
|
|
+ void followUp_stagnationAfter3Days() {
|
|
|
|
|
+ Object host = contractHost(HOST_FOLLOWUP_SERVICE, "ai.coach.proactive");
|
|
|
|
|
+ long now = 1_700_000_000_000L;
|
|
|
|
|
+ int stagnated = callInt(host, "detectStagnation", now);
|
|
|
|
|
+ assertTrue(stagnated >= 0,
|
|
|
|
|
+ "停滞检测应返回停滞跟进条数(≥0);方案节奏点超 3 天未打卡须主动提示家长"
|
|
|
|
|
+ + "(TC-B-12:停滞时主动提示),stagnated=" + stagnated);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 权限流(TC-B-02-P / TC-B-12-P 观察) ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("SC-B-02-P/SC-B-12-P 权限流:仅本家庭校验由 family_id 承载(说明性锚定)")
|
|
|
|
|
+ void permissionFlow_familyScopeAnchoredByFamilyId() {
|
|
|
|
|
+ // 权限流结构锚定:proactive_reminder / coach_follow_up_config 必须携带 family_id 才能在
|
|
|
|
|
+ // 投递/查询侧做家庭域校验(家长为 F9 孩子配置 403、孩子无配置权 403、查他人提醒 403)。
|
|
|
|
|
+ // 宿主服务未实现,403 行为级断言属 GREEN 阶段;此处以实体字段锚定三流覆盖。
|
|
|
|
|
+ assertContractField(ENTITY_REMINDER, "familyId", "ai.coach.proactive",
|
|
|
|
|
+ "仅本家庭校验数据基础:ProactiveReminder 必须携带 familyId(TC-B-02-P)。");
|
|
|
|
|
+ assertContractField(ENTITY_FOLLOWUP, "familyId", "ai.coach.proactive",
|
|
|
|
|
+ "仅本家庭校验数据基础:CoachFollowUpConfig 必须携带 familyId(TC-B-12-P)。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 落点说明:站内通知通道已具备(不缺失断言) ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("推送落点已具备:UserNotificationController / InAppNotificationChannel(说明性)")
|
|
|
|
|
+ void pushEndpoint_inAppChannelAlreadyPresent() {
|
|
|
|
|
+ // 主动提醒投递落点为已具备的站内通知通道(UserNotificationController /api/notification/list|read、
|
|
|
|
|
+ // InAppNotificationChannel → user_notifications 表,见 §4.15)。此通道已实现,故不作缺失断言,
|
|
|
|
|
+ // 仅标注「推送落点已具备」;RED 缺口集中在「主动提醒调度 / 方案主动跟进」契约宿主。
|
|
|
|
|
+ assertNotNull(loadClass("com.etotem.cfc.controller.UserNotificationController"),
|
|
|
|
|
+ "推送落点 UserNotificationController 应存在(已具备,§4.15)");
|
|
|
|
|
+ assertNotNull(loadClass("com.etotem.cfc.service.notification.InAppNotificationChannel"),
|
|
|
|
|
+ "推送落点 InAppNotificationChannel 应存在(已具备,站内通道)");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 反射辅助(锚定契约宿主,避免编译期依赖不存在类) ====================
|
|
|
|
|
+
|
|
|
|
|
+ private Class<?> loadClass(String name) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
|
|
|
|
|
+ } catch (ClassNotFoundException e) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void assertContractClass(String className, String key, String why) {
|
|
|
|
|
+ assertNotNull(loadClass(className),
|
|
|
|
|
+ "RED[" + key + "] " + why + "(契约宿主 " + className + " 未实现)。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void assertContractField(String className, String fieldName, String key, String why) {
|
|
|
|
|
+ Class<?> clazz = loadClass(className);
|
|
|
|
|
+ assertNotNull(clazz, "RED[" + key + "] " + why + "(实体 " + className + " 未实现)。");
|
|
|
|
|
+ for (Field f : clazz.getDeclaredFields()) {
|
|
|
|
|
+ if (f.getName().equals(fieldName)) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ fail("RED[" + key + "] " + why + "(实体 " + className + " 缺字段 " + fieldName + ")。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void assertContractMethod(String className, String methodName, int paramCount, String why) {
|
|
|
|
|
+ Class<?> clazz = loadClass(className);
|
|
|
|
|
+ assertNotNull(clazz, "RED[ai.coach.proactive] " + why + "(契约宿主 " + className + " 未实现)。");
|
|
|
|
|
+ for (Method m : clazz.getDeclaredMethods()) {
|
|
|
|
|
+ if (m.getName().equals(methodName) && m.getParameterCount() == paramCount) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ fail("RED[ai.coach.proactive] " + why + "(契约方法 " + className + "#"
|
|
|
|
|
+ + methodName + "(" + paramCount + ") 未实现)。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 实例化契约宿主(默认构造),类缺失即断言失败(红)。 */
|
|
|
|
|
+ private Object contractHost(String className, String key) {
|
|
|
|
|
+ Class<?> clazz = loadClass(className);
|
|
|
|
|
+ assertNotNull(clazz, "RED[" + key + "] 契约宿主 " + className + " 未实现(实现未落地)。");
|
|
|
|
|
+ try {
|
|
|
|
|
+ return clazz.getDeclaredConstructor().newInstance();
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ fail("契约宿主 " + className + " 实例化失败:" + 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.getClass().getName() + "#" + method + " 调用失败:" + e.getMessage());
|
|
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ fail("RED[ai.coach.proactive] 契约方法 " + host.getClass().getName() + "#" + 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 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");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String[] callStringArray(Object host, String method, Object... args) {
|
|
|
|
|
+ Object r = call(host, method, args);
|
|
|
|
|
+ if (r instanceof String[]) {
|
|
|
|
|
+ return (String[]) r;
|
|
|
|
|
+ }
|
|
|
|
|
+ fail("契约方法 " + method + " 应返回 String[],实际 " + r);
|
|
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void callVoid(Object host, String method, Object... args) {
|
|
|
|
|
+ call(host, method, args);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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 "";
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|