Quellcode durchsuchen

test(S-cfc-347): task.reminder.daily TDD-RED 契约单测(SC-P-65/79 日提醒配置+早晚调度,20 例预期红)

Sisyphus Agent vor 1 Tag
Ursprung
Commit
2cd8bcca2b

+ 297 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/TaskReminderDailyConfigContractTest.java

@@ -0,0 +1,297 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.dto.TaskReminderConfigDTO;
+import com.etotem.cfc.entity.TaskReminderConfig;
+import com.etotem.cfc.mapper.TaskReminderConfigMapper;
+import com.etotem.cfc.service.TaskReminderConfigService;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.io.InputStream;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+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.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:任务日提醒配置(能力键 {@code task.reminder.daily},单元 task)。
+ *
+ * <p><b>需求/设计锚点(设计契约 v1.0,commit c56ea68ab 已合入 origin/cfclub)</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-P-65(消息通知/提醒设置:设通知开关/
+ *       提醒时间)、SC-P-79(推送/通知开关设置);业务域 H(设置与通知)</li>
+ *   <li>设计:{@code docs/tdd/orchestration/task-daily-reminder-design-2026-11-14.md}
+ *       用户需求 2026-11-14:现有提前/超期两档外新增日提醒(早 07:30 提醒全天关注、晚 21:00
+ *       全天总结并催未点完成补打卡)</li>
+ *   <li>设计决策 #1/#2(已拍板):独立开关可叠加({@code daily_enabled} 独立于 {@code enabled},
+ *       与 before/after 互不影响);早晚时间用户可配({@code morning_time}/{@code evening_time},
+ *       HH:mm),默认 07:30 / 21:00</li>
+ * </ul>
+ *
+ * <p><b>被校验的契约行为(设计 §3.1/§3.2/§3.3 + §四 验收 1/5)</b></p>
+ * <ul>
+ *   <li>DB 迁移:{@code task_reminder_config} 表新增 3 列——{@code daily_enabled} TINYINT 默认 0、
+ *       {@code morning_time} VARCHAR(5) 默认 '07:30'、{@code evening_time} VARCHAR(5) 默认 '21:00'
+ *       (schema.sql 同步)</li>
+ *   <li>实体/DTO:{@code dailyEnabled}/{@code morningTime}/{@code eveningTime} 三字段</li>
+ *   <li>{@code getConfig}:未配置默认 {@code dailyEnabled=0, morningTime="07:30", eveningTime="21:00"}</li>
+ *   <li>{@code saveConfig} 新增校验:{@code dailyEnabled} ∈ {0,1}(null 视为 0);
+ *       {@code morningTime}/{@code eveningTime} 必须匹配 {@code ^([01]\d|2[0-3]):[0-5]\d$},
+ *       否则 {@code IllegalArgumentException("提醒时间格式不合法")}</li>
+ * </ul>
+ *
+ * <p><b>测试范围</b>:纯契约级单测,无 Spring 上下文 / 无 DB / 无外部服务(反射 + 少量 Mockito)。
+ * 配置服务宿主 {@link TaskReminderConfigService} 已存在,但 daily 三字段与校验逻辑
+ * <b>尚未落地</b>(2026-11-14 现状核实)——本测试断言契约所需行为缺失/默认值未实现,
+ * 全部用例在反射探测处失败(红);GREEN 阶段实现后转为验证真实行为。</p>
+ *
+ * <p><b>RED 预期</b>:实体/DTO 字段缺失、getConfig 无 daily 默认值、saveConfig 无 HH:mm 校验、
+ * schema.sql 无 3 列——全部用例红。</p>
+ */
+class TaskReminderDailyConfigContractTest {
+
+    /** 设计 §3.3:HH:mm 正则(契约,实现方按此校验) */
+    private static final String HHMM_REGEX = "^([01]\\d|2[0-3]):[0-5]\\d$";
+
+    /** 设计 §3.1:表默认值 */
+    private static final String DEFAULT_MORNING_TIME = "07:30";
+    private static final String DEFAULT_EVENING_TIME = "21:00";
+
+    // ==================== 实体/DTO 字段契约(§3.2) ====================
+
+    @Test
+    @DisplayName("实体契约-1:TaskReminderConfig 应声明 dailyEnabled/morningTime/eveningTime 三字段")
+    void entity_hasDailyThreeFields() {
+        for (String field : new String[]{"dailyEnabled", "morningTime", "eveningTime"}) {
+            try {
+                TaskReminderConfig.class.getDeclaredField(field);
+            } catch (NoSuchFieldException e) {
+                fail("RED[task.reminder.daily] 实体 TaskReminderConfig 缺字段 " + field
+                        + ":设计契约 §3.2 要求新增 dailyEnabled/morningTime/eveningTime,"
+                        + "DB 迁移(DatabaseInitializer + schema.sql)未落地。");
+            }
+        }
+    }
+
+    @Test
+    @DisplayName("实体契约-2:TaskReminderConfigDTO 应声明 dailyEnabled/morningTime/eveningTime 三字段")
+    void dto_hasDailyThreeFields() {
+        for (String field : new String[]{"dailyEnabled", "morningTime", "eveningTime"}) {
+            try {
+                TaskReminderConfigDTO.class.getDeclaredField(field);
+            } catch (NoSuchFieldException e) {
+                fail("RED[task.reminder.daily] DTO TaskReminderConfigDTO 缺字段 " + field
+                        + ":设计契约 §3.2 要求前端配置页回显/保存 daily 三字段。");
+            }
+        }
+    }
+
+    // ==================== getConfig 默认值契约(§3.3 + 设计决策 #2) ====================
+
+    @Test
+    @DisplayName("配置服务-1:getConfig 未配置时默认 dailyEnabled=0 morningTime=07:30 eveningTime=21:00")
+    void getConfig_unconfigured_dailyDefaults() throws Exception {
+        TaskReminderConfigService service = newServiceWithNoConfig();
+
+        TaskReminderConfigDTO dto = service.getConfig(100L);
+        // 契约要求 DTO 提供 daily 三字段 getter;缺失即为 RED(实现未落地)
+        Method getDaily = getterOf(dto, "getDailyEnabled");
+        Method getMorning = getterOf(dto, "getMorningTime");
+        Method getEvening = getterOf(dto, "getEveningTime");
+
+        assertEquals(0, ((Number) getDaily.invoke(dto)).intValue(),
+                "未配置用户 daily_enabled 默认应为 0(默认关闭,避免上线扰民,设计决策 #2)");
+        assertEquals(DEFAULT_MORNING_TIME, getMorning.invoke(dto),
+                "未配置用户 morning_time 默认应为 07:30(设计决策 #2)");
+        assertEquals(DEFAULT_EVENING_TIME, getEvening.invoke(dto),
+                "未配置用户 evening_time 默认应为 21:00(设计决策 #2)");
+    }
+
+    // ==================== saveConfig 校验契约(§3.3 + §四 验收 1) ====================
+
+    @Test
+    @DisplayName("配置服务-2:saveConfig 应拒绝非法早提醒时间 25:00(HH:mm 校验)")
+    void saveConfig_rejectsMorningTime_25_00() throws Exception {
+        TaskReminderConfigService service = newServiceWithNoConfig();
+        TaskReminderConfigDTO dto = validBaseDto();
+
+        setterOf(dto, "setMorningTime", String.class).invoke(dto, "25:00");
+        setterOf(dto, "setEveningTime", String.class).invoke(dto, DEFAULT_EVENING_TIME);
+
+        IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
+                () -> service.saveConfig(100L, dto),
+                "morningTime=25:00 不匹配 " + HHMM_REGEX + ",应拒绝(§3.3)");
+        assertEquals("提醒时间格式不合法", ex.getMessage(), "非法 HH:mm 报错文案(§3.3)");
+    }
+
+    @Test
+    @DisplayName("配置服务-3:saveConfig 应拒绝缺前导零的早提醒时间 7:30(HH:mm 校验)")
+    void saveConfig_rejectsMorningTime_7_30() throws Exception {
+        TaskReminderConfigService service = newServiceWithNoConfig();
+        TaskReminderConfigDTO dto = validBaseDto();
+
+        setterOf(dto, "setMorningTime", String.class).invoke(dto, "7:30");
+        setterOf(dto, "setEveningTime", String.class).invoke(dto, DEFAULT_EVENING_TIME);
+
+        IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
+                () -> service.saveConfig(100L, dto),
+                "morningTime=7:30 不匹配 " + HHMM_REGEX + "(缺前导零),应拒绝(§3.3)");
+        assertEquals("提醒时间格式不合法", ex.getMessage());
+    }
+
+    @Test
+    @DisplayName("配置服务-4:saveConfig 应拒绝含非数字的晚总结时间 07:3x(HH:mm 校验)")
+    void saveConfig_rejectsEveningTime_07_3x() throws Exception {
+        TaskReminderConfigService service = newServiceWithNoConfig();
+        TaskReminderConfigDTO dto = validBaseDto();
+
+        setterOf(dto, "setMorningTime", String.class).invoke(dto, DEFAULT_MORNING_TIME);
+        setterOf(dto, "setEveningTime", String.class).invoke(dto, "07:3x");
+
+        IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
+                () -> service.saveConfig(100L, dto),
+                "eveningTime=07:3x 不匹配 " + HHMM_REGEX + ",应拒绝(§3.3)");
+        assertEquals("提醒时间格式不合法", ex.getMessage());
+    }
+
+    @Test
+    @DisplayName("配置服务-5:saveConfig 应拒绝 dailyEnabled 越界(非 {0,1})")
+    void saveConfig_rejectsDailyEnabledOutOfRange() throws Exception {
+        TaskReminderConfigService service = newServiceWithNoConfig();
+        TaskReminderConfigDTO dto = validBaseDto();
+        setterOf(dto, "setMorningTime", String.class).invoke(dto, DEFAULT_MORNING_TIME);
+        setterOf(dto, "setEveningTime", String.class).invoke(dto, DEFAULT_EVENING_TIME);
+        setterOf(dto, "setDailyEnabled", Integer.class).invoke(dto, 2);
+
+        assertThrows(IllegalArgumentException.class,
+                () -> service.saveConfig(100L, dto),
+                "dailyEnabled=2 越出 {0,1} 边界应拒绝(§3.3)");
+    }
+
+    @Test
+    @DisplayName("配置服务-6:saveConfig 应接受合法日提醒配置(07:30/21:00 + dailyEnabled=1)")
+    void saveConfig_acceptsValidDailyConfig() throws Exception {
+        TaskReminderConfigService service = newServiceWithNoConfig();
+        TaskReminderConfigDTO dto = validBaseDto();
+        setterOf(dto, "setMorningTime", String.class).invoke(dto, DEFAULT_MORNING_TIME);
+        setterOf(dto, "setEveningTime", String.class).invoke(dto, DEFAULT_EVENING_TIME);
+        setterOf(dto, "setDailyEnabled", Integer.class).invoke(dto, 1);
+
+        // 合法配置不应抛异常(GREEN 后走持久化断言;此处仅验证不拒绝)
+        service.saveConfig(100L, dto);
+        assertFalse(
+                service.getConfig(100L).getChannels().isEmpty(),
+                "合法日提醒配置应可保存并回读(§3.3)");
+    }
+
+    // ==================== DB 迁移契约(§3.1 + schema.sql 同步) ====================
+
+    @Test
+    @DisplayName("DB 迁移-1:task_reminder_config 建表应含 daily_enabled/morning_time/evening_time 三列")
+    void schemaSql_taskReminderConfig_hasDailyColumns() {
+        String schema = readSchemaSql();
+        int idx = schema.indexOf("CREATE TABLE IF NOT EXISTS task_reminder_config");
+        assertTrue(idx >= 0,
+                "schema.sql 应含 task_reminder_config 建表(迁移 353 已有,意外丢失)");
+        String tableDef = schema.substring(idx, Math.min(idx + 1200, schema.length()));
+
+        assertTrue(tableDef.contains("daily_enabled"),
+                "RED[task.reminder.daily] schema.sql 的 task_reminder_config 缺 daily_enabled 列"
+                        + "(TINYINT DEFAULT 0,§3.1)——DB 迁移未落地");
+        assertTrue(tableDef.contains("morning_time"),
+                "RED[task.reminder.daily] schema.sql 的 task_reminder_config 缺 morning_time 列"
+                        + "(VARCHAR(5) DEFAULT '07:30',§3.1)");
+        assertTrue(tableDef.contains("evening_time"),
+                "RED[task.reminder.daily] schema.sql 的 task_reminder_config 缺 evening_time 列"
+                        + "(VARCHAR(5) DEFAULT '21:00',§3.1)");
+    }
+
+    @Test
+    @DisplayName("DB 迁移-2:schema.sql 三列默认值应分别为 0 / '07:30' / '21:00'")
+    void schemaSql_dailyColumns_defaultsMatchSpec() {
+        String schema = readSchemaSql();
+        int idx = schema.indexOf("CREATE TABLE IF NOT EXISTS task_reminder_config");
+        if (idx < 0) {
+            fail("RED[task.reminder.daily] schema.sql 缺 task_reminder_config 建表");
+        }
+        String tableDef = schema.substring(idx, Math.min(idx + 1200, schema.length()));
+
+        assertTrue(tableDef.contains("daily_enabled") && tableDef.contains("DEFAULT 0"),
+                "daily_enabled 默认值应为 0(默认关闭日提醒,§3.1)");
+        assertTrue(tableDef.contains("morning_time") && tableDef.contains("'07:30'"),
+                "morning_time 默认值应为 '07:30'(§3.1)");
+        assertTrue(tableDef.contains("evening_time") && tableDef.contains("'21:00'"),
+                "evening_time 默认值应为 '21:00'(§3.1)");
+    }
+
+    // ==================== 辅助 ====================
+
+    /** 构造未配置任何提醒设置的 service(mapper 查无记录),并注入 mock mapper / 空渠道。 */
+    private TaskReminderConfigService newServiceWithNoConfig() {
+        TaskReminderConfigMapper mapper = mock(TaskReminderConfigMapper.class);
+        when(mapper.selectOne(any())).thenReturn(null);
+        TaskReminderConfigService service = new TaskReminderConfigService();
+        ReflectionTestUtils.setField(service, "taskReminderConfigMapper", mapper);
+        ReflectionTestUtils.setField(service, "notificationChannels", Collections.emptyList());
+        return service;
+    }
+
+    /** 构造现有字段合法的 DTO(不触碰 daily 字段,由用例通过反射注入)。 */
+    private TaskReminderConfigDTO validBaseDto() {
+        TaskReminderConfigDTO dto = new TaskReminderConfigDTO();
+        dto.setChannels(Collections.singletonList("wechat"));
+        dto.setTimingMode("after_timeout");
+        dto.setOffsetMinutes(60);
+        dto.setEnabled(1);
+        dto.setCompletionPraiseEnabled(0);
+        dto.setCompletionPraiseChannels(Collections.singletonList("wechat"));
+        return dto;
+    }
+
+    /** 反射获取 getter;缺失即 RED 失败(契约未实现)。 */
+    private Method getterOf(Object target, String getter) {
+        try {
+            return target.getClass().getMethod(getter);
+        } catch (NoSuchMethodException e) {
+            fail("RED[task.reminder.daily] DTO 缺访问器 " + getter
+                    + ":daily 三字段契约(§3.2/§3.3)未落地。");
+            throw new IllegalStateException("unreachable");
+        }
+    }
+
+    /** 反射获取 setter;缺失即 RED 失败(契约未实现)。 */
+    private Method setterOf(Object target, String setter, Class<?> paramType) {
+        try {
+            return target.getClass().getMethod(setter, paramType);
+        } catch (NoSuchMethodException e) {
+            fail("RED[task.reminder.daily] DTO 缺写入器 " + setter
+                    + ":daily 三字段契约(§3.2/§3.3)未落地。");
+            throw new IllegalStateException("unreachable");
+        }
+    }
+
+    /** 读取 schema.sql(src/main/resources 资源,参考 TeamProfitHookContractTest 同款)。 */
+    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 "";
+        }
+    }
+}

