Files
Cyrene_v2/cmd/cyrene/wire_flow_graphs_test.go
T
AskaEth a6cb44b8a8
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(flow): 链路图本地落盘 + 多轮历史回放(design/19 §P3,契约 v0.73)
主人要求「链路流程应该有本地数据,不然每次重载之后页面就没数据了」:
- 落盘:迁移 0022_flow_graphs(session_id/turn_id/graph_json/时间戳 + 唯一键 upsert)+ internal/runtime/flowgraph(RecordRound/Graph/Latest/Turns/Turn + 保留期 GC)
- 写入:loop.EndRound **每轮一条**(锁外深拷贝后写;失败 WARN 且不打印图内容);只落发布会话白名单(复用 flowSessionAllowed)
- 读回:端点加 turnId + source(live/persisted/none) + turns[](最新在前,空为 []);**内存优先、miss 才落盘**(最新一轮永远最新)
- 前端:flowGraphs 纳入 persistOptions.partialize(**刷新即刻有图**,version 3→4 + migrate)+ 历史轮选择器 + 诚实文案;顺带收 L1 转交的 flow/NodeDetail.tsx 节点日志倒序
- 新 env 键:FLOW_GRAPH_RETENTION=168h / FLOW_GRAPH_GC_INTERVAL=1h
- 变异实测:每 span 一行 / 保留期守卫失效 / partialize 去掉 flowGraphs / 去掉内存优先 / 白名单失效 ⇒ 全部打红
- 3 个禁动面文件的纯追加改动(Flow.tsx 挂选择器、FlowSnapshotHint 纠已成假话的文案、其渲染断言随之更新)由主会话裁定认可
- 登记存疑:图桶持久化无体积上限;useFlowTurns 的 effect 无自动化证据(项目无 jsdom)
2026-10-05 16:19:32 +08:00

