Files
Cyrene_v2/internal/runtime/flowgraph/timeline_test.go
T
AskaEth 1efe3a6a79 feat(flow): 链路图时间轴出口(W1)—— 所有轮次按时间正序
- 新增 GET /api/v1/admin/flow/timeline?sessionId=&limit=&before=:时间正序多轮,
  每轮 {turnId,round,startedAt,endedAt,lanes[]};limit 缺省 50/上限 200 + before 游标
- 数据源=落盘 flow_graphs + 内存最近一轮(同图 id 去重);保留期 168h 外的轮次不返回,
  但响应给 retentionHours/retentionEnabled/truncated ⇒ 前端如实标注,不悄悄少一块
- 老出口 GET /admin/flow/graph 零 diff;角色门 writeAuth(owner|admin)
- 4 个变异自检(游标 <= 重复、分组降级、去重缺失、truncated 静默)
2026-10-06 15:59:09 +08:00

264 lines
9.0 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 flowgraph
// 本文件钉住**时间轴读侧**(design/19 §7;切片 W1):多轮按时间正序取回、一轮含其
// **每一路**、`before` 游标向更早翻页不重不漏、没有轮次号的老行各自成组。
//
// 断言的是**库里读回来的事实**(几轮、每轮几路、次序、`hasMore`),不按日志断言 ——
// 与 flowgraph_test.go / flowgraph_lane_test.go 同一口径。
import (
"context"
"fmt"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// timelineGraph 用真建图器造一条路的收尾图:round 是会话轮次号(同轮的多路共享),
// seq 是会话级建图序号(每条路必不同,决定图 id),lane 是这一路的权威回合标识。
func timelineGraph(session string, round, seq int, lane string, started time.Time) *trace.FlowGraph {
g := trace.NewRunGraph(session)
g.SetTurnID(lane)
g.SetSeq(seq)
g.Start(round, started)
g.SeedInput("输入-" + lane)
g.SeedOutput("回复-" + lane)
return g.Snapshot()
}
// recordTimelineRound 落一条时间轴夹具(失败即 Fatal)。
func recordTimelineRound(t *testing.T, st *SQLStore, g *trace.FlowGraph) {
t.Helper()
if err := st.RecordRound(g); err != nil {
t.Fatalf("落盘 %s: %v", g.ID, err)
}
}
// TestTimeline_GroupsLanesByRound_正常 是本文件的核心断言:三轮按**时间正序**返回,
// 中间那一轮的**两路**并进同一轮(`lanes` 两条),而不是被拆成两轮、也不是只剩一路。
func TestTimeline_GroupsLanesByRound_正常(t *testing.T) {
t.Parallel()
st, _ := newTestStore(t, nil)
ctx := context.Background()
base := testNow().Add(-time.Hour)
recordTimelineRound(t, st, timelineGraph("web", 1, 1, "turn-a", base.Add(30*time.Minute)))
recordTimelineRound(t, st, timelineGraph("web", 2, 2, "turn-b", base.Add(40*time.Minute)))
recordTimelineRound(t, st, timelineGraph("web", 2, 3, "turn-c", base.Add(40*time.Minute).Add(time.Second)))
recordTimelineRound(t, st, timelineGraph("web", 3, 4, "turn-d", base.Add(50*time.Minute)))
rounds, more, err := st.Timeline(ctx, "web", 0, time.Time{})
if err != nil {
t.Fatalf("Timeline: %v", err)
}
if more {
t.Fatal("hasMore = true,但库里只有这三轮")
}
if len(rounds) != 3 {
t.Fatalf("轮数 = %d, want 3(同一轮的两路属**同一轮**): %+v", len(rounds), roundKeys(rounds))
}
for i, want := range []string{"r:1", "r:2", "r:3"} {
if rounds[i].Key != want {
t.Fatalf("rounds[%d].Key = %q, want %q(时间正序)", i, rounds[i].Key, want)
}
}
for i, want := range []int{1, 2, 3} {
if rounds[i].Round != want {
t.Fatalf("rounds[%d].Round = %d, want %d", i, rounds[i].Round, want)
}
}
if got := len(rounds[1].Lanes); got != 2 {
t.Fatalf("第 2 轮的 lanes = %d, want 2(两路都在): %+v", got, rounds[1].Lanes)
}
if !rounds[1].StartedAt.Equal(base.Add(40 * time.Minute)) {
t.Fatalf("第 2 轮开始时刻 = %v, want %v(取最早那一路)", rounds[1].StartedAt, base.Add(40*time.Minute))
}
if rounds[0].Lanes[0].ID != "web-1#turn-a" {
t.Fatalf("第 1 轮的路 id = %q, want web-1#turn-a", rounds[0].Lanes[0].ID)
}
}
// TestTimeline_BeforeCursorPagesWithoutGapOrDup_边界 钉住分页:`limit=2` 逐页向更早翻,
// 各页**不重不漏**、`hasMore` 如实、翻到底后是诚实空态。
func TestTimeline_BeforeCursorPagesWithoutGapOrDup_边界(t *testing.T) {
t.Parallel()
st, _ := newTestStore(t, nil)
ctx := context.Background()
base := testNow().Add(-10 * time.Hour)
for round := 1; round <= 5; round++ {
started := base.Add(time.Duration(round) * time.Hour)
recordTimelineRound(t, st, timelineGraph("web", round, round, fmt.Sprintf("turn-%d", round), started))
}
seen := map[string]int{}
before := time.Time{}
wantPages := [][]string{
{"r:4", "r:5"},
{"r:2", "r:3"},
{"r:1"},
{},
}
wantMorePages := []bool{true, true, false, false}
for page, want := range wantPages {
rounds, more, err := st.Timeline(ctx, "web", 2, before)
if err != nil {
t.Fatalf("第 %d 页 Timeline: %v", page+1, err)
}
if got := roundKeys(rounds); !sameKeys(got, want) {
t.Fatalf("第 %d 页轮次 = %v, want %v", page+1, got, want)
}
if more != wantMorePages[page] {
t.Fatalf("第 %d 页 hasMore = %v, want %v", page+1, more, wantMorePages[page])
}
if len(rounds) == 0 {
continue
}
for _, r := range rounds {
seen[r.Key]++
}
before = rounds[0].StartedAt
}
for round := 1; round <= 5; round++ {
if key := fmt.Sprintf("r:%d", round); seen[key] != 1 {
t.Fatalf("轮 %s 出现 %d 次, want 1(翻页不重不漏;全部=%v)", key, seen[key], seen)
}
}
}
// TestTimeline_LegacyRowWithoutRoundIsOwnGroup_边界 钉住老行的诚实口径:图上没有轮次号
// (`round` 键缺席)时**每张图自成一组**,绝不把它们猜成同一轮,`Round` 如实为 0
// (调用方据此让线上 JSON 出 `null`)。
func TestTimeline_LegacyRowWithoutRoundIsOwnGroup_边界(t *testing.T) {
t.Parallel()
st, _ := newTestStore(t, nil)
ctx := context.Background()
// 手工造两张「老图」:Round 为 0(= 键缺席)、图 id 不同。
for i, id := range []string{"web-old-1", "web-old-2"} {
g := &trace.FlowGraph{
ID: id,
SessionID: "web",
StartedAt: testNow().Add(-time.Duration(10-i) * time.Minute),
EndedAt: testNow(),
Nodes: []trace.FlowNode{{ID: "input", Label: "你好", Kind: "input"}},
}
recordTimelineRound(t, st, g)
}
rounds, _, err := st.Timeline(ctx, "web", 0, time.Time{})
if err != nil {
t.Fatalf("Timeline: %v", err)
}
if len(rounds) != 2 {
t.Fatalf("轮数 = %d, want 2(无轮次号的行各自成组,绝不猜)", len(rounds))
}
if rounds[0].Key != "t:web-old-1" || rounds[1].Key != "t:web-old-2" {
t.Fatalf("分组键 = %q/%q, want t:web-old-1 / t:web-old-2", rounds[0].Key, rounds[1].Key)
}
for i, r := range rounds {
if r.Round != 0 {
t.Fatalf("rounds[%d].Round = %d, want 0(未知,不得冒充真实轮次)", i, r.Round)
}
}
}
// TestTimeline_StartFallsBackToCreatedAt_边界 钉住时间回落:图上的 `startedAt` 不可用时,
// 轮次开始时刻取该行的**落盘时刻**(同一条记录里的另一个真实时间字段),而不是零值。
func TestTimeline_StartFallsBackToCreatedAt_边界(t *testing.T) {
t.Parallel()
st, _ := newTestStore(t, nil)
ctx := context.Background()
recordTimelineRound(t, st, &trace.FlowGraph{
ID: "web-9",
SessionID: "web",
Round: 9,
Nodes: []trace.FlowNode{{ID: "input", Label: "你好", Kind: "input"}},
})
rounds, _, err := st.Timeline(ctx, "web", 0, time.Time{})
if err != nil {
t.Fatalf("Timeline: %v", err)
}
if len(rounds) != 1 {
t.Fatalf("轮数 = %d, want 1", len(rounds))
}
if !rounds[0].StartedAt.Equal(testNow()) {
t.Fatalf("开始时刻 = %v, want %v(图上没有 startedAt ⇒ 回落落盘时刻)", rounds[0].StartedAt, testNow())
}
}
// TestTimeline_LimitClamped_边界 钉住页大小:缺省 50、超过硬上限 200 即钳住且 `hasMore`
// 如实为 true(绝不静默返回全表)。
func TestTimeline_LimitClamped_边界(t *testing.T) {
t.Parallel()
st, _ := newTestStore(t, nil)
ctx := context.Background()
base := testNow().Add(-time.Hour)
total := MaxTurnsLimit + 5
for round := 1; round <= total; round++ {
started := base.Add(time.Duration(round) * time.Second)
recordTimelineRound(t, st, timelineGraph("web", round, round, "", started))
}
rounds, more, err := st.Timeline(ctx, "web", MaxTurnsLimit+999, time.Time{})
if err != nil {
t.Fatalf("Timeline(超上限): %v", err)
}
if len(rounds) != MaxTurnsLimit || !more {
t.Fatalf("超上限时 = %d 轮 / hasMore=%v, want %d / true", len(rounds), more, MaxTurnsLimit)
}
rounds, more, err = st.Timeline(ctx, "web", 0, time.Time{})
if err != nil {
t.Fatalf("Timeline(缺省): %v", err)
}
if len(rounds) != DefaultTurnsLimit || !more {
t.Fatalf("缺省时 = %d 轮 / hasMore=%v, want %d / true", len(rounds), more, DefaultTurnsLimit)
}
}
// TestTimeline_EmptyAndNilDB_边界 钉住两个空态:没有任何落盘行时返回**空切片**(不是
// nil、更不是错误),未接数据库时如实报 ErrNilDB。
func TestTimeline_EmptyAndNilDB_边界(t *testing.T) {
t.Parallel()
st, _ := newTestStore(t, nil)
rounds, more, err := st.Timeline(context.Background(), "web", 0, time.Time{})
if err != nil {
t.Fatalf("Timeline(空会话): %v", err)
}
if rounds == nil || len(rounds) != 0 || more {
t.Fatalf("空会话 = %+v/%v, want 空切片/false", rounds, more)
}
var nilStore *SQLStore
if _, _, err := nilStore.Timeline(context.Background(), "web", 0, time.Time{}); err == nil {
t.Fatal("nil store 必须如实报错,不得假装「没有轮次」")
}
}
// roundKeys 取出轮次键序列(断言用)。
func roundKeys(rounds []TimelineRound) []string {
out := make([]string, 0, len(rounds))
for _, r := range rounds {
out = append(out, r.Key)
}
return out
}
// sameKeys 报告两个键序列是否逐字相等。
func sameKeys(got, want []string) bool {
if len(got) != len(want) {
return false
}
for i := range got {
if got[i] != want[i] {
return false
}
}
return true
}