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

+213
View File
@@ -0,0 +1,213 @@
package memory
import (
"bytes"
"log/slog"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// 本文件锁定**整理频率**的解析链(主人 2026-10-07 两条口径):
//
// ①「`.env` 只做兜底用 …… 这些键优先从面板配置上取」⇒ 逐键 **面板 > `.env` > 代码默认**;
// ②「频率必须调 …… 或者动态分配」⇒ 周期分两档(安静/非安静,见 tidy_config.go 的
// intervalAt),quietIntervalSec 缺省 = intervalSec。
//
// 解析点只有一处:ApplyTidyScheduleTuning。用例走的是组合根的次序
// (TidyConfigFromConfig → ApplyTidyScheduleTuning → normalizeTidyConfig),
// 因此「环境变量缺位也缺位时落到代码默认」这一档也是真跑出来的,不是声称。
// resolveTidySchedule 按组合根的次序解析一次调度旋钮。
func resolveTidySchedule(env config.MemoryTidyConfig, panel runtimeconfig.MemoryTidyTuning, log *slog.Logger) TidyConfig {
return normalizeTidyConfig(ApplyTidyScheduleTuning(TidyConfigFromConfig(env), panel, log))
}
// captureTidyLog 收集一次解析期间的日志,供「坏值必须留痕」的断言使用。
func captureTidyLog(t *testing.T) (*slog.Logger, *bytes.Buffer) {
t.Helper()
var buf bytes.Buffer
return slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})), &buf
}
// intPtr 造一个「面板里显式写了这个键」的指针。
func intPtr(v int) *int { return &v }
// TestApplyTidyScheduleTuning_PriorityChain 是**优先级链**的三档用例:面板有值 ⇒
// 用面板;面板缺位 ⇒ 用 `.env`;两档都缺位 ⇒ 用代码默认。逐键独立。
func TestApplyTidyScheduleTuning_PriorityChain(t *testing.T) {
t.Parallel()
// env 层:与面板值**故意对立**,这样「谁生效」是能一眼看出来的。
env := config.MemoryTidyConfig{IntervalSec: 7200, QuietStartHour: 23, QuietEndHour: 7}
panel := runtimeconfig.MemoryTidyTuning{
IntervalSec: intPtr(3600),
QuietIntervalSec: intPtr(10800),
QuietStartHour: intPtr(1),
QuietEndHour: intPtr(5),
}
t.Run("面板有值 ⇒ 四个键都用面板值", func(t *testing.T) {
log, buf := captureTidyLog(t)
got := resolveTidySchedule(env, panel, log)
if got.IntervalSec != 3600 || got.Interval != time.Hour {
t.Fatalf("intervalSec = %d / %s, want 3600 / 1h", got.IntervalSec, got.Interval)
}
if got.QuietIntervalSec != 10800 || got.QuietInterval != 3*time.Hour {
t.Fatalf("quietIntervalSec = %d / %s, want 10800 / 3h", got.QuietIntervalSec, got.QuietInterval)
}
if got.QuietStartHour != 1 || got.QuietEndHour != 5 {
t.Fatalf("安静时段 = %02d:00-%02d:00, want 01:00-05:00", got.QuietStartHour, got.QuietEndHour)
}
if buf.Len() != 0 {
t.Fatalf("正常配置不该有任何 WARN:%s", buf.String())
}
})
t.Run("面板缺位 ⇒ 用 .env", func(t *testing.T) {
log, buf := captureTidyLog(t)
got := resolveTidySchedule(env, runtimeconfig.MemoryTidyTuning{}, log)
if got.IntervalSec != 7200 || got.Interval != 2*time.Hour {
t.Fatalf("intervalSec = %d / %s, want 7200 / 2h(env 兜底)", got.IntervalSec, got.Interval)
}
if got.QuietStartHour != 23 || got.QuietEndHour != 7 {
t.Fatalf("安静时段 = %02d:00-%02d:00, want 23:00-07:00(env 兜底)",
got.QuietStartHour, got.QuietEndHour)
}
// quietIntervalSec 没有 env 键 ⇒ 缺省等于 intervalSec(不是「夜里不跑」)。
if got.QuietIntervalSec != 7200 || got.QuietInterval != 2*time.Hour {
t.Fatalf("quietIntervalSec = %d / %s, want = intervalSec(缺省 = 不因时段换档)",
got.QuietIntervalSec, got.QuietInterval)
}
if buf.Len() != 0 {
t.Fatalf("键没写不是错,不该有 WARN:%s", buf.String())
}
})
t.Run("两档都缺位 ⇒ 用代码默认", func(t *testing.T) {
log, buf := captureTidyLog(t)
got := resolveTidySchedule(config.MemoryTidyConfig{}, runtimeconfig.MemoryTidyTuning{}, log)
if got.IntervalSec != defaultTidyIntervalSec || got.Interval != time.Duration(defaultTidyIntervalSec)*time.Second {
t.Fatalf("intervalSec = %d / %s, want %d / 24h",
got.IntervalSec, got.Interval, defaultTidyIntervalSec)
}
if got.QuietIntervalSec != defaultTidyIntervalSec {
t.Fatalf("quietIntervalSec = %d, want = intervalSec 的代码默认", got.QuietIntervalSec)
}
if got.QuietStartHour != defaultTidyQuietStartHour || got.QuietEndHour != defaultTidyQuietEndHour {
t.Fatalf("安静时段 = %02d:00-%02d:00, want 代码默认 %02d:00-%02d:00",
got.QuietStartHour, got.QuietEndHour, defaultTidyQuietStartHour, defaultTidyQuietEndHour)
}
if buf.Len() != 0 {
t.Fatalf("没配不是错,不该有 WARN:%s", buf.String())
}
})
t.Run("面板只写一档 ⇒ 另一档仍走 env(逐键独立)", func(t *testing.T) {
log, _ := captureTidyLog(t)
got := resolveTidySchedule(env, runtimeconfig.MemoryTidyTuning{
QuietIntervalSec: intPtr(10800),
QuietEndHour: intPtr(5),
}, log)
if got.IntervalSec != 7200 {
t.Fatalf("intervalSec = %d, want 7200(面板没写这一项)", got.IntervalSec)
}
if got.QuietIntervalSec != 10800 {
t.Fatalf("quietIntervalSec = %d, want 10800(面板写了这一项)", got.QuietIntervalSec)
}
if got.QuietStartHour != 23 || got.QuietEndHour != 5 {
t.Fatalf("安静时段 = %02d:00-%02d:00, want 23:00-05:00(一端面板、一端 env)",
got.QuietStartHour, got.QuietEndHour)
}
})
t.Run("面板写了 intervalSec 但没写 quietIntervalSec ⇒ 缺省跟随**面板**值", func(t *testing.T) {
log, _ := captureTidyLog(t)
got := resolveTidySchedule(env, runtimeconfig.MemoryTidyTuning{IntervalSec: intPtr(3600)}, log)
if got.IntervalSec != 3600 || got.QuietIntervalSec != 3600 {
t.Fatalf("intervalSec = %d, quietIntervalSec = %d, want 两者都是 3600",
got.IntervalSec, got.QuietIntervalSec)
}
})
t.Run("钟点显式写 0 ⇒ 生效(0 是合法钟点)", func(t *testing.T) {
log, _ := captureTidyLog(t)
got := resolveTidySchedule(env, runtimeconfig.MemoryTidyTuning{QuietStartHour: intPtr(0)}, log)
if got.QuietStartHour != 0 || got.QuietEndHour != 7 {
t.Fatalf("安静时段 = %02d:00-%02d:00, want 00:00-07:00", got.QuietStartHour, got.QuietEndHour)
}
})
t.Run("两端相等且非 0 ⇒ 关闭窗口(语义未变)", func(t *testing.T) {
log, _ := captureTidyLog(t)
got := resolveTidySchedule(env, runtimeconfig.MemoryTidyTuning{
QuietStartHour: intPtr(5), QuietEndHour: intPtr(5),
}, log)
if !got.quietWindowDisabled() {
t.Fatalf("5/5 应当被判定为「窗口已关闭」,得到 %02d:00-%02d:00",
got.QuietStartHour, got.QuietEndHour)
}
})
}
// TestApplyTidyScheduleTuning_BadValuesFallBackWithWarn 钉住「坏配置不得导致启动
// 失败」:非正数的秒数是**坏值**(不是「没配」),一律回落下一档 + 记一条 WARN ——
// 它会被读成「一直跑」而不是「不跑」,静默回落会让主人以为配了 0 就等于关了。
func TestApplyTidyScheduleTuning_BadValuesFallBackWithWarn(t *testing.T) {
t.Parallel()
env := config.MemoryTidyConfig{IntervalSec: 7200, QuietStartHour: 23, QuietEndHour: 7}
cases := []struct {
name string
panel runtimeconfig.MemoryTidyTuning
key string
}{
{"intervalSec 写成 0", runtimeconfig.MemoryTidyTuning{IntervalSec: intPtr(0)}, "intervalSec"},
{"intervalSec 写成负数", runtimeconfig.MemoryTidyTuning{IntervalSec: intPtr(-60)}, "intervalSec"},
{"quietIntervalSec 写成 0", runtimeconfig.MemoryTidyTuning{QuietIntervalSec: intPtr(0)}, "quietIntervalSec"},
{"quietIntervalSec 写成负数", runtimeconfig.MemoryTidyTuning{QuietIntervalSec: intPtr(-1)}, "quietIntervalSec"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
log, buf := captureTidyLog(t)
got := resolveTidySchedule(env, tc.panel, log)
if got.IntervalSec != 7200 {
t.Fatalf("intervalSec = %d, want 回落 env 的 7200", got.IntervalSec)
}
if got.QuietIntervalSec != 7200 {
t.Fatalf("quietIntervalSec = %d, want 回落 7200", got.QuietIntervalSec)
}
logs := buf.String()
if !strings.Contains(logs, tc.key) || !strings.Contains(logs, "level=WARN") {
t.Fatalf("坏值必须留痕(含键名 %q 的 WARN),日志 = %q", tc.key, logs)
}
})
}
// 坏值**不得**让整理停摆:解析出的周期必须是正数,且归一化后一切照旧。
log, _ := captureTidyLog(t)
got := resolveTidySchedule(env, runtimeconfig.MemoryTidyTuning{IntervalSec: intPtr(0)}, log)
if got.Interval <= 0 || got.QuietInterval <= 0 {
t.Fatalf("坏值之后的周期必须仍为正:interval=%s quiet=%s", got.Interval, got.QuietInterval)
}
if got.IntervalSec != env.IntervalSec {
t.Fatalf("坏值应回落 env 值 %d,得到 %d", env.IntervalSec, got.IntervalSec)
}
}
// TestApplyTidyScheduleTuning_NilLoggerIsSafe:解析点可能被没有日志器的调用方
// 直接使用(单测、将来的面板校验),nil 日志器不得 panic。
func TestApplyTidyScheduleTuning_NilLoggerIsSafe(t *testing.T) {
t.Parallel()
got := ApplyTidyScheduleTuning(TidyConfig{}, runtimeconfig.MemoryTidyTuning{
IntervalSec: intPtr(0), QuietIntervalSec: intPtr(-1),
}, nil)
if got.IntervalSec != 0 {
t.Fatalf("坏值不该写进 cfg,得到 %d", got.IntervalSec)
}
}