主人 2026-10-07:「.env 只做兜底…优先从面板配置上取」+「频率必须调…或者动态
分配,比如白天间隔短一点 晚上到了静默时期稍微长一点」。
- memory-tidy.json 新增四键:intervalSec / quietIntervalSec / quietStartHour /
quietEndHour(*int + 指针判定 => 显式 0 是合法值、缺位才回落)
- 优先级逐键三档:面板 > env(既有键,未增删)> 代码默认;坏值不覆盖 + WARN,
且不启动失败
- 动态间隔由纯函数 intervalAt(at) 决定,调度侧每一拍现算 => 跑完按当时时段重算
- 🔴 语义变更(有意,代码注释已登记,待补设计修订):安静时段从「唯一可跑时段」
变成「降频时段」—— 旧的「安静窗外一律 continue」被删,否则 intervalSec 永远
是空壳(白天唤醒必被跳过);窗口仍决定档位与补跑判据
- 6 个变异(含「加回旧的那道门」=> 端到端用例红)
214 lines
9.2 KiB
Go
214 lines
9.2 KiB
Go
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)
|
||
}
|
||
}
|