Browse Source

test(RED): S-cfc-262 task.frequency.config 任务频率/周期配置 契约级单测 20例全红

E2E Test Bot 2 ngày trước cách đây
mục cha
commit
faa97f0e8a

+ 547 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/TaskFrequencyConfigContractTest.java

@@ -0,0 +1,547 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.etotem.cfc.dto.CreateTaskDTO;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.MiniGameMapper;
+import com.etotem.cfc.mapper.SurveyTemplateMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.service.TaskService;
+import com.etotem.cfc.service.UserService;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+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.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:任务频率/周期配置——自定义周期、跨月/跨年、暂停/恢复(能力键 {@code task.frequency.config},单元 T 任务与激励)。
+ *
+ * <p><b>需求/设计锚点</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} <b>SC-P-12</b> ——
+ *       目标:设日/周/<b>自定义周期</b>;流程:配频率 → 按周期自动生成实例;
+ *       边界:周期计算<b>跨月/跨年正确</b>;<b>暂停/恢复</b>。模块:{@code TaskController}。</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.3 任务 ——
+ *       {@code POST /api/tasks/create}(actionType+actionConfig 声明任务配置)、
+ *       {@code POST /api/tasks/{id}/start}(两阶段入口);创建入参 {@code frequency} 现有合法值
+ *       {@code none/daily/weekly/monthly/every_other_day/hourly}({@link TaskService#createTask})。</li>
+ *   <li>现状核查(2026-11-14,origin cfclub 7da12b6f4):
+ *       {@code RepeatTaskGenerator} 已实现 fixed 频率(hourly/daily/every_other_day/weekly/monthly)
+ *       实例生成,但 <b>① 无自定义周期配置(frequency=custom + 周期参数)② 月频 31 日在短月无回退
+ *       ({@code templateDay == todayDay} 严格相等)③ 无暂停/恢复控制</b>——三者均为 SC-P-12 契约空白。</li>
+ * </ul>
+ *
+ * <p><b>宿主契约(GREEN 按此落地)</b></p>
+ * <ul>
+ *   <li>{@code CreateTaskDTO.cycleInterval}(Integer,≥1)/ {@code cycleUnit}(String:day/week/month)
+ *       —— 创建入口声明自定义周期(§4.3 /api/tasks/create 频率配置域扩展,不新增路由);</li>
+ *   <li>{@code Task.cycleInterval} / {@code Task.cycleUnit} / {@code Task.scheduleStatus}
+ *       ("active"|"paused",默认 active)—— 周期配置与暂停态落库;</li>
+ *   <li>{@code TaskService.VALID_FREQUENCIES} 应含 {@code "custom"};异常流:cycleInterval ≤ 0 或缺失 → 400;
+ *       cycleInterval 缺省默认每日(cycleUnit=day, interval=1);</li>
+ *   <li>{@code com.etotem.cfc.util.TaskCycleCalculator}(纯计算、无 Spring/DB):
+ *       {@code Date nextOccurrence(Date cycleStart, String cycleUnit, int interval, Date from)}
+ *       —— 严格晚于 from 的下一次发生日(00:00:00,本地时区;按月遇短月回退至当月最后一天,跨年由周期推进保证);
+ *       {@code Date resolveMonthlyDate(int templateDay, int year, int month)} —— month=1..12,短月回退月末;</li>
+ *   <li>{@code RepeatTaskGenerator}:注入 {@code TaskCycleCalculator};新增 frequency=custom 分派;
+ *       提供 {@code generateDailyTasks(Date now)} 重载作为可注入时钟的测试缝;生成前校验模板
+ *       {@code scheduleStatus != "paused"}(暂停中的模板不生成实例);</li>
+ *   <li>{@code TaskService.pauseSchedule(Long taskId, Long userId)} / {@code resumeSchedule(Long taskId, Long userId)}
+ *       —— 返回 Boolean;任务不存在或不属于该用户家庭 → IllegalArgumentException(同 SC-P-10 越权口径);</li>
+ *   <li>{@code TaskController}:{@code @PostMapping("/{id}/pause")}(pauseTask)、
+ *       {@code @PostMapping("/{id}/resume")}(resumeTask),统一 POST 规范。</li>
+ * </ul>
+ *
+ * <p><b>测试范围</b>:纯契约级单测(反射/Mockito),无 Spring / 无 DB / 无外部服务。
+ * 宿主类缺失、字段/方法缺失、默认值未实现均以 {@code fail(...)} 断言暴露(红)。</p>
+ *
+ * <p><b>RED 预期</b>:上述宿主契约当前全部未落地,本类用例在 RED 阶段应全部失败(红);
+ * 失败断言原文带 {@code [task.frequency.config]} 前缀,清晰可贴。</p>
+ *
+ * <p><b>权限流</b>:暂停/恢复为家长对模板的调度控制,存在家庭作用域 → 权限流适用(F1 跨家庭拒绝)。</p>
+ */
+class TaskFrequencyConfigContractTest {
+
+    /** 自建周期计算宿主(当前未实现)。 */
+    private static final String CYCLE_CALCULATOR = "com.etotem.cfc.util.TaskCycleCalculator";
+
+    /** 重复任务生成器(已存在宿主,本契约要求其接入自定义周期与暂停控制)。 */
+    private static final String GENERATOR = "com.etotem.cfc.task.RepeatTaskGenerator";
+
+    // ==================== 正常流-1:自定义周期配置承载(create 声明 → 落库) ====================
+
+    @Test
+    @DisplayName("正常流-1a:CreateTaskDTO 应声明自定义周期入参 cycleInterval/cycleUnit(§4.3 /api/tasks/create)")
+    void createTaskDTO_declaresCustomCycleParams() throws Exception {
+        assertHasField(CreateTaskDTO.class, "cycleInterval",
+                "[task.frequency.config] CreateTaskDTO 缺 cycleInterval 字段(Integer≥1):"
+                        + "SC-P-12 要求创建任务时声明自定义周期,经 §4.3 /api/tasks/create 频率配置域提交。");
+        assertHasField(CreateTaskDTO.class, "cycleUnit",
+                "[task.frequency.config] CreateTaskDTO 缺 cycleUnit 字段(day/week/month):"
+                        + "SC-P-12 要求自定义周期按单位推进(每N天/周/月)。");
+    }
+
+    @Test
+    @DisplayName("正常流-1b:Task 实体应承载周期配置与暂停态(cycleInterval/cycleUnit/scheduleStatus)")
+    void taskEntity_carriesCycleConfigAndScheduleStatus() throws Exception {
+        assertHasField(Task.class, "cycleInterval",
+                "[task.frequency.config] Task 实体缺 cycleInterval 字段:自定义周期配置应落库供实例生成使用。");
+        assertHasField(Task.class, "cycleUnit",
+                "[task.frequency.config] Task 实体缺 cycleUnit 字段:自定义周期单位(day/week/month)应落库。");
+        assertHasField(Task.class, "scheduleStatus",
+                "[task.frequency.config] Task 实体缺 scheduleStatus 字段(active/paused,默认 active):"
+                        + "SC-P-12 暂停/恢复需要模板级调度状态,暂停中的模板不得再生成实例。");
+    }
+
+    @Test
+    @DisplayName("正常流-1c:频率枚举必须支持 custom(自定义周期)")
+    void taskService_frequencyEnum_supportsCustom() throws Exception {
+        Field f = TaskService.class.getDeclaredField("VALID_FREQUENCIES");
+        f.setAccessible(true);
+        @SuppressWarnings("unchecked")
+        Set<String> freqs = (Set<String>) f.get(null);
+        assertTrue(freqs.contains("custom"),
+                "[task.frequency.config] 频率枚举缺 custom:SC-P-12 目标为「设日/周/自定义周期」,"
+                        + "自定义周期必须可作为频率提交,当前合法值=" + freqs + "。");
+    }
+
+    // ==================== 正常流-2:按自定义周期自动生成实例 ====================
+
+    @Test
+    @DisplayName("正常流-2a:纯周期计算宿主 TaskCycleCalculator 应存在(跨月/跨年计算归属)")
+    void taskCycleCalculator_hostExists() {
+        assertNotNull(loadClass(CYCLE_CALCULATOR),
+                "[task.frequency.config] TaskCycleCalculator 未实现:SC-P-12 边界「周期计算跨月/跨年正确」"
+                        + "需要有可独立验证的纯周期计算组件(无 Spring/DB)。");
+    }
+
+    @Test
+    @DisplayName("正常流-2b:TaskCycleCalculator 应提供 nextOccurrence(下一次发生日,跨月/跨年)")
+    void taskCycleCalculator_nextOccurrenceMethod() {
+        Class<?> calc = requireClass(CYCLE_CALCULATOR,
+                "[task.frequency.config] TaskCycleCalculator 未实现,无法验证 nextOccurrence(SC-P-12 按周期生成实例)。");
+        assertHasMethod(calc, "nextOccurrence",
+                "[task.frequency.config] TaskCycleCalculator 应提供 nextOccurrence(Date cycleStart, "
+                        + "String cycleUnit, int interval, Date from),返回严格晚于 from 的下一次发生日。",
+                Date.class, String.class, int.class, Date.class);
+    }
+
+    @Test
+    @DisplayName("正常流-2c:TaskCycleCalculator 应提供 resolveMonthlyDate(月频短月回退)")
+    void taskCycleCalculator_resolveMonthlyDateMethod() {
+        Class<?> calc = requireClass(CYCLE_CALCULATOR,
+                "[task.frequency.config] TaskCycleCalculator 未实现,无法验证 resolveMonthlyDate(SC-P-12 跨月正确)。");
+        assertHasMethod(calc, "resolveMonthlyDate",
+                "[task.frequency.config] TaskCycleCalculator 应提供 resolveMonthlyDate(int templateDay, "
+                        + "int year, int month)(month=1..12):每月31日在2月等短月应回退到当月最后一天。",
+                int.class, int.class, int.class);
+    }
+
+    @Test
+    @DisplayName("正常流-2d:每天周期跨年——2025-12-31 的次日必须为 2026-01-01")
+    void nextOccurrence_dailyCrossesYear() throws Exception {
+        Object calc = contractHost(CYCLE_CALCULATOR);
+        Date next = callDate(calc, "nextOccurrence", date(2025, 11, 31), "day", 1, date(2025, 11, 31));
+        assertEquals(date(2026, 0, 1), next,
+                "[task.frequency.config] 每天周期跨年错误:2025-12-31 的次日应为 2026-01-01,实际=" + next + "。");
+    }
+
+    @Test
+    @DisplayName("正常流-2e:每周周期跨年——2025-12-29(周一)的下一个周一必须为 2026-01-05")
+    void nextOccurrence_weeklyCrossesYear() throws Exception {
+        Object calc = contractHost(CYCLE_CALCULATOR);
+        Date next = callDate(calc, "nextOccurrence", date(2025, 11, 29), "week", 1, date(2025, 11, 29));
+        assertEquals(date(2026, 0, 5), next,
+                "[task.frequency.config] 每周周期跨年错误:2025-12-29 的下一次应落在 2026-01-05,实际=" + next + "。");
+    }
+
+    @Test
+    @DisplayName("正常流-2f:每月31日遇2月短月——2026-01-31 的下一个月实例必须回退到 2026-02-28")
+    void nextOccurrence_monthlyShortMonthFallsBack() throws Exception {
+        Object calc = contractHost(CYCLE_CALCULATOR);
+        Date next = callDate(calc, "nextOccurrence", date(2026, 0, 31), "month", 1, date(2026, 0, 31));
+        assertEquals(date(2026, 1, 28), next,
+                "[task.frequency.config] 月频短月回退错误:31日模板在2月应落到 2026-02-28 而非不生成,实际=" + next + "。");
+    }
+
+    @Test
+    @DisplayName("正常流-2g:自定义周期 interval=2(隔月)越过短月——2026-01-31 后两月为 2026-03-31")
+    void nextOccurrence_customInterval2SkipsShortMonth() throws Exception {
+        Object calc = contractHost(CYCLE_CALCULATOR);
+        Date next = callDate(calc, "nextOccurrence", date(2026, 0, 31), "month", 2, date(2026, 0, 31));
+        assertEquals(date(2026, 2, 31), next,
+                "[task.frequency.config] 自定义周期(月,2)错误:2026-01-31 之后两月应为 2026-03-31,实际=" + next + "。");
+    }
+
+    @Test
+    @DisplayName("正常流-2h:resolveMonthlyDate 短月/闰年回退正确")
+    void resolveMonthlyDate_shortAndLeapMonths() throws Exception {
+        Object calc = contractHost(CYCLE_CALCULATOR);
+        assertEquals(date(2026, 0, 31), callDate(calc, "resolveMonthlyDate", 31, 2026, 1),
+                "[task.frequency.config] 1月31日应原样返回 1月31日。");
+        assertEquals(date(2026, 1, 28), callDate(calc, "resolveMonthlyDate", 31, 2026, 2),
+                "[task.frequency.config] 2026-02(平年)31日应回退到 2月28日。");
+        assertEquals(date(2028, 1, 29), callDate(calc, "resolveMonthlyDate", 31, 2028, 2),
+                "[task.frequency.config] 2028-02(闰年)31日应回退到 2月29日。");
+        assertEquals(date(2026, 3, 30), callDate(calc, "resolveMonthlyDate", 31, 2026, 4),
+                "[task.frequency.config] 4月31日应回退到 4月30日。");
+    }
+
+    // ==================== 正常流-2i:RepeatTaskGenerator 接入自定义周期 ====================
+
+    @Test
+    @DisplayName("正常流-2i:RepeatTaskGenerator 应注入 TaskCycleCalculator 供自定义周期与月度回退分派")
+    void repeatTaskGenerator_injectsCycleCalculator() {
+        Class<?> gen = requireClass(GENERATOR,
+                "[task.frequency.config] RepeatTaskGenerator 不存在:SC-P-12「按周期自动生成实例」的生成宿主。");
+        boolean injected = false;
+        for (Field f : gen.getDeclaredFields()) {
+            if (CYCLE_CALCULATOR.equals(f.getType().getName())) {
+                injected = true;
+                break;
+            }
+        }
+        assertTrue(injected,
+                "[task.frequency.config] RepeatTaskGenerator 未注入 TaskCycleCalculator:"
+                        + "自定义周期与跨月/跨年计算应委托纯计算组件,而非在生成器内手写 Calendar 逻辑。");
+    }
+
+    @Test
+    @DisplayName("正常流-2j:RepeatTaskGenerator 应提供 frequency=custom 分派方法")
+    void repeatTaskGenerator_hasCustomCycleDispatch() {
+        Class<?> gen = requireClass(GENERATOR,
+                "[task.frequency.config] RepeatTaskGenerator 不存在,无法验证 custom 分派。");
+        boolean hasDispatch = false;
+        for (Method m : gen.getDeclaredMethods()) {
+            String name = m.getName().toLowerCase();
+            if (name.contains("custom") || name.contains("cycle")) {
+                hasDispatch = true;
+                break;
+            }
+        }
+        assertTrue(hasDispatch,
+                "[task.frequency.config] RepeatTaskGenerator 缺 frequency=custom 分派:"
+                        + "SC-P-12「配频率 → 按周期自动生成实例」应覆盖自定义周期(每N天/周/月)。");
+    }
+
+    @Test
+    @DisplayName("正常流-2k:RepeatTaskGenerator 应提供 generateDailyTasks(Date) 可注入时钟重载(测试缝)")
+    void repeatTaskGenerator_hasDateSeam() {
+        Class<?> gen = requireClass(GENERATOR,
+                "[task.frequency.config] RepeatTaskGenerator 不存在,无法验证测试缝。");
+        assertHasMethod(gen, "generateDailyTasks",
+                "[task.frequency.config] RepeatTaskGenerator 应提供 generateDailyTasks(Date now) 重载"
+                        + "(原无参方法委托之):周期实例生成需可注入时钟以验证跨月/跨年/暂停边界。",
+                Date.class);
+    }
+
+    // ==================== 边界流:暂停/恢复 ====================
+
+    @Test
+    @DisplayName("边界流-1:暂停中的模板不得生成实例(generateDailyTasks(Date) 走查)")
+    void repeatTaskGenerator_pausedTemplate_notGenerated() throws Exception {
+        Class<?> gen = requireClass(GENERATOR,
+                "[task.frequency.config] RepeatTaskGenerator 不存在,无法验证暂停跳过行为。");
+        Method seam = findMethod(gen, "generateDailyTasks", Date.class);
+        assertNotNull(seam,
+                "[task.frequency.config] RepeatTaskGenerator 缺 generateDailyTasks(Date) 测试缝"
+                        + "(SC-P-12 暂停/恢复行为验证入口)。");
+
+        Object generator = gen.getDeclaredConstructor().newInstance();
+        TaskMapper taskMapper = mock(TaskMapper.class);
+        FamilyMemberMapper familyMemberMapper = mock(FamilyMemberMapper.class);
+        inject(generator, "taskMapper", taskMapper);
+        inject(generator, "familyMemberMapper", familyMemberMapper);
+
+        Task pausedTemplate = pausedTemplate();
+        when(taskMapper.selectList(any(Wrapper.class)))
+                .thenReturn(java.util.Collections.singletonList(pausedTemplate),
+                        java.util.Collections.emptyList(), java.util.Collections.emptyList(),
+                        java.util.Collections.emptyList(), java.util.Collections.emptyList());
+        when(taskMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
+
+        seam.invoke(generator, date(2026, 10, 14)); // 2026-11-14
+
+        verify(taskMapper, never()).insert(any(Task.class));
+        assertTrue(true, "[task.frequency.config] 暂停中的模板未生成实例(插入未发生)。");
+    }
+
+    @Test
+    @DisplayName("边界流-2:TaskService 应提供暂停/恢复调度入口 pauseSchedule/resumeSchedule")
+    void taskService_hasPauseResumeSchedule() throws Exception {
+        assertHasMethod(TaskService.class, "pauseSchedule",
+                "[task.frequency.config] TaskService 缺 pauseSchedule(Long taskId, Long userId):"
+                        + "SC-P-12「暂停」需服务层入口(校验家庭归属后置 scheduleStatus=paused)。",
+                Long.class, Long.class);
+        assertHasMethod(TaskService.class, "resumeSchedule",
+                "[task.frequency.config] TaskService 缺 resumeSchedule(Long taskId, Long userId):"
+                        + "SC-P-12「恢复」需服务层入口(置回 active 后重新纳入实例生成)。",
+                Long.class, Long.class);
+    }
+
+    @Test
+    @DisplayName("边界流-3:TaskController 应提供 POST /{id}/pause 与 POST /{id}/resume 端点")
+    void taskController_hasPauseResumeEndpoints() throws Exception {
+        com.etotem.cfc.controller.task.TaskController controller = new com.etotem.cfc.controller.task.TaskController();
+        assertHasMethod(controller.getClass(), "pauseTask",
+                "[task.frequency.config] TaskController 缺 @PostMapping(\"/{id}/pause\") pauseTask 端点:"
+                        + "SC-P-12 暂停经任务详情页操作,模块 TaskController。",
+                Long.class);
+        assertHasMethod(controller.getClass(), "resumeTask",
+                "[task.frequency.config] TaskController 缺 @PostMapping(\"/{id}/resume\") resumeTask 端点:"
+                        + "SC-P-12 恢复经任务详情页操作。",
+                Long.class);
+    }
+
+    // ==================== 异常流:非法自定义周期参数 ====================
+
+    @Test
+    @DisplayName("异常流-1:frequency=custom 且 cycleInterval≤0 → 400 周期参数错误,不落库")
+    void createTask_customInvalidInterval_rejected() throws Exception {
+        Harness h = harness();
+        CreateTaskDTO dto = customCycleDto(0);
+        IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+                () -> h.service.createTask(1L, dto));
+        assertTrue(e.getMessage().contains("周期"),
+                "[task.frequency.config] custom 周期且 interval≤0 应被拒绝并提示周期参数错误,"
+                        + "实际提示: " + e.getMessage());
+        verify(h.taskMapper, never()).insert(any(Task.class));
+    }
+
+    @Test
+    @DisplayName("异常流-2:frequency=custom 缺省周期参数 → 默认每日(cycleUnit=day, cycleInterval=1)落库")
+    void createTask_customNoInterval_defaultsDaily() throws Exception {
+        Harness h = harness();
+        CreateTaskDTO dto = customCycleDto(null);
+        AtomicReference<Task> created = new AtomicReference<>();
+        assertDoesNotThrow(() -> {
+            h.service.createTask(1L, dto);
+            created.set(h.inserted.get(0));
+        }, "[task.frequency.config] frequency=custom 缺省周期参数应可创建(默认每日),当前被拒绝。");
+        assertEquals("day", reflectField(created.get(), "cycleUnit"),
+                "[task.frequency.config] custom 缺省周期应默认 cycleUnit=day。");
+        assertEquals(1, reflectField(created.get(), "cycleInterval"),
+                "[task.frequency.config] custom 缺省周期应默认 cycleInterval=1(每日一次)。");
+    }
+
+    // ==================== 权限流:暂停/恢复家庭作用域 ====================
+
+    @Test
+    @DisplayName("权限流:暂停他人家庭的任务模板 → 拒绝且不改库")
+    void pauseSchedule_crossFamily_rejected() throws Exception {
+        Harness h = harness();
+        Task otherFamilyTask = new Task();
+        otherFamilyTask.setId(9999L);
+        otherFamilyTask.setFamilyId(999L);
+        otherFamilyTask.setTitle("他家任务");
+        when(h.taskMapper.selectById(9999L)).thenReturn(otherFamilyTask);
+        // 当前用户 familyId=1(harness 固定),操作 familyId=999 的任务 → 越权
+
+        Method pause = findMethod(TaskService.class, "pauseSchedule", Long.class, Long.class);
+        assertNotNull(pause,
+                "[task.frequency.config] TaskService 缺 pauseSchedule:无法验证暂停的家庭作用域(SC-P-12 暂停/恢复)。");
+        IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+                () -> pause.invoke(h.service, 9999L, 1L));
+        assertTrue(e.getMessage().contains("不属于本家庭"),
+                "[task.frequency.config] 暂停他人家庭任务应拒绝并提示不属于本家庭,实际: " + e.getMessage());
+        verify(h.taskMapper, never()).updateById(any(Task.class));
+    }
+
+    // ==================== 辅助构造 ====================
+
+    private Task pausedTemplate() throws Exception {
+        Task t = new Task();
+        t.setId(9001L);
+        t.setFamilyId(1L);
+        t.setFamilyMemberId(1L);
+        t.setMemberId(100L);
+        t.setTitle("暂停模板");
+        t.setFrequency("daily");
+        t.setRepeatType("none");
+        t.setIsTemplate(1);
+        t.setNeedReview(0);
+        t.setStatus("pending");
+        t.setCreatedAt(new Date(date(2026, 10, 13).getTime()));
+        t.setDeadline(date(2026, 11, 31));
+        setFieldOrFail(t, "scheduleStatus", "paused",
+                "[task.frequency.config] Task 缺 scheduleStatus 字段:无法构造暂停中的模板。");
+        return t;
+    }
+
+    private CreateTaskDTO customCycleDto(Integer interval) throws Exception {
+        CreateTaskDTO dto = new CreateTaskDTO();
+        dto.setTitle("自定义周期任务");
+        dto.setPoints(5);
+        dto.setMemberId(100L);
+        dto.setExecutorType("child");
+        dto.setNeedReview(0);
+        dto.setFrequency("custom");
+        setFieldOrFail(dto, "cycleInterval", interval,
+                "[task.frequency.config] CreateTaskDTO 缺 cycleInterval 字段:无法声明自定义周期。");
+        setFieldOrFail(dto, "cycleUnit", "day",
+                "[task.frequency.config] CreateTaskDTO 缺 cycleUnit 字段:无法声明自定义周期单位。");
+        return dto;
+    }
+
+    /** TaskService 装配(Mockito,无 Spring/DB,风格同 TaskFrequencyScP12ContractTest)。 */
+    private static final class Harness {
+        final TaskService service = new TaskService();
+        final UserService userService = mock(UserService.class);
+        final FamilyMemberMapper familyMemberMapper = mock(FamilyMemberMapper.class);
+        final TaskMapper taskMapper = mock(TaskMapper.class);
+        final List<Task> inserted = new ArrayList<>();
+        long idSeq = 8000L;
+    }
+
+    private Harness harness() throws Exception {
+        Harness h = new Harness();
+        inject(h.service, "userService", h.userService);
+        inject(h.service, "familyMemberMapper", h.familyMemberMapper);
+        inject(h.service, "taskMapper", h.taskMapper);
+        inject(h.service, "miniGameMapper", mock(MiniGameMapper.class));
+        inject(h.service, "surveyTemplateMapper", mock(SurveyTemplateMapper.class));
+
+        User user = new User();
+        user.setId(1L);
+        user.setFamilyId(1L);
+        when(h.userService.getUserInfo(any())).thenReturn(user);
+        when(h.taskMapper.selectCount(any(Wrapper.class))).thenReturn(1L);
+        when(h.taskMapper.insert(any(Task.class))).thenAnswer(inv -> {
+            Task t = inv.getArgument(0);
+            if (t.getId() == null) {
+                t.setId(++h.idSeq);
+            }
+            t.setCreatedAt(new Date());
+            h.inserted.add(t);
+            return 1;
+        });
+        when(h.taskMapper.selectList(any(Wrapper.class))).thenReturn(new ArrayList<>());
+        return h;
+    }
+
+    // ==================== 反射辅助 ====================
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    private Class<?> requireClass(String name, String msg) {
+        Class<?> clazz = loadClass(name);
+        if (clazz == null) {
+            fail(msg);
+        }
+        return clazz;
+    }
+
+    /** 宿主实例(不存在即 fail,明确契约缺口)。 */
+    private Object contractHost(String name) throws Exception {
+        Class<?> clazz = loadClass(name);
+        if (clazz == null) {
+            fail("[task.frequency.config] 宿主 " + name + " 未实现:SC-P-12 契约行为缺失,测试红。");
+        }
+        return clazz.getDeclaredConstructor().newInstance();
+    }
+
+    private void assertHasField(Class<?> clazz, String field, String msg) throws Exception {
+        try {
+            clazz.getDeclaredField(field);
+        } catch (NoSuchFieldException e) {
+            fail(msg);
+        }
+    }
+
+    private void assertHasMethod(Class<?> clazz, String name, String msg, Class<?>... paramTypes) {
+        if (findMethod(clazz, name, paramTypes) == null) {
+            fail(msg);
+        }
+    }
+
+    private Method findMethod(Class<?> clazz, String name, Class<?>... paramTypes) {
+        try {
+            return clazz.getDeclaredMethod(name, paramTypes);
+        } catch (NoSuchMethodException e) {
+            return null;
+        }
+    }
+
+    private void inject(Object target, String field, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(field);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
+    private void setFieldOrFail(Object target, String field, Object value, String msg) throws Exception {
+        try {
+            Field f = target.getClass().getDeclaredField(field);
+            f.setAccessible(true);
+            f.set(target, value);
+        } catch (NoSuchFieldException e) {
+            fail(msg);
+        }
+    }
+
+    private Object reflectField(Object target, String field) throws Exception {
+        try {
+            Field f = target.getClass().getDeclaredField(field);
+            f.setAccessible(true);
+            return f.get(target);
+        } catch (NoSuchFieldException e) {
+            fail("[task.frequency.config] " + target.getClass().getSimpleName()
+                    + " 缺 " + field + " 字段:契约字段未落地。");
+            return null;
+        }
+    }
+
+    private Date callDate(Object host, String method, Object... args) throws Exception {
+        Class<?>[] types = new Class<?>[args.length];
+        for (int i = 0; i < args.length; i++) {
+            types[i] = primitiveType(args[i].getClass());
+        }
+        Method m = host.getClass().getMethod(method, types);
+        return (Date) m.invoke(host, args);
+    }
+
+    private Class<?> primitiveType(Class<?> c) {
+        if (c == Integer.class) return int.class;
+        if (c == Long.class) return long.class;
+        if (c == Boolean.class) return boolean.class;
+        if (c == Double.class) return double.class;
+        if (c == Float.class) return float.class;
+        if (c == Short.class) return short.class;
+        if (c == Byte.class) return byte.class;
+        return c;
+    }
+
+    /** local 零点日期(javadoc 契约为本地时区 00:00:00)。 */
+    private Date date(int year, int month0, int day) {
+        Calendar c = Calendar.getInstance();
+        c.clear();
+        c.set(year, month0, day);
+        return c.getTime();
+    }
+}