+ 379 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/TaskReminderDailyScheduledTaskContractTest.java

@@ -0,0 +1,379 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.dto.TaskReminderConfigDTO;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.entity.UserNotification;
+import com.etotem.cfc.mapper.FamilyMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.mapper.UserNotificationMapper;
+import com.etotem.cfc.service.NotificationDispatchService;
+import com.etotem.cfc.service.TaskReminderConfigService;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:任务日提醒调度器(能力键 {@code task.reminder.daily} 调度面)。
+ *
+ * <p><b>需求/设计锚点(设计契约 v1.0,commit c56ea68ab 已合入 origin/cfclub)</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-P-65(消息通知/提醒设置)、
+ *       SC-P-79(推送/通知开关设置);业务域 H(设置与通知)</li>
+ *   <li>设计:{@code docs/tdd/orchestration/task-daily-reminder-design-2026-11-14.md}
+ *       用户需求 2026-11-14:现有提前/超期两档外新增日提醒——
+ *       早 07:30 提醒全天关注(今日到期 + 逾期宽限未打卡),
+ *       晚 21:00 全天总结(今日到期已完成 / 未完成分组,催未点完成补打卡)</li>
+ *   <li>调度实现(§3.4 拍板):固定 cron 逐用户判断——早上
+ *       {@code @Scheduled(cron = "0 30 7 * * ?")}(07:30)、晚上
+ *       {@code @Scheduled(cron = "0 0 21 * * ?")}(21:00);按用户配置时间判定当日是否已触发,
+ *       靠 {@code user_notifications} 当日唯一判重保证每日一次</li>
+ * </ul>
+ *
+ * <p><b>宿主契约(TDD-RED 锚点)</b>:{@code com.etotem.cfc.task.TaskDailyReminderScheduledTask} ——
+ * 设计契约 §3.4/§六 规划的新增调度器(保持职责单一),2026-11-14 <b>尚未实现</b>。
+ * 本测试通过反射锚定该类名(避免直接 import 不存在类导致编译失败),运行时
+ * {@code Class.forName} 抛 {@link ClassNotFoundException} 即断言失败(红),
+ * 暴露「实现未落地」真实缺陷;GREEN 阶段实现宿主后转为验证真实行为。</p>
+ *
+ * <p><b>被校验的契约行为(设计 §3.4 + §四 验收 2/3/4)</b></p>
+ * <ul>
+ *   <li><b>早提醒</b>(07:30):对 daily_enabled=1 的用户配置,扫描「今日到期 +
+ *       逾期宽限(OVERDUE_GRACE_DAYS=3)内未打卡」的 parent 执行型任务,发送
+ *       {@code task_daily_morning} 通知(当日唯一);未启用(daily_enabled=0)→ 不发;
+ *       今日无任务 → 不发(避免空打扰);渠道 in_app 恒兜底</li>
+ *   <li><b>晚总结</b>(21:00):今日到期任务按「已完成(completedAt 今日)/ 未完成」分组,
+ *       未完成含催补打卡文案,发送 {@code task_daily_evening} 通知(当日唯一);
+ *       历史逾期不进晚总结(由超期提醒兜底)</li>
+ *   <li><b>幂等</b>:同一用户同一日早晚各一次——user_notifications 当日同
+ *       (type, ref_type=task, createdAt) 判重</li>
+ * </ul>
+ *
+ * <p><b>契约方法签名(实现方按此落地,依赖字段复用 §3.4「现有查询骨架」)</b></p>
+ * <ul>
+ *   <li>{@code void sendMorningReminder()} —— 早提醒(@Scheduled cron "0 30 7 * * ?")</li>
+ *   <li>{@code void sendEveningSummary()} —— 晚总结(@Scheduled cron "0 0 21 * * ?")</li>
+ *   <li>依赖:{@code taskMapper} / {@code familyMapper} / {@code userNotificationMapper} /
+ *       {@code notificationDispatchService}(与现有 TaskReminderScheduledTask 口径一致)</li>
+ * </ul>
+ *
+ * <p><b>测试范围</b>:纯契约级单测,无 Spring 上下文 / 无 DB / 无外部服务(反射 + Mockito)。
+ * 若实现方采用其他包路径/宿主,GREEN 阶段由编排侧对齐本测试锚点。</p>
+ *
+ * <p><b>RED 预期</b>:宿主类不存在,全部用例在反射探测处失败(红),
+ * 断言原文为「契约宿主 TaskDailyReminderScheduledTask 未实现」。</p>
+ */
+class TaskReminderDailyScheduledTaskContractTest {
+
+    /** 设计蓝图宿主类(新增,§3.4/§六;当前未实现)。 */
+    private static final String HOST_CLASS = "com.etotem.cfc.task.TaskDailyReminderScheduledTask";
+
+    /** 设计 §3.4:早晚固定 cron。 */
+    private static final String MORNING_CRON = "0 30 7 * * ?";
+    private static final String EVENING_CRON = "0 0 21 * * ?";
+
+    // ==================== 宿主结构契约 ====================
+
+    @Test
+    @DisplayName("宿主-1:TaskDailyReminderScheduledTask 类应存在(§3.4/§六)")
+    void hostClass_exists() {
+        assertNotNull(loadClass(HOST_CLASS),
+                "RED[task.reminder.daily] 契约宿主 TaskDailyReminderScheduledTask 未实现"
+                        + "(设计契约 §3.4/§六 规划的新增调度器,实现未落地)");
+    }
+
+    @Test
+    @DisplayName("宿主-2:应提供 sendMorningReminder/sendEveningSummary 两个调度入口(§3.4)")
+    void host_hasMorningAndEveningEntries() {
+        Class<?> clazz = loadClass(HOST_CLASS);
+        if (clazz == null) {
+            fail("RED[task.reminder.daily] 契约宿主 TaskDailyReminderScheduledTask 未实现,"
+                    + "无法验证早晚调度入口(§3.4)。");
+        }
+        boolean hasMorning = false;
+        boolean hasEvening = false;
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().equals("sendMorningReminder")) {
+                hasMorning = true;
+            }
+            if (m.getName().equals("sendEveningSummary")) {
+                hasEvening = true;
+            }
+        }
+        if (!hasMorning || !hasEvening) {
+            fail("RED[task.reminder.daily] 宿主应提供 sendMorningReminder() 与 sendEveningSummary()"
+                    + "两个 @Scheduled 入口(§3.4,缺失:"
+                    + "morning=" + hasMorning + ", evening=" + hasEvening + ")。");
+        }
+    }
+
+    @Test
+    @DisplayName("宿主-3:早提醒 cron 应为 0 30 7 * * ?(07:30 触发,§3.4)")
+    void host_morningCron_0730() throws Exception {
+        Class<?> clazz = loadClass(HOST_CLASS);
+        if (clazz == null) {
+            fail("RED[task.reminder.daily] 契约宿主 TaskDailyReminderScheduledTask 未实现,"
+                    + "无法验证早提醒 cron(§3.4)。");
+        }
+        String cron = scheduledCron(clazz, "sendMorningReminder");
+        assertEquals(MORNING_CRON, cron,
+                "早提醒固定 cron 应为 0 30 7 * * ?(07:30,§3.4 拍板)");
+    }
+
+    @Test
+    @DisplayName("宿主-4:晚总结 cron 应为 0 0 21 * * ?(21:00 触发,§3.4)")
+    void host_eveningCron_2100() throws Exception {
+        Class<?> clazz = loadClass(HOST_CLASS);
+        if (clazz == null) {
+            fail("RED[task.reminder.daily] 契约宿主 TaskDailyReminderScheduledTask 未实现,"
+                    + "无法验证晚总结 cron(§3.4)。");
+        }
+        String cron = scheduledCron(clazz, "sendEveningSummary");
+        assertEquals(EVENING_CRON, cron,
+                "晚总结固定 cron 应为 0 0 21 * * ?(21:00,§3.4 拍板)");
+    }
+
+    // ==================== 早提醒行为(§四 验收 2) ====================
+
+    @Test
+    @DisplayName("早提醒-1:启用日提醒用户 + 今日到期任务 → 发送 task_daily_morning(当日唯一)")
+    void morningReminder_enabledUser_todayDue_sends() throws Exception {
+        Object host = contractHost();
+        TaskMapper taskMapper = mock(TaskMapper.class);
+        UserNotificationMapper unMapper = mock(UserNotificationMapper.class);
+        NotificationDispatchService dispatch = mock(NotificationDispatchService.class);
+        inject(host, "taskMapper", taskMapper);
+        inject(host, "userNotificationMapper", unMapper);
+        inject(host, "notificationDispatchService", dispatch);
+
+        Task due = task(1L, "晨起空腹喝水", todayDeadline(10, 0), "pending");
+        when(taskMapper.selectList(any())).thenReturn(new ArrayList<>(Collections.singletonList(due)));
+        when(unMapper.selectCount(any())).thenReturn(0L);
+
+        call(host, "sendMorningReminder");
+
+        ArgumentCaptor<String> typeCaptor = ArgumentCaptor.forClass(String.class);
+        verify(dispatch, atLeastOnce()).sendByTemplate(typeCaptor.capture(), anyLong(), any(),
+                any(), anyLong(), any(), any());
+        assertNotNull(typeCaptor.getValue(),
+                "应通过 sendByTemplate 发送早提醒");
+        assertEquals("task_daily_morning", typeCaptor.getValue(),
+                "早提醒通知 type 应为 task_daily_morning(§3.4)");
+    }
+
+    @Test
+    @DisplayName("早提醒-2:daily_enabled=0(未启用)→ 不发早提醒(默认关闭,§3.4/设计决策 #1)")
+    void morningReminder_disabledUser_notSend() throws Exception {
+        Object host = contractHost();
+        TaskMapper taskMapper = mock(TaskMapper.class);
+        UserNotificationMapper unMapper = mock(UserNotificationMapper.class);
+        NotificationDispatchService dispatch = mock(NotificationDispatchService.class);
+        inject(host, "taskMapper", taskMapper);
+        inject(host, "userNotificationMapper", unMapper);
+        inject(host, "notificationDispatchService", dispatch);
+
+        when(taskMapper.selectList(any())).thenReturn(new ArrayList<>());
+        when(unMapper.selectCount(any())).thenReturn(0L);
+
+        call(host, "sendMorningReminder");
+
+        verify(dispatch, never()).sendByTemplate(any(), anyLong(), any(),
+                any(), anyLong(), any(), any());
+    }
+
+    @Test
+    @DisplayName("早提醒-3:今日无到期任务 → 不发早提醒(避免空打扰,§3.4)")
+    void morningReminder_noTaskToday_notSend() throws Exception {
+        Object host = contractHost();
+        TaskMapper taskMapper = mock(TaskMapper.class);
+        UserNotificationMapper unMapper = mock(UserNotificationMapper.class);
+        NotificationDispatchService dispatch = mock(NotificationDispatchService.class);
+        inject(host, "taskMapper", taskMapper);
+        inject(host, "userNotificationMapper", unMapper);
+        inject(host, "notificationDispatchService", dispatch);
+
+        when(taskMapper.selectList(any())).thenReturn(new ArrayList<>());
+        when(unMapper.selectCount(any())).thenReturn(0L);
+
+        call(host, "sendMorningReminder");
+
+        verify(dispatch, never()).sendByTemplate(any(), anyLong(), any(),
+                any(), anyLong(), any(), any());
+    }
+
+    // ==================== 晚总结行为(§四 验收 3) ====================
+
+    @Test
+    @DisplayName("晚总结-1:今日到期任务按已完成/未完成分组,未完成含催补打卡文案")
+    void eveningSummary_todayDue_groupedCompletedAndUncompleted() throws Exception {
+        Object host = contractHost();
+        TaskMapper taskMapper = mock(TaskMapper.class);
+        UserNotificationMapper unMapper = mock(UserNotificationMapper.class);
+        NotificationDispatchService dispatch = mock(NotificationDispatchService.class);
+        inject(host, "taskMapper", taskMapper);
+        inject(host, "userNotificationMapper", unMapper);
+        inject(host, "notificationDispatchService", dispatch);
+
+        // 今日到期:1 已完成(completedAt 今日)+ 1 未完成(pending)
+        Task done = task(2L, "已完成的今日任务", todayDeadline(9, 0), "completed");
+        done.setCompletedAt(new Date());
+        Task undone = task(3L, "未完成的今日任务", todayDeadline(18, 0), "pending");
+        when(taskMapper.selectList(any())).thenReturn(
+                new ArrayList<>(Arrays.asList(done, undone)));
+        when(unMapper.selectCount(any())).thenReturn(0L);
+
+        call(host, "sendEveningSummary");
+
+        ArgumentCaptor<String> typeCaptor = ArgumentCaptor.forClass(String.class);
+        ArgumentCaptor<String> contentCaptor = ArgumentCaptor.forClass(String.class);
+        verify(dispatch, atLeastOnce()).sendByTemplate(typeCaptor.capture(), anyLong(), any(),
+                any(), anyLong(), any(), contentCaptor.capture());
+        assertEquals("task_daily_evening", typeCaptor.getValue(),
+                "晚总结通知 type 应为 task_daily_evening(§3.4)");
+        String content = contentCaptor.getValue();
+        // 设计 §3.4 晚总结文案:「今日总结:完成 X/N。✅ 已完成:…;【未完成】⚠ 标题…别忘了打卡哦~」
+        assertNotNull(content, "晚总结内容不应为空");
+        if (!content.contains("未完成") || !content.contains("已完成")) {
+            fail("RED[task.reminder.daily] 晚总结应区分「已完成/未完成」两组"
+                    + "(设计契约 §四 验收 3,实际内容=" + content + ")");
+        }
+    }
+
+    @Test
+    @DisplayName("晚总结-2:今日无到期任务 → 不发晚总结(§3.4)")
+    void eveningSummary_noTaskToday_notSend() throws Exception {
+        Object host = contractHost();
+        TaskMapper taskMapper = mock(TaskMapper.class);
+        UserNotificationMapper unMapper = mock(UserNotificationMapper.class);
+        NotificationDispatchService dispatch = mock(NotificationDispatchService.class);
+        inject(host, "taskMapper", taskMapper);
+        inject(host, "userNotificationMapper", unMapper);
+        inject(host, "notificationDispatchService", dispatch);
+
+        when(taskMapper.selectList(any())).thenReturn(new ArrayList<>());
+        when(unMapper.selectCount(any())).thenReturn(0L);
+
+        call(host, "sendEveningSummary");
+
+        verify(dispatch, never()).sendByTemplate(any(), anyLong(), any(),
+                any(), anyLong(), any(), any());
+    }
+
+    // ==================== 幂等(§四 验收 4) ====================
+
+    @Test
+    @DisplayName("幂等-1:同一用户同一日早晚各一次——当日已发(user_notifications 判重)则跳过")
+    void idempotent_sameDayAlreadySent_skip() throws Exception {
+        Object host = contractHost();
+        TaskMapper taskMapper = mock(TaskMapper.class);
+        UserNotificationMapper unMapper = mock(UserNotificationMapper.class);
+        NotificationDispatchService dispatch = mock(NotificationDispatchService.class);
+        inject(host, "taskMapper", taskMapper);
+        inject(host, "userNotificationMapper", unMapper);
+        inject(host, "notificationDispatchService", dispatch);
+
+        Task due = task(4L, "今日任务", todayDeadline(15, 0), "pending");
+        when(taskMapper.selectList(any())).thenReturn(new ArrayList<>(Collections.singletonList(due)));
+        // 当日同 type 已发一条 → 判重跳过
+        when(unMapper.selectCount(any())).thenReturn(1L);
+
+        call(host, "sendMorningReminder");
+
+        verify(dispatch, never()).sendByTemplate(any(), anyLong(), any(),
+                any(), anyLong(), any(), any());
+    }
+
+    // ==================== 反射辅助 ====================
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    /** 反射读取 @Scheduled cron 表达式。 */
+    private String scheduledCron(Class<?> clazz, String methodName) throws Exception {
+        Method m = clazz.getMethod(methodName);
+        org.springframework.scheduling.annotation.Scheduled ann =
+                m.getAnnotation(org.springframework.scheduling.annotation.Scheduled.class);
+        if (ann == null) {
+            fail("RED[task.reminder.daily] 方法 " + methodName + " 应标注 @Scheduled(§3.4)");
+        }
+        return ann.cron();
+    }
+
+    /** 反射实例化宿主;ClassNotFoundException → RED 失败。 */
+    private Object contractHost() {
+        try {
+            Class<?> clazz = Class.forName(HOST_CLASS);
+            return clazz.getDeclaredConstructor().newInstance();
+        } catch (ClassNotFoundException e) {
+            fail("RED[task.reminder.daily] 契约宿主 TaskDailyReminderScheduledTask 未实现"
+                    + "(实现未落地):" + e.getMessage());
+            throw new IllegalStateException("unreachable");
+        } catch (Exception e) {
+            fail("契约宿主 TaskDailyReminderScheduledTask 实例化失败:" + e.getMessage());
+            throw new IllegalStateException("unreachable");
+        }
+    }
+
+    /** 反射注入宿主依赖(字段名按设计 §3.4 复用现有骨架;实现方对齐)。 */
+    private void inject(Object host, String field, Object value) {
+        try {
+            ReflectionTestUtils.setField(host, field, value);
+        } catch (IllegalArgumentException e) {
+            fail("RED[task.reminder.daily] 宿主缺依赖字段 " + field
+                    + "(设计 §3.4 依赖骨架:" + e.getMessage() + ")");
+        }
+    }
+
+    /** 反射调用宿主契约方法。 */
+    private void call(Object host, String method) throws Exception {
+        Method m = host.getClass().getMethod(method);
+        m.invoke(host);
+    }
+
+    /** 构造今日任务(deadline 落在今天 HH:mm)。 */
+    private Task task(long id, String title, Date deadline, String status) {
+        Task t = new Task();
+        t.setId(id);
+        t.setTitle(title);
+        t.setDeadline(deadline);
+        t.setStatus(status);
+        t.setIsTemplate(0);
+        t.setExecutorType("parent");
+        return t;
+    }
+
+    /** 今日 HH:mm 时刻。 */
+    private Date todayDeadline(int hour, int minute) {
+        java.util.Calendar cal = java.util.Calendar.getInstance();
+        cal.set(java.util.Calendar.HOUR_OF_DAY, hour);
+        cal.set(java.util.Calendar.MINUTE, minute);
+        cal.set(java.util.Calendar.SECOND, 0);
+        cal.set(java.util.Calendar.MILLISECOND, 0);
+        return cal.getTime();
+    }
+}