主人 2026-10-07:「.env 只做兜底…优先从面板配置上取」+「频率必须调…或者动态
分配,比如白天间隔短一点 晚上到了静默时期稍微长一点」。
- memory-tidy.json 新增四键:intervalSec / quietIntervalSec / quietStartHour /
quietEndHour(*int + 指针判定 => 显式 0 是合法值、缺位才回落)
- 优先级逐键三档:面板 > env(既有键,未增删)> 代码默认;坏值不覆盖 + WARN,
且不启动失败
- 动态间隔由纯函数 intervalAt(at) 决定,调度侧每一拍现算 => 跑完按当时时段重算
- 🔴 语义变更(有意,代码注释已登记,待补设计修订):安静时段从「唯一可跑时段」
变成「降频时段」—— 旧的「安静窗外一律 continue」被删,否则 intervalSec 永远
是空壳(白天唤醒必被跳过);窗口仍决定档位与补跑判据
- 6 个变异(含「加回旧的那道门」=> 端到端用例红)
188 lines
7.6 KiB
Go
188 lines
7.6 KiB
Go
package memory
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// 本文件锁定**分时段的动态频率**(主人 2026-10-07:「频率必须调 …… 或者动态分配,
|
||
// 比如白天间隔短一点,晚上到了静默时期稍微长一点」):
|
||
//
|
||
// ① 非安静时段用 intervalSec、安静时段用 quietIntervalSec(intervalAt);
|
||
// ② 缺省(quietIntervalSec 未配)时两者相等 —— 也就是**行为与改动前一致**;
|
||
// ③ 间隔在**每一轮跑完之后按当时时段重算**(不是在启动时算一次定死):
|
||
// 否则白天/晚上的切换不会生效;
|
||
// ④ 到点判据(dueNow)用**当时时段**的周期,与 nextDelay 同源,不会各说各话;
|
||
// ⑤ 安静时段不再是「唯一可跑时段」:窗口外也按 intervalSec 跑(①的直接推论,
|
||
// 由 ③ 的用例端到端证明)。
|
||
|
||
// quietWindowCfg 造一份分档周期配置:安静 22:00-02:00(跨午夜),
|
||
// 白天 2h 一次、夜里 6h 一次。
|
||
func quietWindowCfg(t *testing.T) TidyConfig {
|
||
t.Helper()
|
||
cfg := tidyTestCfg(0.80)
|
||
cfg.IntervalSec = 2 * 3600
|
||
cfg.QuietIntervalSec = 6 * 3600
|
||
cfg.QuietStartHour, cfg.QuietEndHour = 22, 2
|
||
return cfg
|
||
}
|
||
|
||
// TestTidyConfigIntervalAt_SwitchesByPeriod 钉住 intervalAt 的取档口径与
|
||
// inQuietHours 逐字同源(含跨午夜、边界、窗口关闭)。
|
||
func TestTidyConfigIntervalAt_SwitchesByPeriod(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
cfg := normalizeTidyConfig(quietWindowCfg(t))
|
||
at := func(hour, min int) time.Time {
|
||
return time.Date(2026, 10, 7, hour, min, 0, 0, time.Local)
|
||
}
|
||
cases := []struct {
|
||
name string
|
||
at time.Time
|
||
want time.Duration
|
||
}{
|
||
{"白天(14:00)用 intervalSec", at(14, 0), 2 * time.Hour},
|
||
{"窗口前一分钟(21:59)仍用 intervalSec", at(21, 59), 2 * time.Hour},
|
||
{"窗口起点(22:00)用 quietIntervalSec", at(22, 0), 6 * time.Hour},
|
||
{"跨午夜的半夜(00:30)用 quietIntervalSec", at(0, 30), 6 * time.Hour},
|
||
{"窗口终点前(01:59)用 quietIntervalSec", at(1, 59), 6 * time.Hour},
|
||
{"窗口终点(02:00)回到 intervalSec", at(2, 0), 2 * time.Hour},
|
||
}
|
||
for _, tc := range cases {
|
||
tc := tc
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
got := cfg.intervalAt(tc.at)
|
||
if got != tc.want {
|
||
t.Fatalf("intervalAt(%s) = %s, want %s", tc.at.Format("15:04"), got, tc.want)
|
||
}
|
||
// 与既有安静判定**同源**:落在哪一档当且仅当 inQuietHours。
|
||
want := cfg.Interval
|
||
if cfg.inQuietHours(tc.at) {
|
||
want = cfg.QuietInterval
|
||
}
|
||
if got != want {
|
||
t.Fatalf("intervalAt 与 inQuietHours 不同源:%s interval=%s, 期望 %s",
|
||
tc.at.Format("15:04"), got, want)
|
||
}
|
||
})
|
||
}
|
||
|
||
// 缺省(quietIntervalSec 未配)⇒ 两档相等 = 行为与本次改动之前逐字一致。
|
||
plain := normalizeTidyConfig(tidyTestCfg(0.80))
|
||
if plain.QuietIntervalSec != plain.IntervalSec || plain.QuietInterval != plain.Interval {
|
||
t.Fatalf("缺省时两档必须相等:interval=%s quiet=%s", plain.Interval, plain.QuietInterval)
|
||
}
|
||
if got := plain.intervalAt(at(23, 30)); got != plain.Interval {
|
||
t.Fatalf("缺省时分档不得改变频率:得到 %s, want %s", got, plain.Interval)
|
||
}
|
||
|
||
// 窗口被显式关闭(两端相等且非 0)⇒ 不换档:关闭窗口的意思是「没有低峰这回事」,
|
||
// 而不是「全天都算低峰」。
|
||
closed := normalizeTidyConfig(quietWindowCfg(t))
|
||
closed.QuietStartHour, closed.QuietEndHour = 5, 5
|
||
for _, h := range []int{0, 5, 12, 23} {
|
||
if got := closed.intervalAt(at(h, 0)); got != closed.Interval {
|
||
t.Fatalf("窗口关闭时 %02d:00 的间隔 = %s, want %s", h, got, closed.Interval)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestTidyServiceDueNowUsesPeriodInterval 钉住「到点」判据用的是**当时时段**的周期。
|
||
//
|
||
// 用例刻意让「按白天档会判到点、按夜里档不会」:安静 23:00-07:00,白天 1h、
|
||
// 夜里 8h。上次跑在 23:05,此刻 01:00 ⇒ 距上次 1h55m:若拿白天的 1h 去比会判
|
||
// 「到点」(那是错的),拿夜里的 8h 才判「不到点」。
|
||
func TestTidyServiceDueNowUsesPeriodInterval(t *testing.T) {
|
||
t.Parallel()
|
||
store := newTestStore(t)
|
||
|
||
cfg := tidyTestCfg(0.80)
|
||
cfg.IntervalSec = 3600
|
||
cfg.QuietIntervalSec = 8 * 3600
|
||
cfg.QuietStartHour, cfg.QuietEndHour = 23, 7
|
||
svc := newTidyServiceForTest(t, store, cfg, nil, nil, nil)
|
||
|
||
set := func(now, last time.Time) {
|
||
svc.now = func() time.Time { return now }
|
||
svc.lastRun = last
|
||
}
|
||
last := time.Date(2026, 10, 7, 23, 5, 0, 0, time.Local)
|
||
|
||
set(time.Date(2026, 10, 8, 1, 0, 0, 0, time.Local), last)
|
||
if due, reason := svc.dueNow(); due {
|
||
t.Fatalf("夜里距上次 1h55m 不该到点(夜里的档是 8h),得到 due=true(%s)", reason)
|
||
}
|
||
|
||
set(time.Date(2026, 10, 9, 1, 0, 0, 0, time.Local), last)
|
||
if due, _ := svc.dueNow(); !due {
|
||
t.Fatal("夜里距上次已 25h55m 必须到点(≥ 8h)")
|
||
}
|
||
|
||
// 白天档:上次跑在 09:00,此刻 10:30 ⇒ 1.5h ≥ 1h ⇒ 到点。
|
||
set(time.Date(2026, 10, 7, 10, 30, 0, 0, time.Local),
|
||
time.Date(2026, 10, 7, 9, 0, 0, 0, time.Local))
|
||
if due, _ := svc.dueNow(); !due {
|
||
t.Fatal("白天距上次 1.5h 必须到点(白天的档是 1h)")
|
||
}
|
||
}
|
||
|
||
// TestTidyServiceRecomputesIntervalAfterEachRound 是**动态频率**的端到端证明:
|
||
// 安静 22:00-02:00(跨午夜),白天 2h、夜里 6h。20:00 从零启动(无历史)⇒
|
||
// 首次落在窗口起点 22:00;此后:
|
||
//
|
||
// 22:00 跑完 ⇒ 按**当时**(窗口内)的档 = 6h ⇒ 下一拍 04:00(已在窗口外)
|
||
// 04:00 跑完 ⇒ 按白天的档 = 2h ⇒ 06:00 ⇒ 08:00
|
||
//
|
||
// 断言的是**留痕的时刻序列**与其间隔:22:00 → 04:00 是 6h(夜里的档),
|
||
// 04:00 → 06:00 → 08:00 都是 2h(白天的档)。若把间隔在启动时算一次定死
|
||
// (或全程按同一个周期跑),这段序列不可能是这个形状 —— 变异用例钉住了它。
|
||
func TestTidyServiceRecomputesIntervalAfterEachRound(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, 20, 0, 0, 0, time.Local)}
|
||
probe := newTidyWaitProbe(clock, 4) // 4 拍之后退出(心跳 #5 返回 false)
|
||
svc := newTidyServiceForTest(t, store, quietWindowCfg(t), nil, clock.now, nil)
|
||
svc.wait = probe.wait
|
||
svc.Start(ctx)
|
||
probe.awaitCalls(t, 5)
|
||
if err := svc.Stop(ctx); err != nil {
|
||
t.Fatalf("Stop: %v", err)
|
||
}
|
||
|
||
first := time.Date(2026, 10, 7, 22, 0, 0, 0, time.Local)
|
||
want := []time.Time{
|
||
first.Add(10 * time.Hour), // 08:00(次日)
|
||
first.Add(8 * time.Hour), // 06:00
|
||
first.Add(6 * time.Hour), // 04:00(窗口外)
|
||
first, // 22:00(窗口起点)
|
||
}
|
||
gotRuns := tidyRunTimes(t, store)
|
||
if len(gotRuns) != len(want) {
|
||
t.Fatalf("运行留痕 = %v, want %d 轮", gotRuns, len(want))
|
||
}
|
||
for i, w := range want {
|
||
if gotRuns[i] != w.UTC().Format(time.RFC3339) {
|
||
t.Fatalf("第 %d 轮留痕 = %s, want %s(全部:%v)",
|
||
i+1, gotRuns[i], w.UTC().Format(time.RFC3339), gotRuns)
|
||
}
|
||
}
|
||
|
||
// 间隔必须**分档**:第一段(夜里)6h,其后(白天)2h。这一条同时证明窗口外也跑。
|
||
// want 是「最新在前」,因此这里按时间顺序取差。
|
||
gaps := []time.Duration{
|
||
want[2].Sub(want[3]), // 22:00 → 04:00:跨过窗口终点,用的是夜里的档
|
||
want[1].Sub(want[2]), // 04:00 → 06:00:窗口外,用白天的档
|
||
want[0].Sub(want[1]), // 06:00 → 08:00
|
||
}
|
||
if gaps[0] != 6*time.Hour {
|
||
t.Fatalf("夜里那一轮之后的间隔 = %s, want 6h(quietIntervalSec)", gaps[0])
|
||
}
|
||
if gaps[1] != 2*time.Hour || gaps[2] != 2*time.Hour {
|
||
t.Fatalf("白天间隔 = %s / %s, want 2h(intervalSec)", gaps[1], gaps[2])
|
||
}
|
||
}
|