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) } }