Files
Cyrene_v2/cmd/cyrene/wire_memory_tidy_seed_test.go
AskaEth 23b6e3fe1e
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): 启动时自动播种面板配置文件(缺失才写、绝不覆盖)
主人 2026-10-07:「后端自动创建独立的面板配置文件要做」。

- 启动路径(wireMemoryTidy → applyMemoryTidyTuning 之后)若
  $DATA_DIR/config/memory-tidy.json 不存在或全空白 => 写一份「当时生效值」
  (十键:既有六键 + 频率四键),此后面板接管
- 已存在(非空)=> 跳过,该分支没有任何写盘语句;损坏 => WARN + 按 .env 跑、
  一个字不写盘(不「修好它」)
- 幂等(第二次启动必落到跳过分支);原子写(tmp 0600 + rename)
- 落盘前完整性闸:缺键 / 非正周期 / 钟点越界 => WARN + 不落盘
- 值来自 TidyTuningSnapshot(先跑两个归一化再翻回正向键)=> 播种那一刻运行
  行为零变化,有强断言(归一化后配置逐字相等)
- 零新增依赖、零 env 键变更;5 个变异(含「让已存在走写盘」=> 红)
2026-10-07 20:38:37 +08:00

268 lines
12 KiB
Go
Raw Permalink 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 main
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// 本文件锁定**启动播种在组合根上的接线**(wire_memory_tidy.go 的 seedMemoryTidyPanel;
// 主人 2026-10-07:「`.env` 只做兜底用 —— 后端**自动创建**独立的面板配置文件**要做**」):
//
// ① 文件不存在 ⇒ 后端自己写一份「当时生效值」(`.env` 给了的用 `.env`,没给的用代码
// 默认),面板配置自此自足;
// ② 播种之后再走一遍解析,生效配置与没播种时**逐字相同**(播种那一刻行为零变化);
// ③ 幂等:再跑一次不改字节(文件已在 ⇒ 走「绝不覆盖」那条路)。
//
// 用例全部落在临时 DataDir(`t.TempDir()`):生产那份 data/config/memory-tidy.json 是
// 主人手工建的,用例不许依赖它存在与否,也不许碰它。
// panelPath 返回某个 DataDir 下面板配置的落点(与组合根同一套路径推导)。
func panelPath(dataDir string) string {
return runtimeconfig.DefaultMemoryTidyPath(dataDir)
}
// readPanel 读回面板文件原文(幂等断言要比字节,不能只看解出来的键)。
func readPanel(t *testing.T, path string) string {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("读回 %s: %v", path, err)
}
return string(raw)
}
// TestWireMemoryTidySeedsPanelOnBoot 是最接近主人场景的一条:直接调**组合根的真实
// 装配函数** `wireMemoryTidy`(它被 wireMemory → newApp 调用,即进程启动那条路),
// 断言这一次启动就**自己把面板配置文件建出来**了;再跑一次(= 第二次启动)字节不变。
//
// 为什么不用「跑一遍真二进制」来验收:那要再起一个 :8080 的同进程服务,与「后端常驻」
// 那条红线冲突(不许为验收停/重启正在跑的后端)。因此这里走的是**同一个函数**、同一套
// 路径推导,只把 DataDir 换成临时目录。
func TestWireMemoryTidySeedsPanelOnBoot(t *testing.T) {
t.Parallel()
ctx := context.Background()
conn, err := db.Open(ctx, ":memory:", tidyTestLog())
if err != nil {
t.Fatalf("open db: %v", err)
}
defer func() { _ = conn.Close() }()
if err := db.MigrateUp(ctx, conn, tidyTestLog()); err != nil {
t.Fatalf("migrate: %v", err)
}
store, err := memory.NewStore(ctx, conn, tidyTestLog())
if err != nil {
t.Fatalf("memory store: %v", err)
}
env := config.MemoryTidyConfig{Enabled: true, IntervalSec: 3600, QuietStartHour: 23, QuietEndHour: 7}
dataDir := t.TempDir()
a := &app{
cfg: &config.Config{DataDir: dataDir, MemoryTidy: env},
memStore: store,
log: tidyTestLog(),
}
if err := a.wireMemoryTidy(); err != nil {
t.Fatalf("wireMemoryTidy: %v", err)
}
path := panelPath(dataDir)
if _, err := os.Stat(path); err != nil {
t.Fatalf("启动装配没有自动创建 %s:%v(这正是主人要求的那条「后端自动创建要做」)", path, err)
}
got, err := runtimeconfig.LoadMemoryTidyTuning(path)
if err != nil {
t.Fatalf("回读播种结果: %v", err)
}
if got.IntervalSec == nil || *got.IntervalSec != 3600 ||
got.QuietIntervalSec == nil || *got.QuietIntervalSec != 3600 {
t.Fatalf("两档周期 = %v/%v, want 3600/3600(取 `.env`)", got.IntervalSec, got.QuietIntervalSec)
}
if got.QuietStartHour == nil || *got.QuietStartHour != 23 ||
got.QuietEndHour == nil || *got.QuietEndHour != 7 {
t.Fatalf("安静时段 = %v-%v, want 23-7", got.QuietStartHour, got.QuietEndHour)
}
// 第二次启动(同一个装配函数再跑一次):文件已在 ⇒ 一个字节都不动。
before := readPanel(t, path)
second := &app{
cfg: &config.Config{DataDir: dataDir, MemoryTidy: env},
memStore: store,
log: tidyTestLog(),
}
if err := second.wireMemoryTidy(); err != nil {
t.Fatalf("第二次 wireMemoryTidy: %v", err)
}
if after := readPanel(t, path); after != before {
t.Fatalf("第二次启动改动了面板文件:\n before=%s\n after =%s", before, after)
}
}
// TestSeedMemoryTidyPanel_WritesEffectiveValuesOnBoot 钉住「自动创建」这件事本身:
// 组合根启动时必须自己把面板文件建出来,内容 = 当时生效值(`.env` 档 + 代码默认)。
func TestSeedMemoryTidyPanel_WritesEffectiveValuesOnBoot(t *testing.T) {
t.Parallel()
// `.env` 层:与代码默认**故意不同**,这样「播的是生效值」与「播了一份硬编码默认值」
// 一眼可分。
env := config.MemoryTidyConfig{Enabled: true, IntervalSec: 10800, QuietStartHour: 22, QuietEndHour: 6}
dataDir := t.TempDir()
a := &app{cfg: &config.Config{DataDir: dataDir, MemoryTidy: env}, log: tidyTestLog()}
path := panelPath(dataDir)
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("前置:%s 不该存在", path)
}
a.seedMemoryTidyPanel(memory.TidyConfigFromConfig(env))
if _, err := os.Stat(path); err != nil {
t.Fatalf("启动播种没有把面板文件建出来:%v", err)
}
got, err := runtimeconfig.LoadMemoryTidyTuning(path)
if err != nil {
t.Fatalf("回读播种结果: %v", err)
}
// `.env` 档:三键如实落进快照(quietIntervalSec 没有 env 键 ⇒ 缺省 = intervalSec)。
if got.IntervalSec == nil || *got.IntervalSec != 10800 {
t.Fatalf("intervalSec = %v, want 10800(取 `.env`)", got.IntervalSec)
}
if got.QuietIntervalSec == nil || *got.QuietIntervalSec != 10800 {
t.Fatalf("quietIntervalSec = %v, want 10800(缺省 = intervalSec)", got.QuietIntervalSec)
}
if got.QuietStartHour == nil || *got.QuietStartHour != 22 ||
got.QuietEndHour == nil || *got.QuietEndHour != 6 {
t.Fatalf("安静时段 = %v-%v, want 22-6(取 `.env`)", got.QuietStartHour, got.QuietEndHour)
}
// 代码默认档:老六键必须齐(否则「面板能管住自动档/闸/T1」仍是空话)。
if got.AutoApply == nil || !*got.AutoApply || got.AutoApplyPerRound <= 0 {
t.Fatalf("自动档两键 = %v/%d, want 显式 true 且正数上限", got.AutoApply, got.AutoApplyPerRound)
}
if got.IngestGate == nil || !*got.IngestGate || got.T1Judge == nil || !*got.T1Judge {
t.Fatalf("闸/T1 开关 = %v/%v, want 显式 true", got.IngestGate, got.T1Judge)
}
if got.T1PerWindow <= 0 || got.T1WindowSec <= 0 {
t.Fatalf("T1 限速 = %d/%d, want 正数", got.T1PerWindow, got.T1WindowSec)
}
// 幂等 + 「绝不覆盖」:再跑一次,字节一个不动。
before := readPanel(t, path)
a.seedMemoryTidyPanel(memory.TidyConfigFromConfig(env))
if after := readPanel(t, path); after != before {
t.Fatalf("第二次播种改动了面板文件:\n before=%s\n after =%s", before, after)
}
}
// TestSeedMemoryTidyPanel_KeepsRuntimeBehaviorIdentical 钉住「播种那一刻行为零变化」:
// 播种前后各按组合根的次序解析一次,得到的生效配置必须逐字相同 —— 否则播种就成了
// 一次没人察觉的行为变更,而不是「把现状写进文件」。
func TestSeedMemoryTidyPanel_KeepsRuntimeBehaviorIdentical(t *testing.T) {
t.Parallel()
env := config.MemoryTidyConfig{Enabled: true, IntervalSec: 7200, QuietStartHour: 1, QuietEndHour: 5}
dataDir := t.TempDir()
a := &app{cfg: &config.Config{DataDir: dataDir, MemoryTidy: env}, log: tidyTestLog()}
// 比对的是**归一化之后**的生效配置:TidyConfig 是归一化前的中间形态,面板写不写
// quietIntervalSec / autoApplyPerRound 会让它在 0 与「等于生效值」之间变化,语义却
// 相同 —— 因此必须走 normalizeTidyConfig 这一层(memory.NewTidier 的既有入口)才能
// 说「行为逐字未变」。
ctx := context.Background()
conn, err := db.Open(ctx, ":memory:", tidyTestLog())
if err != nil {
t.Fatalf("open db: %v", err)
}
defer func() { _ = conn.Close() }()
if err := db.MigrateUp(ctx, conn, tidyTestLog()); err != nil {
t.Fatalf("migrate: %v", err)
}
store, err := memory.NewStore(ctx, conn, tidyTestLog())
if err != nil {
t.Fatalf("memory store: %v", err)
}
effective := func(t *testing.T, cfg memory.TidyConfig) memory.TidyConfig {
t.Helper()
tidier, err := memory.NewTidier(cfg, memory.TidyDeps{Store: store, Log: tidyTestLog()})
if err != nil {
t.Fatalf("NewTidier: %v", err)
}
return tidier.Config() // 已套默认值
}
// ① 播种前:面板文件不存在 ⇒ 只有 `.env` + 代码默认这一档。
before := memory.TidyConfigFromConfig(env)
a.applyMemoryTidyTuning(&before)
before = effective(t, before)
// ② 启动播种。
a.seedMemoryTidyPanel(memory.TidyConfigFromConfig(env))
// ③ 播种后:面板文件已在,同样的 env 再解析一次。
after := memory.TidyConfigFromConfig(env)
a.applyMemoryTidyTuning(&after)
after = effective(t, after)
if before != after {
t.Fatalf("播种改变了生效配置:\n before=%+v\n after =%+v", before, after)
}
// 两档周期(intervalAt 的真判据)也要如实等价:安静时段窗口内用 quietInterval。
if after.QuietInterval != after.Interval || after.Interval != time.Duration(env.IntervalSec)*time.Second {
t.Fatalf("两档周期 = %s/%s, want 都等于 `.env` 的 %s",
after.Interval, after.QuietInterval, time.Duration(env.IntervalSec)*time.Second)
}
}
// TestSeedMemoryTidyPanel_PanelTakesOverAfterSeeding 是主人口径的终点:
// 「后端自动创建独立的面板配置文件之后就走面板配置文件」—— 播种之后,改那个文件
// 必须真的改变生效值(否则「自动创建」只是造了个没人读的文件)。
func TestSeedMemoryTidyPanel_PanelTakesOverAfterSeeding(t *testing.T) {
t.Parallel()
env := config.MemoryTidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
dataDir := t.TempDir()
a := &app{cfg: &config.Config{DataDir: dataDir, MemoryTidy: env}, log: tidyTestLog()}
a.seedMemoryTidyPanel(memory.TidyConfigFromConfig(env))
path := panelPath(dataDir)
if err := os.WriteFile(path, []byte(`{"intervalSec": 3600}`), 0o600); err != nil {
t.Fatalf("改写面板文件: %v", err)
}
cfg := memory.TidyConfigFromConfig(env)
a.applyMemoryTidyTuning(&cfg)
if cfg.IntervalSec != 3600 {
t.Fatalf("intervalSec = %d, want 3600(面板优先,`.env` 只兜底)", cfg.IntervalSec)
}
// 面板没写的键仍由 `.env` 兜底(面板只管它写了的那几个键)。
if cfg.QuietStartHour != 23 || cfg.QuietEndHour != 7 {
t.Fatalf("安静时段 = %02d:00-%02d:00, want `.env` 的 23:00-07:00", cfg.QuietStartHour, cfg.QuietEndHour)
}
}
// TestSeedMemoryTidyPanel_LeavesExistingFileAlone 钉住组合根这一侧的「绝不覆盖」:
// 盘上已有一份主人手写的配置(哪怕缺键、哪怕带未知字段)⇒ 启动播种一个字节都不动。
func TestSeedMemoryTidyPanel_LeavesExistingFileAlone(t *testing.T) {
t.Parallel()
env := config.MemoryTidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
dataDir := t.TempDir()
dir := filepath.Join(dataDir, "config")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
body := "{\n \"autoApply\": false,\n \"手工写的未知键\": 1\n}\n"
path := panelPath(dataDir)
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("写手写的配置: %v", err)
}
a := &app{cfg: &config.Config{DataDir: dataDir, MemoryTidy: env}, log: tidyTestLog()}
a.seedMemoryTidyPanel(memory.TidyConfigFromConfig(env))
if got := readPanel(t, path); got != body {
t.Fatalf("已存在的面板文件被改了:\n want=%s\n got =%s", body, got)
}
}