291 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 main
// 本文件是**链路图落盘组合根接线**的验收(切片 F2;design/19 §4 P3)。
//
// 两道并行的取证(与 `wire_channel_events_test.go` 同一手法):
//
// 1. **接线存在性**:组合根本身无法在单元测试里实例化(开库、迁移、绑端口,而后端还在
// 运行),因此「存储真的被构造」「真的交给发布器作落盘出口」「真的注入网关作查询
// 出口」「常驻循环真的被拉起」只能在源码层钉住 —— 少任何一处,落盘会**静默不工作**
// (图照旧只在内存、日志里一句都没有),而这不红任何既有用例。最后一段还核对
// `.env.example` 的两个键(AGENTS §10 的 E6 条目)。
// 2. **行为**:两个 env 键的默认值必须来自 `flowgraph` 导出的常量(**不另写一份字面量**)、
// 显式 ≤0 = 关闭清扫、只写在 `.env` 里也真实生效、非法取值回落默认 + WARN;以及
// GC 关闭时不建零间隔 ticker(`time.NewTicker(0)` 会 panic)。
import (
"bytes"
"context"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/flowgraph"
)
// TestWireFlowGraphs_IsWired 钉住组合根的接缝 + `.env.example` 同步。
func TestWireFlowGraphs_IsWired(t *testing.T) {
t.Parallel()
seams := map[string][]string{
"wire_flow_graphs.go": {
// 存储真的被构造,且默认值取 flowgraph 导出的常量(不另写字面量)。
"flowgraph.NewStore(flowgraph.Config{",
"durationEnv(a.log, envFilePath, flowGraphGCIntervalEnv, flowgraph.DefaultGCInterval)",
"durationEnv(a.log, envFilePath, flowGraphRetentionEnv, flowgraph.DefaultRetention)",
// 未接线时交出**真 nil 接口**(装 nil 指针的接口不等于 nil,会让发布器/网关的
// 未接线判据失效)。
"func (a *app) flowRoundRecorder() loop.RoundRecorder {",
"func (a *app) flowGraphStore() gateway.FlowGraphStore {",
// GC 关闭时不许建零间隔 ticker(time.NewTicker(0) 会 panic)。
"if !k.store.Enabled() {",
},
"wire_runtime.go": {
// 先建存储,再把它交给发布器作每轮落盘出口。
"a.wireFlowGraphs()",
"loop.WithRoundRecorder(a.flowRoundRecorder())",
},
"wire_frontend.go": {
// 网关只读出口注入**同一个**存储句柄。
"FlowGraphs: a.flowGraphStore(),",
},
"main.go": {
// 常驻循环:没有这一行,落盘的轮次图从不被清理(保留期形同虚设、无限增长)。
"a.startFlowGraphs(ctx)",
},
}
for file, wants := range seams {
src, err := os.ReadFile(file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
}
for _, want := range wants {
if !strings.Contains(string(src), want) {
t.Fatalf("%s 里找不到 %q —— 链路图落盘没有被接上组合根", file, want)
}
}
}
// 两个新键必须与代码里的键名**逐字一致**(AGENTS §10 的 E6 条目)。
src, err := os.ReadFile("wire_flow_graphs.go")
if err != nil {
t.Fatalf("read wire_flow_graphs.go: %v", err)
}
raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example"))
if err != nil {
t.Fatalf("read .env.example: %v", err)
}
for _, key := range []string{flowGraphRetentionEnv, flowGraphGCIntervalEnv} {
if !strings.Contains(string(src), `"`+key+`"`) {
t.Fatalf("wire_flow_graphs.go 里没有键名常量 %q", key)
}
if !strings.Contains(string(raw), key+"=") {
t.Fatalf(".env.example 里找不到 %q(新 env 键必须同步模板,AGENTS §10)", key+"=")
}
}
}
// flowGraphTestApp 构造一个只带落盘面所需依赖的 app(库 + 静默 logger)。
func flowGraphTestApp(t *testing.T, log *slog.Logger) *app {
t.Helper()
if log == nil {
log = testLog()
}
return &app{db: openMigratedTestDB(t, filepath.Join(t.TempDir(), "cyrene.db")), log: log}
}
// unsetFlowGraphEnv 把两个键从**进程环境**里拿掉并在用例结束后复原。
// ⚠️ 不能用 `t.Setenv(key, "")` 代替:空值在优先级链里仍然「存在」,会盖住 `.env`
// 里的同名声明,而「只写在 .env 里也生效」正是要验的东西(与渠道事件同一取舍)。
func unsetFlowGraphEnv(t *testing.T) {
t.Helper()
for _, key := range []string{flowGraphRetentionEnv, flowGraphGCIntervalEnv} {
key := key
prev, had := os.LookupEnv(key)
if had {
t.Cleanup(func() { _ = os.Setenv(key, prev) })
} else {
t.Cleanup(func() { _ = os.Unsetenv(key) })
}
if err := os.Unsetenv(key); err != nil {
t.Fatalf("unset %s: %v", key, err)
}
}
}
// TestNewFlowGraphStore_默认值取导出常量_正常:未配置时两个旋钮必须**就是** flowgraph
// 导出的默认值(168h / 1h),且清扫开启 —— 另写字面量迟早与文档承诺漂移。
func TestNewFlowGraphStore_默认值取导出常量_正常(t *testing.T) {
unsetFlowGraphEnv(t)
store := flowGraphTestApp(t, nil).newFlowGraphStore("")
if store == nil {
t.Fatal("有库时必须构造出链路图存储")
}
if store.Retention() != flowgraph.DefaultRetention {
t.Fatalf("Retention = %v, want %v(导出的默认常量)", store.Retention(), flowgraph.DefaultRetention)
}
if store.Interval() != flowgraph.DefaultGCInterval {
t.Fatalf("GC 间隔 = %v, want %v", store.Interval(), flowgraph.DefaultGCInterval)
}
if !store.Enabled() {
t.Fatal("未配置时清扫必须开启(漏配 != 关闭)")
}
}
// TestNewFlowGraphStore_显式零关闭清扫_边界:`≤0 = 关闭`—— 0 刻意不解释成「立即清空」,
// 那会让一次笔误抹掉刚落盘的历史轮。
func TestNewFlowGraphStore_显式零关闭清扫_边界(t *testing.T) {
cases := []struct {
name string
key string
value string
}{
{"保留期为 0", flowGraphRetentionEnv, "0"},
{"保留期为负", flowGraphRetentionEnv, "-1h"},
{"清扫间隔为 0", flowGraphGCIntervalEnv, "0"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
unsetFlowGraphEnv(t)
t.Setenv(tc.key, tc.value)
store := flowGraphTestApp(t, nil).newFlowGraphStore("")
if store == nil {
t.Fatal("配置非法不该让存储整个消失")
}
if store.Enabled() {
t.Fatalf("%s=%s 时清扫仍开启,want 关闭(显式 ≤0 = 关闭)", tc.key, tc.value)
}
})
}
}
// TestNewFlowGraphStore_只写在env文件里也生效_正常:`.env` 不写回进程环境,直接
// os.Getenv 会**静默忽略**它。本用例把两个键只写进临时 `.env`,取值必须原样生效。
func TestNewFlowGraphStore_只写在env文件里也生效_正常(t *testing.T) {
unsetFlowGraphEnv(t)
path := filepath.Join(t.TempDir(), ".env")
body := strings.Join([]string{
flowGraphRetentionEnv + "=24h",
flowGraphGCIntervalEnv + "=30m",
}, "\n") + "\n"
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("写临时 .env: %v", err)
}
store := flowGraphTestApp(t, nil).newFlowGraphStore(path)
if store == nil {
t.Fatal("有库时必须构造出链路图存储")
}
if store.Retention() != 24*time.Hour || store.Interval() != 30*time.Minute {
t.Fatalf("取值 = (%v, %v), want (24h, 30m)", store.Retention(), store.Interval())
}
}
// TestNewFlowGraphStore_非法取值回落默认并告警_异常:配了但读不懂 ⇒ 回落默认值**且**记
// 一条带键名的 WARN(非法配置不许静默失效,也不许一票否决整条落盘面)。
func TestNewFlowGraphStore_非法取值回落默认并告警_异常(t *testing.T) {
unsetFlowGraphEnv(t)
path := filepath.Join(t.TempDir(), ".env")
if err := os.WriteFile(path, []byte(flowGraphRetentionEnv+"=七天\n"), 0o600); err != nil {
t.Fatalf("写临时 .env: %v", err)
}
var buf bytes.Buffer
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
store := flowGraphTestApp(t, log).newFlowGraphStore(path)
if store == nil {
t.Fatal("单个键非法不该让存储整个消失")
}
if store.Retention() != flowgraph.DefaultRetention {
t.Fatalf("非法取值时的保留期 = %v, want %v", store.Retention(), flowgraph.DefaultRetention)
}
logged := buf.String()
if !strings.Contains(logged, "level=WARN") || !strings.Contains(logged, flowGraphRetentionEnv) {
t.Fatalf("非法取值没有留下带键名的 WARN:%q", logged)
}
}
// TestFlowGraphStore_未接线时交出真nil接口_边界:未接数据库时两个出口都必须返回**真
// nil 接口** —— 返回「装着 nil 指针的接口」会让发布器/网关的 nil 判据失效。
func TestFlowGraphStore_未接线时交出真nil接口_边界(t *testing.T) {
blank := &app{log: testLog()}
if store := blank.newFlowGraphStore(""); store != nil {
t.Fatalf("库未接时不该构造存储:%+v", store)
}
if rec := blank.flowRoundRecorder(); rec != nil {
t.Fatalf("未接线时落盘出口必须是真 nil 接口,拿到的是 %#v", rec)
}
if got := blank.flowGraphStore(); got != nil {
t.Fatalf("未接线时网关只读出口必须是真 nil 接口,拿到的是 %#v", got)
}
}
// TestFlowGraphKeeper_GC关闭时不建零间隔ticker_边界:`time.NewTicker(0)` 会 panic,所以
// 「配了 ≤0 = 关闭清扫」必须在建 ticker 之前分岔,且 loop 立即返回(不留空转 goroutine)。
func TestFlowGraphKeeper_GC关闭时不建零间隔ticker_边界(t *testing.T) {
unsetFlowGraphEnv(t)
t.Setenv(flowGraphGCIntervalEnv, "0")
store := flowGraphTestApp(t, nil).newFlowGraphStore("")
if store == nil || store.Enabled() {
t.Fatalf("GC 应关闭:store=%v enabled=%v", store, store != nil && store.Enabled())
}
keeper := &flowGraphKeeper{store: store, log: testLog(), done: make(chan struct{})}
ticks, stop := keeper.gcTicker()
if ticks != nil {
t.Fatal("GC 关闭时不该建 ticker(零间隔 ticker 会 panic)")
}
stop()
// loop 在 GC 关闭时必须立刻返回并关闭 done(否则常驻 goroutine 泄漏)。
keeper.loop(context.Background())
select {
case <-keeper.done:
default:
t.Fatal("GC 关闭时 loop 应立即返回并关闭 done")
}
}
// TestFlowGraphKeeper_开启时给出定时通道_正常:默认配置下 GC 开启,必须给出非 nil 通道。
func TestFlowGraphKeeper_开启时给出定时通道_正常(t *testing.T) {
unsetFlowGraphEnv(t)
store := flowGraphTestApp(t, nil).newFlowGraphStore("")
if store == nil || !store.Enabled() {
t.Fatal("默认配置下 GC 应开启")
}
ticks, stop := (&flowGraphKeeper{store: store, log: testLog()}).gcTicker()
if ticks == nil {
t.Fatal("GC 开启时必须给出定时通道")
}
stop()
}
// TestFlowGraphEnv_模板取值是合法duration_正常 是 E6 自查的机械化版本:两个键名必须能
// 在 `.env.example` 里逐字找到,且模板值是合法 duration(占位符形态,从不放凭据)。
func TestFlowGraphEnv_模板取值是合法duration_正常(t *testing.T) {
t.Parallel()
raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example"))
if err != nil {
t.Fatalf("read .env.example: %v", err)
}
lines := strings.Split(string(raw), "\n")
for _, key := range []string{flowGraphRetentionEnv, flowGraphGCIntervalEnv} {
found := false
for _, line := range lines {
value, ok := strings.CutPrefix(line, key+"=")
if !ok {
continue
}
found = true
if _, err := time.ParseDuration(value); err != nil {
t.Fatalf("%s 的模板值 %q 不是合法 duration", key, value)
}
}
if !found {
t.Fatalf(".env.example 缺少 %s(AGENTS §10:键名必须与代码逐字一致)", key)
}
}
}