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