package memory import ( "context" "sync" "testing" "time" ) // 本文件锁定记忆整理**调度修复**(切片 TIDY-1)的三条判据: // // ① **重启不饿死**:进程在安静窗内启动、且上次 run 已超过 interval ⇒ 启动即补跑 // (旧实现只看进程内 lastRun + 24h ticker,后端两天重启十几次 ⇒ 永远不跑); // ② **跨过安静窗起点会跑**:启动在窗口外、无历史 ⇒ 等到窗口起点那一轮自己跑; // ③ **不重复跑**:同一安静窗内已经跑过(lastRun 在窗口起点之后)⇒ 重启不再跑, // 否则「修好饿死」会变成「每次重启都全库扫一遍」。 // // 用例全部用**注入时钟 + 假等待**:不真等、不靠真实时间。假等待把假时钟推进到 // 下一次唤醒时刻,因此「跨过窗口起点」是可确定性验证的。 // schedClock 是可推进的假时钟。 type schedClock struct { mu sync.Mutex at time.Time } func (c *schedClock) now() time.Time { c.mu.Lock() defer c.mu.Unlock() return c.at } func (c *schedClock) advance(d time.Duration) { c.mu.Lock() defer c.mu.Unlock() c.at = c.at.Add(d) } // tidyWaitProbe 是假等待 + 「已被调用」信号的组合:假等待把假时钟推进到下一次 // 唤醒时刻,同时把每次调用记进 signals —— 于是用例可以**确定性地**等到 // 「循环已经走到某个阶段」再断言 / 再 Stop,不必真等也不必 sleep。 // // 信号序列与循环的对应关系:第 1 次 wait 之前一定已经做完启动补跑判定; // 第 N+1 次 wait 之前一定已经做完第 N 次唤醒后的那一轮。 type tidyWaitProbe struct { clock *schedClock budget int calls int signals chan int } func newTidyWaitProbe(clock *schedClock, budget int) *tidyWaitProbe { return &tidyWaitProbe{clock: clock, budget: budget, signals: make(chan int, 16)} } func (p *tidyWaitProbe) wait(ctx context.Context, d time.Duration) bool { p.calls++ select { case p.signals <- p.calls: default: } if p.calls > p.budget { return false } p.clock.advance(d) return true } // awaitCalls 等到假等待已被调用 n 次(每次至多等 5 秒;超时即 FAIL)。 func (p *tidyWaitProbe) awaitCalls(t *testing.T, n int) { t.Helper() for i := 0; i < n; i++ { select { case <-p.signals: case <-time.After(5 * time.Second): t.Fatalf("等待第 %d 次心跳唤醒超时(循环没走到预期阶段)", i+1) } } } // seedTidyRun 直接落一条运行留痕(模拟「上次 run 的持久事实」)。 func seedTidyRun(t *testing.T, store *Store, startedAt time.Time) { t.Helper() if err := store.persistTidyRun(context.Background(), TidyRun{ ID: mustNewTidyID(), StartedAt: startedAt.UTC(), FinishedAt: startedAt.UTC(), RuleVersion: TidyRuleVersion, }); err != nil { t.Fatalf("写运行留痕: %v", err) } } // tidyRunTimes 返回运行留痕的开始时刻(最新在前)。 func tidyRunTimes(t *testing.T, store *Store) []string { t.Helper() runs, err := store.ListTidyRuns(context.Background(), 50) if err != nil { t.Fatalf("ListTidyRuns: %v", err) } out := make([]string, 0, len(runs)) for _, r := range runs { out = append(out, r.StartedAt.UTC().Format(time.RFC3339)) } return out } // TestTidyServiceCatchUpAfterRestartDoesNotStarve 是判据①:上次 run 已超过 // interval(这里用 25h),进程在安静窗内重启 ⇒ **启动即补跑**,不等 ticker。 // // 假等待立刻返回 false(模拟「还没到下一个窗口,进程又被重启了」)—— 这正是 // 后端两天重启十几次的真实情形:只看 ticker 的实现会永远不跑。 func TestTidyServiceCatchUpAfterRestartDoesNotStarve(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) seedTidyItem(t, store, habitItem(fixtureHabitShort, tidyTestTime(t), 0.9)) // 上次 run:两天前 23:00(安静窗内),现在 23:30 ⇒ 距上次 48.5h ≥ 24h。 last := time.Date(2026, 10, 5, 23, 0, 0, 0, time.Local) seedTidyRun(t, store, last) now := time.Date(2026, 10, 7, 23, 30, 0, 0, time.Local) clock := &schedClock{at: now} probe := newTidyWaitProbe(clock, 0) // 一次都不等到下一次窗口 svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, clock.now, nil) svc.wait = probe.wait svc.Start(ctx) probe.awaitCalls(t, 1) // 循环已走完启动补跑判定并进入等待 if err := svc.Stop(ctx); err != nil { t.Fatalf("Stop: %v", err) } times := tidyRunTimes(t, store) if len(times) != 2 { t.Fatalf("运行留痕 = %v, want 2(历史 1 + 启动补跑 1)", times) } if times[0] != now.UTC().Format(time.RFC3339) { t.Fatalf("补跑时刻 = %q, want 启动时刻 %q", times[0], now.UTC().Format(time.RFC3339)) } } // TestTidyServiceCatchUpOnFirstStartInsideQuietHours 是判据①的「无历史」分支: // 全新库在安静窗内启动 ⇒ 立刻跑一轮(旧口径会一直等到下一个 23:00)。 func TestTidyServiceCatchUpOnFirstStartInsideQuietHours(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) seedTidyItem(t, store, habitItem(fixtureHabitShort, tidyTestTime(t), 0.9)) now := time.Date(2026, 10, 7, 23, 5, 0, 0, time.Local) clock := &schedClock{at: now} probe := newTidyWaitProbe(clock, 0) svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, clock.now, nil) svc.wait = probe.wait svc.Start(ctx) probe.awaitCalls(t, 1) if err := svc.Stop(ctx); err != nil { t.Fatalf("Stop: %v", err) } times := tidyRunTimes(t, store) if len(times) != 1 || times[0] != now.UTC().Format(time.RFC3339) { t.Fatalf("运行留痕 = %v, want 恰为启动时刻 %q", times, now.UTC().Format(time.RFC3339)) } } // TestTidyServiceRunsWhenQuietWindowStarts 是判据②:启动在窗口外(22:00)且无 // 历史 ⇒ 先不跑;假等待把时钟推进到窗口起点(23:00)⇒ 心跳跑一轮。 func TestTidyServiceRunsWhenQuietWindowStarts(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) seedTidyItem(t, store, habitItem(fixtureHabitShort, tidyTestTime(t), 0.9)) clock := &schedClock{at: time.Date(2026, 10, 7, 22, 0, 0, 0, time.Local)} probe := newTidyWaitProbe(clock, 1) // 第一次唤醒(= 窗口起点)后放行,第二次退出 svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, clock.now, nil) svc.wait = probe.wait svc.Start(ctx) probe.awaitCalls(t, 2) // 第 2 次唤醒之前必然已跑完窗口起点那一轮 if err := svc.Stop(ctx); err != nil { t.Fatalf("Stop: %v", err) } want := time.Date(2026, 10, 7, 23, 0, 0, 0, time.Local) times := tidyRunTimes(t, store) if len(times) != 1 || times[0] != want.UTC().Format(time.RFC3339) { t.Fatalf("运行留痕 = %v, want 恰为窗口起点 %q", times, want.UTC().Format(time.RFC3339)) } } // TestTidyServiceDoesNotRerunInsideSameQuietWindow 是判据③(防过度补跑): // 同一安静窗内已经跑过 ⇒ 重启不再跑。窗口是跨午夜的(23:00–07:00),因此 // 用例特意用「今天 00:30」这种「窗口起点在昨天 23:00」的时刻。 func TestTidyServiceDoesNotRerunInsideSameQuietWindow(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) seedTidyItem(t, store, habitItem(fixtureHabitShort, tidyTestTime(t), 0.9)) last := time.Date(2026, 10, 6, 23, 5, 0, 0, time.Local) // 窗口起点 23:00 之后 seedTidyRun(t, store, last) clock := &schedClock{at: time.Date(2026, 10, 7, 0, 30, 0, 0, time.Local)} // 同一窗口内 probe := newTidyWaitProbe(clock, 0) svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, clock.now, nil) svc.wait = probe.wait svc.Start(ctx) probe.awaitCalls(t, 1) if err := svc.Stop(ctx); err != nil { t.Fatalf("Stop: %v", err) } if times := tidyRunTimes(t, store); len(times) != 1 { t.Fatalf("运行留痕 = %v, want 只保留历史那一条(同一窗口不重复跑)", times) } } // TestTidyServiceDueNowJudgements 把 dueNow 的三条判据逐条钉死(纯函数式断言, // 不涉及 goroutine)。 func TestTidyServiceDueNowJudgements(t *testing.T) { t.Parallel() store := newTestStore(t) svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, nil, nil) // 时钟由用例直接改写 svc.now(不经过 Start)。 at := func(tm time.Time) { svc.now = func() time.Time { return tm } } setLast := func(tm time.Time) { svc.lastRun = tm } cases := []struct { name string now time.Time last time.Time due bool }{ { name: "无历史且在窗口外 ⇒ 不补跑", now: time.Date(2026, 10, 7, 15, 0, 0, 0, time.Local), last: time.Time{}, due: false, }, { name: "无历史且在窗口内 ⇒ 补跑", now: time.Date(2026, 10, 7, 23, 30, 0, 0, time.Local), last: time.Time{}, due: true, }, { name: "距上次已超 interval ⇒ 补跑(窗口外也跑)", now: time.Date(2026, 10, 7, 15, 0, 0, 0, time.Local), last: time.Date(2026, 10, 6, 14, 0, 0, 0, time.Local), due: true, }, { name: "窗口内且本窗口未跑 ⇒ 补跑", now: time.Date(2026, 10, 7, 23, 30, 0, 0, time.Local), last: time.Date(2026, 10, 7, 12, 0, 0, 0, time.Local), due: true, }, { name: "窗口内且本窗口已跑 ⇒ 不补跑(跨午夜也算同一窗口)", now: time.Date(2026, 10, 7, 0, 30, 0, 0, time.Local), last: time.Date(2026, 10, 6, 23, 5, 0, 0, time.Local), due: false, }, { name: "窗口外且未到周期 ⇒ 不补跑", now: time.Date(2026, 10, 7, 15, 0, 0, 0, time.Local), last: time.Date(2026, 10, 7, 2, 0, 0, 0, time.Local), due: false, }, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { at(tc.now) setLast(tc.last) due, reason := svc.dueNow() if due != tc.due { t.Fatalf("dueNow = %v(%s), want %v", due, reason, tc.due) } if due && reason == "" { t.Fatal("补跑必须带原因标签(日志/排障要用)") } }) } } // TestTidyServiceManualRunRespectsDisabled 钉住手动真跑入口的口径:关闭时如实 // 返回 ErrTidyDisabled(不退化成空结果),开启时与 RunOnce 同一条路径。 func TestTidyServiceManualRunRespectsDisabled(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) off := tidyTestCfg(0.80) off.Enabled = false svcOff := newTidyServiceForTest(t, store, off, nil, nil, nil) if _, err := svcOff.ManualRun(ctx); err != ErrTidyDisabled { t.Fatalf("关闭时 ManualRun err = %v, want ErrTidyDisabled", err) } on := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, nil, nil) stats, err := on.ManualRun(ctx) if err != nil { t.Fatalf("ManualRun: %v", err) } if stats.RunID == "" { t.Fatal("真跑必须留下运行 id(候选按它归属)") } runs := tidyRunTimes(t, store) if len(runs) != 1 { t.Fatalf("运行留痕 = %v, want 1", runs) } }