package flowgraph // 本文件是**链路图落盘存储**的验收(design/19 §4 P3;切片 F2)。断言的是**库里的事实** // (行在不在、读回来的图是不是原图、每轮一行),不按日志文本断言 —— 与 // `internal/runtime/events` 的测试同一口径。 // // 核心一条(主人原话的内核):「后端重启后仍能查回历史轮次的图」—— // `TestStore_SurvivesRestart_正常` 用**同一个文件库、两条连接、两个 store** 模拟 // 「进程重启」,第二个 store 内存里没有任何图,只能靠落盘读回。 import ( "context" "database/sql" "errors" "path/filepath" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/db" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) // testNow 是夹具的固定时钟(可复现:所有时间断言都基于它)。 func testNow() time.Time { return time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) } // newTestStore 建一套隔离夹具:一个跑过迁移的内存库 + 一个把时钟钉在 testNow 的 // 存储。默认 **关闭 GC**(存储用例与保留期无关,GC 由 gc_test.go 专门覆盖), // tune 可覆盖任何配置项。第二个返回值是底层连接,供用例做结构级断言。 func newTestStore(t *testing.T, tune func(*Config)) (*SQLStore, *sql.DB) { t.Helper() ctx := context.Background() conn, err := db.Open(ctx, ":memory:", nil) if err != nil { t.Fatalf("打开内存库: %v", err) } if err := db.MigrateUp(ctx, conn, nil); err != nil { _ = conn.Close() t.Fatalf("内存库跑迁移: %v", err) } t.Cleanup(func() { _ = conn.Close() }) cfg := Config{DB: conn, Now: testNow, GCInterval: 0, Retention: 0} if tune != nil { tune(&cfg) } st, err := NewStore(cfg) if err != nil { t.Fatalf("NewStore: %v", err) } return st, conn } // sampleGraph 造一轮最小图(input→llm→output),轮次标识 = "-", // 与 `trace.RunGraph.Snapshot` 的命名逐字同形。 func sampleGraph(session string, round int) *trace.FlowGraph { return &trace.FlowGraph{ ID: session + "-" + itoa(round), SessionID: session, StartedAt: testNow().Add(-time.Minute), EndedAt: testNow(), Nodes: []trace.FlowNode{ {ID: "input", Label: "你好", Kind: "input", Status: trace.FlowStatusDone}, {ID: "llm-1", Label: "思考", Kind: "llm", Status: trace.FlowStatusDone}, {ID: "output", Label: "回复", Kind: "output", Status: trace.FlowStatusDone}, }, Edges: []trace.FlowEdge{{From: "input", To: "llm-1"}, {From: "llm-1", To: "output"}}, } } // itoa 是个极小的十进制转换(避免为一个测试引入 fmt 的字符串依赖)。 func itoa(n int) string { if n == 0 { return "0" } digits := make([]byte, 0, 4) for n > 0 { digits = append([]byte{byte('0' + n%10)}, digits...) n /= 10 } return string(digits) } // TestNewStore_NilDB_异常:没有数据库就没有存储 —— 宁可明确失败,也不给一个写不进去 // 的替身(与 events / documents 同一口径)。 func TestNewStore_NilDB_异常(t *testing.T) { t.Parallel() if _, err := NewStore(Config{}); !errors.Is(err, ErrNilDB) { t.Fatalf("NewStore(nil DB) err = %v, want ErrNilDB", err) } } // TestRecordRound_ReadBack_正常 钉住「写进去什么、按轮次读回来就是什么」:Latest 回 // 最新一轮、Graph 按 turnId 精确取,且主干端点与边都完整往返。 func TestRecordRound_ReadBack_正常(t *testing.T) { t.Parallel() ctx := context.Background() st, _ := newTestStore(t, nil) for round := 1; round <= 3; round++ { if err := st.RecordRound(sampleGraph("web", round)); err != nil { t.Fatalf("RecordRound(round=%d): %v", round, err) } } latest, err := st.Latest(ctx, "web") if err != nil { t.Fatalf("Latest: %v", err) } if latest == nil || latest.ID != "web-3" { t.Fatalf("Latest = %+v, want web-3", latest) } got, err := st.Graph(ctx, "web", "web-2") if err != nil { t.Fatalf("Graph(web-2): %v", err) } if got == nil || got.ID != "web-2" { t.Fatalf("Graph(web-2) = %+v, want web-2", got) } if len(got.Nodes) != 3 || len(got.Edges) != 2 { t.Fatalf("round-trip lost structure: nodes=%d edges=%d", len(got.Nodes), len(got.Edges)) } if got.SessionID != "web" { t.Fatalf("SessionID = %q, want web(图自带会话,前端按它键入桶)", got.SessionID) } } // TestGraph_MissingIsHonestNil_边界 钉住「该轮不存在」是**诚实的空**(nil, nil), // 不是错误 —— 客户端据此渲染空态,绝不补画假图。 func TestGraph_MissingIsHonestNil_边界(t *testing.T) { t.Parallel() ctx := context.Background() st, _ := newTestStore(t, nil) if err := st.RecordRound(sampleGraph("web", 1)); err != nil { t.Fatalf("RecordRound: %v", err) } for _, turn := range []string{"web-999", "sess-backthink-1"} { got, err := st.Graph(ctx, "web", turn) if err != nil { t.Fatalf("Graph(%s) err = %v, want nil(缺席不是错误)", turn, err) } if got != nil { t.Fatalf("Graph(%s) = %+v, want nil", turn, got) } } latest, err := st.Latest(ctx, "sess-backthink") if err != nil { t.Fatalf("Latest(unknown session) err = %v, want nil", err) } if latest != nil { t.Fatalf("Latest(unknown session) = %+v, want nil", latest) } } // TestRecordRound_SameTurnMerges_正常 钉住唯一键:同一会话同一轮二次收尾是**同轮覆盖** // (一行、取后者),绝不插出两行「同一轮」。 func TestRecordRound_SameTurnMerges_正常(t *testing.T) { t.Parallel() ctx := context.Background() st, conn := newTestStore(t, nil) first := sampleGraph("web", 1) if err := st.RecordRound(first); err != nil { t.Fatalf("record first: %v", err) } second := sampleGraph("web", 1) second.Nodes = second.Nodes[:1] // 第二次少两个节点:读回来必须看到「后写的那份」 if err := st.RecordRound(second); err != nil { t.Fatalf("record second: %v", err) } var rows int if err := conn.QueryRowContext(ctx, `SELECT count(*) FROM flow_graphs WHERE session_id = ? AND turn_id = ?`, "web", "web-1").Scan(&rows); err != nil { t.Fatalf("count rows: %v", err) } if rows != 1 { t.Fatalf("rows = %d, want 1 (unique key must merge, not duplicate)", rows) } got, err := st.Graph(ctx, "web", "web-1") if err != nil { t.Fatalf("Graph: %v", err) } if got == nil || len(got.Nodes) != 1 { t.Fatalf("merged round = %+v, want the second (1 node)", got) } } // TestRecordRound_MissingID_异常 钉住缺坐标是显式的编程错误(ErrNoGraphID), // 绝不写一行「不知道是哪一轮」的记录进库。 func TestRecordRound_MissingID_异常(t *testing.T) { t.Parallel() st, _ := newTestStore(t, nil) cases := map[string]*trace.FlowGraph{ "nil graph": nil, "no session": {ID: "web-1"}, "no turn id": {SessionID: "web"}, "blank turn id": {SessionID: "web", ID: " "}, "blank session": {SessionID: " ", ID: "web-1"}, } for name, g := range cases { if err := st.RecordRound(g); !errors.Is(err, ErrNoGraphID) { t.Fatalf("%s: err = %v, want ErrNoGraphID", name, err) } } } // TestTurns_NewestFirstAndClamped_正常 钉住轮次列表的排序口径(最新在前)与分页钳制: // 面板据此选历史轮,顺序错了就等于把「最近几轮」讲反。 func TestTurns_NewestFirstAndClamped_正常(t *testing.T) { t.Parallel() ctx := context.Background() // 时钟递增,保证 created_at 严格不同(排序可复现)。 base := testNow() clock := base st, _ := newTestStore(t, func(c *Config) { c.Now = func() time.Time { return clock } }) for round := 1; round <= 4; round++ { clock = base.Add(time.Duration(round) * time.Minute) if err := st.RecordRound(sampleGraph("web", round)); err != nil { t.Fatalf("RecordRound(round=%d): %v", round, err) } } turns, err := st.Turns(ctx, "web", 0) if err != nil { t.Fatalf("Turns: %v", err) } if len(turns) != 4 { t.Fatalf("turns = %d, want 4", len(turns)) } want := []string{"web-4", "web-3", "web-2", "web-1"} for i, w := range want { if turns[i].TurnID != w { t.Fatalf("turns[%d] = %q, want %q(最新在前)", i, turns[i].TurnID, w) } } // 负/零取缺省;超上限被钳住(这里给 2 取 2)。 limited, err := st.Turns(ctx, "web", 2) if err != nil { t.Fatalf("Turns(limit=2): %v", err) } if len(limited) != 2 || limited[0].TurnID != "web-4" { t.Fatalf("Turns(limit=2) = %+v, want newest 2", limited) } if MaxTurnsLimit < 3 || normalizeLimit(MaxTurnsLimit+1000) != MaxTurnsLimit { t.Fatalf("normalizeLimit must clamp to MaxTurnsLimit=%d", MaxTurnsLimit) } if normalizeLimit(0) != DefaultTurnsLimit || normalizeLimit(-5) != DefaultTurnsLimit { t.Fatalf("normalizeLimit(≤0) must fall back to DefaultTurnsLimit=%d", DefaultTurnsLimit) } } // TestStore_SurvivesRestart_正常 是**本切片的内核**(主人要求):后端重启后仍能查回 // 历史轮次的图。用同一个**文件库**、先后两条连接 + 两个 store 模拟进程重启 —— 第二个 // store 内存里没有任何图,唯一能拿到 web-3 的方式就是从库里读回。 func TestStore_SurvivesRestart_正常(t *testing.T) { t.Parallel() ctx := context.Background() path := filepath.Join(t.TempDir(), "cyrene.db") // 第一个「进程」:建库、迁移、落一轮。 conn1, err := db.Open(ctx, path, nil) if err != nil { t.Fatalf("open db (first): %v", err) } if err := db.MigrateUp(ctx, conn1, nil); err != nil { _ = conn1.Close() t.Fatalf("migrate (first): %v", err) } st1, err := NewStore(Config{DB: conn1, Now: testNow}) if err != nil { _ = conn1.Close() t.Fatalf("NewStore (first): %v", err) } if err := st1.RecordRound(sampleGraph("web", 3)); err != nil { _ = conn1.Close() t.Fatalf("RecordRound: %v", err) } _ = conn1.Close() // 模拟进程退出 // 第二个「进程」:新连接、新 store(内存里没有任何图),只能靠落盘。 conn2, err := db.Open(ctx, path, nil) if err != nil { t.Fatalf("open db (second): %v", err) } t.Cleanup(func() { _ = conn2.Close() }) if err := db.MigrateUp(ctx, conn2, nil); err != nil { t.Fatalf("migrate (second): %v", err) } st2, err := NewStore(Config{DB: conn2, Now: testNow}) if err != nil { t.Fatalf("NewStore (second): %v", err) } got, err := st2.Graph(ctx, "web", "web-3") if err != nil { t.Fatalf("Graph after restart: %v", err) } if got == nil || got.ID != "web-3" { t.Fatalf("重启后查回 = %+v, want web-3(这正是主人要的「本地数据」)", got) } latest, err := st2.Latest(ctx, "web") if err != nil { t.Fatalf("Latest after restart: %v", err) } if latest == nil || latest.ID != "web-3" { t.Fatalf("重启后 Latest = %+v, want web-3", latest) } turns, err := st2.Turns(ctx, "web", 0) if err != nil { t.Fatalf("Turns after restart: %v", err) } if len(turns) != 1 || turns[0].TurnID != "web-3" { t.Fatalf("重启后 turns = %+v, want [web-3]", turns) } }