Files
Cyrene_v2/internal/runtime/memory/tidy_frequency_test.go
T
AskaEth d680390300 feat(memory): 整理频率走面板配置(面板 > env > 默认)+ 分时段动态间隔
主人 2026-10-07:「.env 只做兜底…优先从面板配置上取」+「频率必须调…或者动态
分配,比如白天间隔短一点 晚上到了静默时期稍微长一点」。

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

188 lines
7.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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])
}
}