feat(memory): 整理频率走面板配置(面板 > env > 默认)+ 分时段动态间隔

主人 2026-10-07:「.env 只做兜底…优先从面板配置上取」+「频率必须调…或者动态
分配,比如白天间隔短一点 晚上到了静默时期稍微长一点」。

- memory-tidy.json 新增四键:intervalSec / quietIntervalSec / quietStartHour /
  quietEndHour(*int + 指针判定 => 显式 0 是合法值、缺位才回落)
- 优先级逐键三档:面板 > env(既有键,未增删)> 代码默认;坏值不覆盖 + WARN,
  且不启动失败
- 动态间隔由纯函数 intervalAt(at) 决定,调度侧每一拍现算 => 跑完按当时时段重算
- 🔴 语义变更(有意,代码注释已登记,待补设计修订):安静时段从「唯一可跑时段」
  变成「降频时段」—— 旧的「安静窗外一律 continue」被删,否则 intervalSec 永远
  是空壳(白天唤醒必被跳过);窗口仍决定档位与补跑判据
- 6 个变异(含「加回旧的那道门」=> 端到端用例红)
This commit is contained in:
AskaEth committed 2026-10-07 19:21:32 +08:00
1 parent 6deab2e836
commit d680390300
11 files changed
+832 -54

No files matched your search

@@ -0,0 +1,187 @@
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])
}
}