package runtimeconfig import ( "bytes" "encoding/json" "errors" "log/slog" "os" "path/filepath" "reflect" "sort" "strings" "testing" ) // 本文件锁定**启动播种**(memorytidy_seed.go;主人 2026-10-07:「**`.env` 只做兜底 // 用** …… 后端**自动创建**独立的面板配置文件」)的四条纪律: // // ① 文件不存在 ⇒ 按调用方给的「当前生效值」播种(**十一键齐全**,不是只播老六键); // ② 文件已存在 ⇒ **一个字节都不动**(也不留 `.tmp`); // ③ 文件损坏 ⇒ **不写盘**(WARN + 按 `.env` 跑),绝不「修好它」; // ④ 幂等:连跑两次只在第一次写盘。 // // 用例全部落在**临时目录**(`t.TempDir()`):生产那份 `data/config/memory-tidy.json` // 是主人手工建的,播种逻辑只在文件缺失时触发,用例不许依赖它存在与否。 // completeSeedTuning 造一份「十一键齐全」的播种内容。值刻意都**不是**代码默认值 // (autoApply=false / 3600 / 10800 / tempTtlSec=259200),这样「播的是调用方给的 // 快照」与「播了一份硬编码的默认值」在断言里一眼可分。 // // 它必须给出**全部** `memoryTidySeedKeys`(含 tempTtlSec):该键带 `omitempty`, // nil 时会被整键省略 —— 那正是「没配 ⇒ 用代码默认」的正常形态,但本用例要验的是 // 「键齐全时逐一落盘」,因此这里显式给值(见 TestSeedMemoryTidy_SeedsMissingFile // 的键集合断言)。 func completeSeedTuning() MemoryTidyTuning { return MemoryTidyTuning{ AutoApply: boolSeedPtr(false), AutoApplyPerRound: 7, IngestGate: boolSeedPtr(true), T1Judge: boolSeedPtr(false), T1PerWindow: 240, T1WindowSec: 1800, IntervalSec: intSeedPtr(3600), QuietIntervalSec: intSeedPtr(10800), QuietStartHour: intSeedPtr(23), QuietEndHour: intSeedPtr(7), TempTtlSec: intSeedPtr(259200), } } // boolSeedPtr / intSeedPtr 造「快照里显式写了这个键」的指针。 func boolSeedPtr(v bool) *bool { return &v } func intSeedPtr(v int) *int { return &v } // seedPath 返回一个**尚不存在**的落点(含尚不存在的 `config/` 目录)。 func seedPath(t *testing.T) string { t.Helper() return filepath.Join(t.TempDir(), "config", memoryTidyFileName) } // decodeSeed 把盘上的文件解成键集合,供「十一键齐全」的断言(比结构体更贴近主人看到 // 的那份文件)。 func decodeSeed(t *testing.T, path string) map[string]json.RawMessage { t.Helper() var keys map[string]json.RawMessage if err := json.Unmarshal([]byte(readRaw(t, path)), &keys); err != nil { t.Fatalf("解 %s 的键集合: %v", path, err) } return keys } // assertNoTempLeft 断言播种没留下临时文件(原子写的中间态不该留在盘上)。 func assertNoTempLeft(t *testing.T, path string) { t.Helper() if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) { t.Fatalf("留下了临时文件 %s.tmp", path) } } // TestSeedMemoryTidy_SeedsMissingFile 是本任务的主用例:文件不存在 ⇒ 播种,且**内容 // 就是调用方给的快照**(十一键齐全、含四项频率键),目录也一并建出来。 func TestSeedMemoryTidy_SeedsMissingFile(t *testing.T) { t.Parallel() path := seedPath(t) // 连 config/ 目录都不存在 seed := completeSeedTuning() res, err := SeedMemoryTidy(path, seed, discardLog()) if err != nil { t.Fatalf("SeedMemoryTidy: %v", err) } if !res.Written || res.Existed || res.Warn != nil { t.Fatalf("结果 = %+v, want Written 且非 Existed 且无 Warn", res) } assertNoTempLeft(t, path) // 十一键齐全(含新增的四项频率键)——「别只播老六键」的断言就在这里。 keys := decodeSeed(t, path) for _, key := range memoryTidySeedKeys { if _, ok := keys[key]; !ok { t.Fatalf("播种文件缺键 %q:%s", key, readRaw(t, path)) } } if len(keys) != len(memoryTidySeedKeys) { t.Fatalf("播种文件的键数 = %d, want %d:%s", len(keys), len(memoryTidySeedKeys), readRaw(t, path)) } // 回读走的是**运行期同一条**读取路径(LoadMemoryTidyTuning),因此这里同时钉住 // 「写下去的东西读回来就是那份快照」。 got, err := LoadMemoryTidyTuning(path) if err != nil { t.Fatalf("LoadMemoryTidyTuning: %v", err) } if !reflect.DeepEqual(got, seed) { t.Fatalf("回读 = %+v, want %+v(播种内容必须逐键等于调用方给的生效值)", got, seed) } } // TestSeedMemoryTidy_NeverTouchesExistingFile 钉住第一条铁律:文件在 ⇒ 一个字节都不动。 // 覆盖四种盘上形态(面板值 / 空对象 / null / 手写带未知键),四种都必须原样保留 —— // 尤其 `null`:它解得出零值(语义上等于没配),但它是「盘上有一个文件」这个事实本身。 func TestSeedMemoryTidy_NeverTouchesExistingFile(t *testing.T) { t.Parallel() bodies := []string{ `{"autoApply": false}`, `{}`, `null`, "{\n \"intervalSec\": 42,\n \"unknownKey\": \"keep me\"\n}\n", } for _, body := range bodies { path := writeMemoryTidyFile(t, body) before := readRaw(t, path) res, err := SeedMemoryTidy(path, completeSeedTuning(), discardLog()) if err != nil { t.Fatalf("%q: SeedMemoryTidy: %v", body, err) } if res.Written || !res.Existed || res.Warn != nil { t.Fatalf("%q: 结果 = %+v, want 跳过(Existed 且非 Written 且无 Warn)", body, res) } if after := readRaw(t, path); after != before { t.Fatalf("%q: 已存在的文件被改了字节:\n before=%s\n after =%s", body, before, after) } assertNoTempLeft(t, path) } } // TestSeedMemoryTidy_BrokenFileIsNotRepaired 钉住第二条纪律:损坏 ⇒ WARN + 按 `.env` // 跑 + **一个字都不写盘**。损坏的文件可以人工救回,程序盖掉它就丢了那份人工意图。 func TestSeedMemoryTidy_BrokenFileIsNotRepaired(t *testing.T) { t.Parallel() bodies := []string{ `{"autoApply": "yes"}`, `{"intervalSec": "3600"}`, `not json at all`, `[1,2,3]`, } for _, body := range bodies { path := writeMemoryTidyFile(t, body) before := readRaw(t, path) res, err := SeedMemoryTidy(path, completeSeedTuning(), discardLog()) if err != nil { // 坏文件不是「该写却没写成」:它压根不该被写,因此不是 error。 t.Fatalf("%q: 坏文件不该让播种返回错误(它只是不写盘):%v", body, err) } if res.Written || !res.Existed { t.Fatalf("%q: 结果 = %+v, want 既不写也不当新文件", body, res) } if !errors.Is(res.Warn, ErrInvalidMemoryTidy) { t.Fatalf("%q: Warn = %v, want ErrInvalidMemoryTidy(分类错误必须与读取侧同一个)", body, res.Warn) } if after := readRaw(t, path); after != before { t.Fatalf("%q: 损坏的文件被覆盖/「修好」了:\n before=%s\n after =%s", body, before, after) } assertNoTempLeft(t, path) } } // TestSeedMemoryTidy_IsIdempotent 钉住幂等:连跑两次,只有第一次写盘,第二次连字节 // 都不改(这是「连续启动多次只写一次」的可验证版本)。 func TestSeedMemoryTidy_IsIdempotent(t *testing.T) { t.Parallel() path := seedPath(t) first, err := SeedMemoryTidy(path, completeSeedTuning(), discardLog()) if err != nil || !first.Written { t.Fatalf("第一次: Written=%v err=%v, want 落盘", first.Written, err) } afterFirst := readRaw(t, path) second, err := SeedMemoryTidy(path, completeSeedTuning(), discardLog()) if err != nil { t.Fatalf("第二次: %v", err) } if second.Written || !second.Existed { t.Fatalf("第二次 = %+v, want 跳过(文件已在)", second) } if afterSecond := readRaw(t, path); afterSecond != afterFirst { t.Fatalf("第二次改动了文件:\n first=%s\n second=%s", afterFirst, afterSecond) } } // TestSeedMemoryTidy_SeedsBlankFile 钉住「全空白」的处置:它与「缺失」同义是既有口径 // (LoadMemoryTidyTuning 把两者都读成「还没配过」),因此也播种 —— 那一刻的生效值 // 一个字都不变。 func TestSeedMemoryTidy_SeedsBlankFile(t *testing.T) { t.Parallel() path := writeMemoryTidyFile(t, " \n\t") res, err := SeedMemoryTidy(path, completeSeedTuning(), discardLog()) if err != nil { t.Fatalf("SeedMemoryTidy: %v", err) } if !res.Written || res.Existed { t.Fatalf("结果 = %+v, want 按「还没配过」播种", res) } if len(decodeSeed(t, path)) != len(memoryTidySeedKeys) { t.Fatalf("空白文件被播种后应当十一键齐全:%s", readRaw(t, path)) } } // TestSeedMemoryTidy_RefusesIncompleteSeed 钉住种子的完整性契约:残缺的快照 // (缺指针 / 非正周期 / 钟点越界)宁可 WARN + 不落盘,也不写出一份自己读回都要 // WARN、或满是 null 的文件。 func TestSeedMemoryTidy_RefusesIncompleteSeed(t *testing.T) { t.Parallel() cases := []struct { name string seed MemoryTidyTuning }{ {"缺 autoApply", func() MemoryTidyTuning { s := completeSeedTuning(); s.AutoApply = nil; return s }()}, {"缺 intervalSec", func() MemoryTidyTuning { s := completeSeedTuning(); s.IntervalSec = nil; return s }()}, {"缺 quietStartHour", func() MemoryTidyTuning { s := completeSeedTuning(); s.QuietStartHour = nil; return s }()}, {"intervalSec 为 0", func() MemoryTidyTuning { s := completeSeedTuning(); s.IntervalSec = intSeedPtr(0); return s }()}, {"quietIntervalSec 为负", func() MemoryTidyTuning { s := completeSeedTuning(); s.QuietIntervalSec = intSeedPtr(-1); return s }()}, {"autoApplyPerRound 为 0", func() MemoryTidyTuning { s := completeSeedTuning(); s.AutoApplyPerRound = 0; return s }()}, {"t1PerWindow 为 0", func() MemoryTidyTuning { s := completeSeedTuning(); s.T1PerWindow = 0; return s }()}, {"t1WindowSec 为负", func() MemoryTidyTuning { s := completeSeedTuning(); s.T1WindowSec = -60; return s }()}, {"quietStartHour 越界", func() MemoryTidyTuning { s := completeSeedTuning(); s.QuietStartHour = intSeedPtr(24); return s }()}, {"quietEndHour 越界", func() MemoryTidyTuning { s := completeSeedTuning(); s.QuietEndHour = intSeedPtr(-1); return s }()}, {"完全零值", MemoryTidyTuning{}}, } for _, tc := range cases { path := seedPath(t) if _, err := SeedMemoryTidy(path, tc.seed, discardLog()); err == nil { t.Fatalf("%s: err = nil, want 拒绝残缺种子", tc.name) } if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { t.Fatalf("%s: 残缺种子不得落盘:%s", tc.name, readRaw(t, path)) } assertNoTempLeft(t, path) } } // tidySeedLeakedValues 是「不许出现在日志里的配置值」的哨兵,**刻意带 `=` 前缀** // (断言的形态是「字段值」而不是裸数字)。 // // 为什么必须带 `=`:这条用例的日志里**合法地**带 `path=<临时目录>`,而 // `t.TempDir()` 的目录名带随机数十进制 —— 裸数字扫整行会**偶发假红**(实测命中过 // `…WithoutValues4091800659\001\config\…` 里的 `1800`,约个位数/10000 的窗口)。 // 带 `=` 的形态只可能来自 slog 的 `key=value` 字段;路径里的随机数前面是路径分隔 // 片段而不是 `=`,因此不受影响(值的**位置**语义也没变松)。 // // 为什么不用另一个备选「只扫 `keys=` 之后的内容」:那会把断言缩成「keys 列表里 // 没有值」,于是任何以**别的**字段名打出来的值(`interval_sec=4242`、将来新加的 // 字段)都会溜过去 —— 那等于把这条防护改成空壳,而不是修掉它的假红。 // // 259200 是 tempTtlSec(completeSeedTuning 里显式给的值),与其余三个同为「一眼可 // 辨、且与代码默认值不同」的哨兵。 func tidySeedLeakedValues() []string { return []string{"=4242", "=10800", "=1800", "=259200"} } // TestSeedMemoryTidy_LogsPathAndReasonWithoutValues 钉住「可观测」:写盘与跳过都有 // 日志(带路径与原因、带 source),且**不打任何配置值**(本仓配置日志的既有口径)。 func TestSeedMemoryTidy_LogsPathAndReasonWithoutValues(t *testing.T) { t.Parallel() // ① 播种:Info 日志带路径、原因、source 与键名,但没有任何值。 // 日志级别用默认的 Info(不是 Debug):主人要在**默认日志级别**下就能看到这件事 // 发生了 —— 可观测是这条口径的一部分,不是「把级别调高才看得见」。 var buf bytes.Buffer log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})) path := seedPath(t) seed := completeSeedTuning() seed.IntervalSec = intSeedPtr(4242) // 一眼可辨的值:它**不许**出现在日志里 if _, err := SeedMemoryTidy(path, seed, log); err != nil { t.Fatalf("SeedMemoryTidy: %v", err) } out := buf.String() if !strings.Contains(out, memoryTidyFileName) { t.Fatalf("播种日志没带路径:%s", out) } if !strings.Contains(out, memoryTidySeedReasonMissing) { t.Fatalf("播种日志没带原因:%s", out) } if !strings.Contains(out, "source=config/runtimeconfig") { t.Fatalf("播种日志没标来源(AGENTS §10 禁止无来源裸日志):%s", out) } if !strings.Contains(out, "已按当时生效值播种落盘") { t.Fatalf("播种日志没说明做了什么:%s", out) } for _, value := range tidySeedLeakedValues() { if strings.Contains(out, value) { t.Fatalf("日志里出现了配置值 %s(配置日志只许打键名):%s", strings.TrimPrefix(value, "="), out) } } // ② 再次调用(文件已在):跳过也要有日志(**默认 Info 级别**可见,含路径与原因), // 且同样不打值。 buf.Reset() if _, err := SeedMemoryTidy(path, seed, log); err != nil { t.Fatalf("第二次 SeedMemoryTidy: %v", err) } skipped := buf.String() if !strings.Contains(skipped, "跳过播种") || !strings.Contains(skipped, memoryTidyFileName) { t.Fatalf("跳过播种的日志缺路径或说明:%s", skipped) } if !strings.Contains(skipped, "绝不覆盖") { t.Fatalf("跳过播种的日志没说明理由(绝不覆盖):%s", skipped) } if !strings.Contains(skipped, memoryTidySeedReasonExists) { t.Fatalf("跳过播种的日志没带 reason 字段:%s", skipped) } for _, value := range tidySeedLeakedValues() { if strings.Contains(skipped, value) { t.Fatalf("跳过日志里出现了配置值 %s:%s", strings.TrimPrefix(value, "="), skipped) } } // ③ 损坏:WARN 日志带路径与分类错误(主人据此知道「这份配置没生效、也没被改」)。 buf.Reset() broken := writeMemoryTidyFile(t, `{"autoApply": "yes"}`) if _, err := SeedMemoryTidy(broken, seed, log); err != nil { t.Fatalf("坏文件的 SeedMemoryTidy: %v", err) } warned := buf.String() if !strings.Contains(warned, "level=WARN") || !strings.Contains(warned, "无法解析") { t.Fatalf("损坏文件必须留一条 WARN:%s", warned) } if !strings.Contains(warned, memoryTidyFileName) { t.Fatalf("WARN 没带路径:%s", warned) } } // TestMemoryTidySeedKeysMatchStruct 是**防漂移**断言:播种日志里列的键名与 // MemoryTidyTuning 的 json tag 必须一一对应。加了新键却忘了更新 memoryTidySeedKeys, // 用例就会红 —— 否则「播了哪些键」的日志会安静地漏报。 func TestMemoryTidySeedKeysMatchStruct(t *testing.T) { t.Parallel() typ := reflect.TypeOf(MemoryTidyTuning{}) var tags []string for i := 0; i < typ.NumField(); i++ { tag, ok := typ.Field(i).Tag.Lookup("json") if !ok || tag == "" { t.Fatalf("字段 %s 没有 json tag", typ.Field(i).Name) } tags = append(tags, strings.Split(tag, ",")[0]) } want := append([]string(nil), memoryTidySeedKeys...) sort.Strings(tags) sort.Strings(want) if !reflect.DeepEqual(tags, want) { t.Fatalf("结构体的键 %v 与 memoryTidySeedKeys %v 不一致(加了新键就要同步那张表)", tags, want) } }