Files
Cyrene_v2/internal/config/runtimeconfig/memorytidy_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

281 lines
11 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 runtimeconfig
import (
"errors"
"os"
"path/filepath"
"testing"
)
// 本文件锁定 `$DATA_DIR/config/memory-tidy.json`(memorytidy.go)的读取口径:
//
// ① 文件缺失 / 空白 ⇒ 零值 + nil(「还没配过」不是错误);
// ② `autoApply` **没写** ⇒ 不覆盖(AutoApplyOff=false)—— 这是指针存在的理由:
// 「没配」与「显式配成 false」必须可区分,否则只写上限的文件会静默关掉自动档;
// ③ `autoApply: false` ⇒ AutoApplyOff=true(一键回到只产建议);
// ④ 文件存在但解不开 ⇒ 分类错误(调用方据此记 WARN 并回落代码默认值);
// ⑤ 落点常量与 backthink 那一族同一目录、不同文件名(不共用常量,避免静默搬家)。
func TestLoadMemoryTidyTuning_MissingIsNotAnError(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "config", "memory-tidy.json")
got, err := LoadMemoryTidyTuning(path)
if err != nil {
t.Fatalf("缺失文件的 err = %v, want nil", err)
}
if got.AutoApplyOff() || got.IngestGateOff() || got.AutoApplyPerRound != 0 {
t.Fatalf("缺失文件应当给零值:%+v", got)
}
}
func TestLoadMemoryTidyTuning_BlankIsNotAnError(t *testing.T) {
t.Parallel()
path := writeMemoryTidyFile(t, " \n\t")
got, err := LoadMemoryTidyTuning(path)
if err != nil {
t.Fatalf("空白文件的 err = %v, want nil", err)
}
if got.AutoApplyOff() {
t.Fatal("空白文件不该被读成「关掉自动档」")
}
}
func TestLoadMemoryTidyTuning_AbsentAutoApplyKeepsDefault(t *testing.T) {
t.Parallel()
path := writeMemoryTidyFile(t, `{"autoApplyPerRound": 7}`)
got, err := LoadMemoryTidyTuning(path)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning: %v", err)
}
if got.AutoApply != nil {
t.Fatalf("没写 autoApply ⇒ 指针应为 nil,实际 %v", *got.AutoApply)
}
if got.AutoApplyOff() {
t.Fatal("只写了上限的文件不得把自动档关掉(代码默认是开)")
}
if got.AutoApplyPerRound != 7 {
t.Fatalf("autoApplyPerRound = %d, want 7", got.AutoApplyPerRound)
}
}
func TestLoadMemoryTidyTuning_ExplicitFalseTurnsAutoApplyOff(t *testing.T) {
t.Parallel()
path := writeMemoryTidyFile(t, `{"autoApply": false}`)
got, err := LoadMemoryTidyTuning(path)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning: %v", err)
}
if !got.AutoApplyOff() {
t.Fatal("显式 false 必须被读成「关掉自动档」")
}
on := writeMemoryTidyFile(t, `{"autoApply": true, "autoApplyPerRound": 3}`)
got, err = LoadMemoryTidyTuning(on)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(on): %v", err)
}
if got.AutoApplyOff() || got.AutoApplyPerRound != 3 {
t.Fatalf("显式 true 的回读 = %+v", got)
}
}
func TestLoadMemoryTidyTuning_BrokenIsClassifiedError(t *testing.T) {
t.Parallel()
path := writeMemoryTidyFile(t, `{"autoApply": "yes"}`)
if _, err := LoadMemoryTidyTuning(path); !errors.Is(err, ErrInvalidMemoryTidy) {
t.Fatalf("坏配置的 err = %v, want ErrInvalidMemoryTidy", err)
}
}
// TestLoadMemoryTidyTuning_IngestGate 钉住**同步入库闸**的开关口径
// (2026-10-07 主人裁定):它与自动档同一个文件、同一个「指针 = 没配」语义,
// 且**零新 env 键**(`.env` 已冻结,design/13 §2.1)。
func TestLoadMemoryTidyTuning_IngestGate(t *testing.T) {
t.Parallel()
// ① 没写 ingestGate ⇒ 不覆盖(代码默认是「开」)。
absent := writeMemoryTidyFile(t, `{"autoApplyPerRound": 3}`)
got, err := LoadMemoryTidyTuning(absent)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning: %v", err)
}
if got.IngestGate != nil || got.IngestGateOff() {
t.Fatalf("没写 ingestGate 不得把闸关掉:%+v", got)
}
// ② 显式 false ⇒ 一键回到「只插不审」。
off := writeMemoryTidyFile(t, `{"ingestGate": false}`)
got, err = LoadMemoryTidyTuning(off)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(off): %v", err)
}
if !got.IngestGateOff() {
t.Fatal("显式 false 必须被读成「关掉入库闸」")
}
// 闸的开关与自动档的开关互不影响:一个键不能顺手改另一件事。
if got.AutoApplyOff() {
t.Fatal("写 ingestGate 不得顺手把自动档也关掉")
}
// ③ 显式 true ⇒ 与默认同义(写它只是为了自解释)。
on := writeMemoryTidyFile(t, `{"ingestGate": true, "autoApply": false}`)
got, err = LoadMemoryTidyTuning(on)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(on): %v", err)
}
if got.IngestGateOff() || !got.AutoApplyOff() {
t.Fatalf("两个开关必须各自独立:%+v", got)
}
// ④ 类型错 ⇒ 分类错误(调用方据此记 WARN 并回落代码默认值,绝不静默)。
broken := writeMemoryTidyFile(t, `{"ingestGate": "nope"}`)
if _, err := LoadMemoryTidyTuning(broken); !errors.Is(err, ErrInvalidMemoryTidy) {
t.Fatalf("坏 ingestGate 的 err = %v, want ErrInvalidMemoryTidy", err)
}
}
// TestLoadMemoryTidyTuning_T1RateLimit 钉住 **T1 全局限速**的两个数值键
// (design/23 §15.4,主人 2026-10-07 裁定):它们与 `t1Judge` 同一个文件、
// **零新 env 键**,且**零值只能是「没配」**——绝不能是「无上限」。
func TestLoadMemoryTidyTuning_T1RateLimit(t *testing.T) {
t.Parallel()
// ① 没写 ⇒ 保持零值(调用方据此回落代码默认额,不是「不限流」)。
absent := writeMemoryTidyFile(t, `{"t1Judge": true}`)
got, err := LoadMemoryTidyTuning(absent)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning: %v", err)
}
if got.T1PerWindow != 0 || got.T1WindowSec != 0 {
t.Fatalf("没写限速键应当给零值:%+v", got)
}
// ② 写了 ⇒ 如实回读,且不连带改动别的键(一个键不能顺手关掉另一件事)。
set := writeMemoryTidyFile(t, `{"t1PerWindow": 240, "t1WindowSec": 1800}`)
got, err = LoadMemoryTidyTuning(set)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(set): %v", err)
}
if got.T1PerWindow != 240 || got.T1WindowSec != 1800 {
t.Fatalf("限速键回读 = %d/%d, want 240/1800", got.T1PerWindow, got.T1WindowSec)
}
if got.T1JudgeOff() || got.IngestGateOff() || got.AutoApplyOff() {
t.Fatalf("限速键顺手改了别的开关:%+v", got)
}
// ③ 显式 0 ⇒ 仍是零值(「没法用 0 关掉限流」是刻意的:那会是一处静默的失效面)。
zero := writeMemoryTidyFile(t, `{"t1PerWindow": 0, "t1WindowSec": 0}`)
got, err = LoadMemoryTidyTuning(zero)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(zero): %v", err)
}
if got.T1PerWindow != 0 || got.T1WindowSec != 0 {
t.Fatalf("显式 0 的回读 = %+v", got)
}
// ④ 类型错 ⇒ 分类错误(调用方据此记 WARN 并回落代码默认额,绝不静默不限流)。
for _, body := range []string{`{"t1PerWindow": "many"}`, `{"t1WindowSec": "1h"}`} {
broken := writeMemoryTidyFile(t, body)
if _, err := LoadMemoryTidyTuning(broken); !errors.Is(err, ErrInvalidMemoryTidy) {
t.Fatalf("坏配置 %s 的 err = %v, want ErrInvalidMemoryTidy", body, err)
}
}
}
func TestDefaultMemoryTidyPathIsDataDirConfig(t *testing.T) {
t.Parallel()
got := DefaultMemoryTidyPath(filepath.Join("root", "data"))
want := filepath.Join("root", "data", "config", "memory-tidy.json")
if got != want {
t.Fatalf("落点 = %q, want %q", got, want)
}
if MemoryTidyConfigRelPath != "config/memory-tidy.json" {
t.Fatalf("相对路径 = %q, want config/memory-tidy.json", MemoryTidyConfigRelPath)
}
// 与 backthink 那一族同目录、不同文件:共用常量会在某一族搬家时静默带走另一族。
if filepath.Dir(MemoryTidyConfigRelPath) != filepath.Dir(BackthinkConfigRelPath) {
t.Fatalf("两族应当同目录:%q vs %q", MemoryTidyConfigRelPath, BackthinkConfigRelPath)
}
}
// writeMemoryTidyFile 在临时目录里写一份 memory-tidy.json 并返回路径。
func writeMemoryTidyFile(t *testing.T, body string) string {
t.Helper()
dir := filepath.Join(t.TempDir(), "config")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
path := filepath.Join(dir, memoryTidyFileName)
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
return path
}
// TestLoadMemoryTidyTuning_ScheduleKeys 钉住 **整理频率**四项
// (intervalSec / quietIntervalSec / quietStartHour / quietEndHour)的读取口径。
//
// 为什么钟点必须能区分「显式 0」与「没写」:0 是**合法钟点**(0-7 的凌晨窗口、
// 23-0 到午夜的窗口),用 int 承载会让只写一端的文件静默改掉另一端。因此这里
// 断言的是「指针是否非 nil」,而不是「值是否非 0」。
//
// 值的**可用性**(非正数 ⇒ 回落)不在这里判:那是 memory 侧
// ApplyTidyScheduleTuning 的职责,本包只管文件形状与读取。
func TestLoadMemoryTidyTuning_ScheduleKeys(t *testing.T) {
t.Parallel()
// ① 文件不存在 ⇒ 四个键都是 nil(= 不覆盖,由 `.env` / 代码默认兜底)。
absent, err := LoadMemoryTidyTuning(filepath.Join(t.TempDir(), memoryTidyFileName))
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(absent): %v", err)
}
if absent.IntervalSec != nil || absent.QuietIntervalSec != nil ||
absent.QuietStartHour != nil || absent.QuietEndHour != nil {
t.Fatalf("没配过时四个调度键都该是 nil,得到 %+v", absent)
}
// ② 四个键都写了 ⇒ 逐键读出来(与 env 无关,本层不比对)。
got, err := LoadMemoryTidyTuning(writeMemoryTidyFile(t,
`{"intervalSec": 3600, "quietIntervalSec": 10800, "quietStartHour": 1, "quietEndHour": 5}`))
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(set): %v", err)
}
if got.IntervalSec == nil || *got.IntervalSec != 3600 {
t.Fatalf("intervalSec = %v, want 3600", got.IntervalSec)
}
if got.QuietIntervalSec == nil || *got.QuietIntervalSec != 10800 {
t.Fatalf("quietIntervalSec = %v, want 10800", got.QuietIntervalSec)
}
if got.QuietStartHour == nil || *got.QuietStartHour != 1 ||
got.QuietEndHour == nil || *got.QuietEndHour != 5 {
t.Fatalf("安静时段 = %v-%v, want 01:00-05:00", got.QuietStartHour, got.QuietEndHour)
}
// ③ 钟点显式写 0 ⇒ **是值**(不是「没配」):0-7 的凌晨窗口是合法配置。
zero, err := LoadMemoryTidyTuning(writeMemoryTidyFile(t, `{"quietStartHour": 0, "quietEndHour": 7}`))
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(zero): %v", err)
}
if zero.QuietStartHour == nil || *zero.QuietStartHour != 0 {
t.Fatalf("显式写 0 的钟点必须原样保留,得到 %v", zero.QuietStartHour)
}
if zero.QuietEndHour == nil || *zero.QuietEndHour != 7 {
t.Fatalf("quietEndHour = %v, want 7", zero.QuietEndHour)
}
// ④ 秒数写 0 ⇒ 仍是「有值」(0 由 memory 侧判为坏值并回落 + WARN,本层不拦)。
explicitZero, err := LoadMemoryTidyTuning(writeMemoryTidyFile(t, `{"intervalSec": 0}`))
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(explicit zero): %v", err)
}
if explicitZero.IntervalSec == nil || *explicitZero.IntervalSec != 0 {
t.Fatalf("显式写 0 的 intervalSec 应当被读出来(好坏由上层判),得到 %v", explicitZero.IntervalSec)
}
// ⑤ 类型写错(字符串)⇒ 整份文件解不开,分类错误 + 不启动失败。
// 这是**既有粒度**:一个键类型写错会让全部键一起回落 `.env`。
if _, err := LoadMemoryTidyTuning(writeMemoryTidyFile(t, `{"intervalSec": "3600"}`)); !errors.Is(err, ErrInvalidMemoryTidy) {
t.Fatalf("intervalSec 写成字符串应当返回 ErrInvalidMemoryTidy,得到 %v", err)
}
}