feat(admin): Flow 页时间轴视图(W2)

- 视图切换:最近一轮(既有 DOM 逐字节未变,7/7 SSR 对照 IDENTICAL)| 时间轴(新)
- 时间轴:竖轴=时间(默认最新在上)、每回合一个可折叠块、块内复用 LaneTracks 泳道、
  块间不画任何连线(不臆造因果);before 游标分页;truncated ⇒ 显式「更早的轮次已过保留期」
- 降级诚实:只拿到最近一轮(老后端)⇒ 明确提示「当前只支持最近一轮」
- 4 个变异自检(排序方向/块间插线/truncated 静默/游标方向);老视图等价性实测
This commit is contained in:
AskaEth committed 2026-10-06 15:59:11 +08:00
1 parent 1efe3a6a79
commit 5ee5f9126f
10 files changed
+1831 -127

No files matched your search

@@ -0,0 +1,336 @@
// 「链路时间轴」的**纯逻辑**测试(切片 W2;`design/19 §7`;W1 的新出口)。
//
// 为什么需要它:时间轴的四条硬口径全是**判定**而非渲染 —— ① 顺序与分页游标(`before`
// 恒向更早走,与展示顺序无关);② **不臆造关系**(去重只认后端给的回合标识,标识缺席
// 也不自己拼一个);③ 状态与文案必须把「无图 / 未收尾 / 完成」分开说;④ 老后端(404)
// 与「没有回合」必须分成两件事。本项目没有前端测试运行器,因此这是无依赖的纯 node 检查
// (与 `flowHistory.test.ts` / `flowSession.test.ts` 同一手法)。
//
// 跑法:
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/flow/flowTimeline.test.ts \
// --bundle --platform=node --format=esm \
// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \
// --outfile=/tmp/cyrene-flow-timeline.test.mjs \
// && node /tmp/cyrene-flow-timeline.test.mjs
//
// `tsc --noEmit` 会对本文件做类型检查,因此它不 import `node:*`(用 globalThis 取 fetch)。
import { ApiError, adminFlowTimeline } from "../../../lib/api";
import type { AdminFlowTimelineRound } from "../../../lib/api";
import type { FlowGraph } from "../../../lib/types";
import { MAIN_SESSION_ID } from "../../../store/sessionCache";
import {
RETENTION_TRUNCATED_TEXT,
TIMELINE_PAGE_SIZE,
appendTimelinePage,
classifyTimelineFailure,
mergeTimelineRounds,
normalizeTimelineRounds,
orderRounds,
pageMayHaveOlder,
retentionText,
roundExpanded,
roundGraphs,
roundLabel,
roundStats,
roundStatus,
roundStatusLabel,
roundStatusTag,
shortTurnId,
timelineCursor,
timelinePageOf,
toggleRoundExpansion,
} from "./flowTimeline";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function same<T>(got: T, want: T, what: string): void {
checks += 1;
if (got !== want) throw new Error(`FAILED: ${what} — got ${String(got)}, want ${String(want)}`);
}
/** 一路的图:一个 `input` 节点 + 若干指定状态的节点(哨兵用 label)。 */
function graph(id: string, statuses: ReadonlyArray<"running" | "done" | "error" | "blocked"> = ["done"]): FlowGraph {
return {
id,
sessionId: MAIN_SESSION_ID,
nodes: [
{ id: "input", label: "输入", kind: "input", status: "done" },
...statuses.map((s, i) => ({ id: `n${i}`, label: `节点${i}`, kind: "tool", status: s })),
],
edges: [{ from: "input", to: "n0" }],
};
}
/** 一个回合:`startedAt` 决定它在时间轴上的位置。 */
function round(
turnId: string,
startedAt: string,
opts: { round?: number | null; endedAt?: string | null; graphs?: ReadonlyArray<FlowGraph | null> } = {}
): AdminFlowTimelineRound {
const graphs = opts.graphs ?? [graph(`${turnId}-1`)];
return {
turnId,
round: opts.round === undefined ? 1 : opts.round,
startedAt,
endedAt: opts.endedAt === undefined ? "2026-10-06T10:00:10+08:00" : opts.endedAt,
lanes: graphs.map((g, i) => ({ turnId: `${turnId}#leg${i + 1}`, graph: g })),
};
}
// ── ① 归一:去重(只认后端给的标识)、按开始时间倒序 ─────────────────────────
const r1 = round("web-1#turn-a", "2026-10-06T10:00:00+08:00", { round: 1 });
const r2 = round("web-2#turn-b", "2026-10-06T10:05:00+08:00", { round: 2 });
const r3 = round("web-3#turn-c", "2026-10-06T10:09:00+08:00", { round: 3 });
// 后端按**时间正序**返回(设计 §7.1),面板必须自己摆成「最新在前」。
const normalized = normalizeTimelineRounds([r1, r2, r3]);
same(normalized.length, 3, "三个回合就是三条(不吞不造)");
same(normalized[0].turnId, "web-3#turn-c", "归一后最新在前");
same(normalized[2].turnId, "web-1#turn-a", "归一后最旧在最后");
same(
normalizeTimelineRounds([r1, r1, r2]).length,
2,
"同一个回合标识出现两次 ⇒ 只留一条(分页重叠不重)"
);
same(normalizeTimelineRounds(null).length, 0, "没有回合 ⇒ 空数组(不编造)");
same(normalizeTimelineRounds(undefined).length, 0, "响应缺 rounds 键 ⇒ 空数组");
// 标识缺席:按「开始时间 + 轮次」去重;既无标识又无时间 ⇒ **不去重**(无法判定是同一回合)。
const anonA: AdminFlowTimelineRound = { turnId: "", round: 7, startedAt: "2026-10-06T10:01:00+08:00", lanes: [] };
const anonB: AdminFlowTimelineRound = { turnId: "", round: 7, startedAt: "2026-10-06T10:01:00+08:00", lanes: [] };
same(normalizeTimelineRounds([anonA, anonB]).length, 1, "无标识但同时间同轮次 ⇒ 视作同一回合");
const noTimeA: AdminFlowTimelineRound = { turnId: "", round: null, startedAt: "", lanes: [] };
const noTimeB: AdminFlowTimelineRound = { turnId: "", round: null, startedAt: "坏时间", lanes: [] };
same(
normalizeTimelineRounds([noTimeA, noTimeB]).length,
2,
"既无标识又无可用时间 ⇒ 不去重(宁可多显示,也不吞掉一条)"
);
// ── ② 展示顺序:可切,且**不影响分页游标** ───────────────────────────────────
same(orderRounds(normalized, "newest-first")[0].turnId, "web-3#turn-c", "默认顺序 = 最新在上");
same(orderRounds(normalized, "oldest-first")[0].turnId, "web-1#turn-a", "切顺序 ⇒ 最旧在上");
same(timelineCursor(normalized), r1.startedAt, "游标 = 手上**最旧**那一回合的开始时间(原样回传)");
same(
timelineCursor(orderRounds(normalized, "oldest-first")),
r1.startedAt,
"切展示顺序不改变游标(分页恒向更早走)"
);
same(timelineCursor([]), null, "没有回合 ⇒ 没有游标(不发请求)");
same(
timelineCursor([noTimeA, noTimeB]),
null,
"最旧那一回合没有可用时间 ⇒ 游标为 null(绝不瞎凑一个游标)"
);
same(
timelineCursor([r1, { ...r2, startedAt: "不是时间" }]),
r1.startedAt,
"坏时间的那一条只被跳过,不吃掉能用的游标"
);
// ── ③ 分页:满页才可能还有更早的 ────────────────────────────────────────────
ok(pageMayHaveOlder(TIMELINE_PAGE_SIZE, TIMELINE_PAGE_SIZE), "满页 ⇒ 还可能更早");
ok(!pageMayHaveOlder(3, TIMELINE_PAGE_SIZE), "短页 ⇒ 到头了(否则会在同一游标上打转)");
ok(!pageMayHaveOlder(0, 0), "页大小非法 ⇒ 不宣称还有更早的");
const page1 = normalizeTimelineRounds([r2, r3]);
const merged = mergeTimelineRounds(page1, normalizeTimelineRounds([r1, r2]));
same(merged.length, 3, "翻页重叠 ⇒ 合并后不重(3 条不是 4 条)");
same(merged.map((r) => r.turnId).join(","), "web-3#turn-c,web-2#turn-b,web-1#turn-a", "合并后仍是最新在前");
// 追加一页的判定:满页 + 真有新回合才继续翻。
const r0 = round("web-0#turn-0", "2026-10-06T09:00:00+08:00", { round: 0 });
const fullPage = [r1, r0];
const appendFresh = appendTimelinePage([r2, r3], fullPage, 2);
same(appendFresh.added, 2, "追加一页:落在外面的都算新增");
same(appendFresh.rounds.length, 4, "追加后 4 条(去重不重)");
ok(appendFresh.canLoadMore, "满页 + 有新增 ⇒ 还能继续翻");
const appendStale = appendTimelinePage([r1, r2], [r1, r2], 2);
same(appendStale.added, 0, "整页都是已有的回合 ⇒ 新增 0 条");
ok(!appendStale.canLoadMore, "没有新增 ⇒ 停住(否则会在同一游标上打转)");
const shortPage = appendTimelinePage([r2, r3], [r1], 2);
same(shortPage.rounds.length, 3, "短页里的新回合照旧并入(不因为停翻就丢掉已拿到的)");
ok(!shortPage.canLoadMore, "短页(哪怕有新增)⇒ 仍到头了");
// ── ④ 短标识:只截断展示,不反解、不拼 id ────────────────────────────────────
same(shortTurnId("web-7#turn-3f9c8ab1c2d3"), "turn-3f9c8ab…", "带 # 的标识取 # 之后那段并截断");
same(shortTurnId("web-7"), "web-7", "老行没有 # ⇒ 原样(短就不用截断)");
same(shortTurnId(""), "无回合标识", "标识缺席 ⇒ 如实说没有标识(绝不现编一个)");
// ── ⑤ 轮次号:缺席 / null ⇒ 「轮次未知」,不得用 0 冒充 ──────────────────────
same(roundLabel(3), "第 3 轮", "有轮次号就报号");
same(roundLabel(0), "第 0 轮", "0 是后端真的给的号就照报(本切片不去猜它的含义)");
same(roundLabel(undefined), "轮次未知", "轮次号缺席 ⇒ 「轮次未知」");
same(roundLabel(null), "轮次未知", "轮次号为 null ⇒ 「轮次未知」");
// ── ⑥ 状态:运行 / 错误 / 阻塞优先;未收尾 ≠ 完成;无图 ≠ 空闲 ───────────────
same(roundStatus(round("a", "t", { graphs: [graph("g1", ["running", "done"])] })), "running", "有在跑的节点 ⇒ 运行中");
same(roundStatus(round("a", "t", { graphs: [graph("g1", ["done"]), graph("g2", ["error"])] })), "error", "一路出错 ⇒ 整回合错误(并行下不许说「完成」)");
same(roundStatus(round("a", "t", { graphs: [graph("g1", ["done"])] })), "done", "节点都完成且有收尾时间 ⇒ 完成");
same(
roundStatus(round("a", "t", { graphs: [graph("g1", ["done"])], endedAt: null })),
"unfinished",
"没有收尾时间 ⇒ 未收尾(不是「完成」,也不是「运行中」)"
);
same(
roundStatus(round("a", "t", { graphs: [graph("g1", ["done"])], endedAt: "" })),
"unfinished",
"收尾时间为空串 ⇒ 同样按未收尾(不把空当收尾过)"
);
same(
roundStatus(round("a", "t", { graphs: [null, null] })),
"none",
"一路图都没有 ⇒ 「无图」(不是「空闲」)"
);
same(roundStatus(round("a", "t", { graphs: [] })), "none", "没有路 ⇒ 「无图」");
same(roundStatus(null), "none", "没有这个回合 ⇒ 「无图」");
same(roundStatusLabel("none"), "无图", "「无图」的文案");
same(roundStatusLabel("unfinished"), "未收尾", "「未收尾」的文案");
same(roundStatusLabel("running"), "运行中", "运行中文案与既有泳道口径一致");
same(roundStatusTag("none"), "neutral", "「无图」不着色(不是告警)");
same(roundStatusTag("unfinished"), "warn", "「未收尾」着警示色");
// ── ⑦ 观测量:路数 / 有图的路数 / 节点数 / 缺席的路数 ───────────────────────
const mixed = round("a", "t", { graphs: [graph("g1", ["done", "done"]), null] });
const stats = roundStats(mixed);
same(stats.legs, 2, "路数按后端报的算(含没图的)");
same(stats.drawn, 1, "有图可画的只有一路");
same(stats.missing, 1, "缺席的路数必须能让用户看见");
same(stats.nodes, 3, "节点数按有图的那些算");
same(roundGraphs(mixed).length, 1, "取图只取真有的(null 不进画布)");
same(roundStats(null).legs, 0, "没有回合 ⇒ 全零(不编造)");
// ── ⑧ 折叠记忆:最新默认展开,用户点过以用户为准 ────────────────────────────
ok(roundExpanded("web-3#turn-c", {}, true), "没人点过时:最新那回合默认展开");
ok(!roundExpanded("web-1#turn-a", {}, false), "没人点过时:更早的回合默认折叠");
const toggled = toggleRoundExpansion({}, "web-1#turn-a", false);
ok(toggled["web-1#turn-a"] === true, "点一下 ⇒ 展开(并按用户的选择记住)");
ok(
roundExpanded("web-1#turn-a", toggled, true) === true,
"用户点开过的块保持开 —— 即使它成为「最新」也不被默认规则改写"
);
const newestCollapsed = toggleRoundExpansion({}, "web-3#turn-c", true);
ok(
roundExpanded("web-3#turn-c", newestCollapsed, true) === false,
"最新那回合被用户点收起 ⇒ 保持收起(用户的选择优先于「最新默认展开」)"
);
const toggledTwice = toggleRoundExpansion(toggled, "web-1#turn-a", false);
ok(toggledTwice["web-1#turn-a"] === false, "再点一下 ⇒ 收起");
same(toggleRoundExpansion({ web: true }, "", false).web, true, "标识为空时不动别人的状态(不写空键)");
// ── ⑨ 保留期文案:三支互不冒充 ───────────────────────────────────────────────
same(retentionText(168), "保留期 168 小时", "保留期如实报小时数");
ok(retentionText(0).includes("未启用"), "≤0 = 后端配置为不回收(既有口径)");
ok(retentionText(-1).includes("未启用"), "负值同样按「未启用」");
ok(retentionText(null).includes("未知"), "缺失 ⇒ 未知(不编一个数)");
ok(retentionText(undefined).includes("未知"), "undefined ⇒ 未知");
ok(RETENTION_TRUNCATED_TEXT.includes("保留期"), "过期提示必须是「已过保留期」这件事(不是「没有回合」)");
// ── ⑩ 失败分类:老后端(没这个出口)≠ 这次没读到 ──────────────────────────
same(classifyTimelineFailure(new ApiError(404, "not_found", "no route")), "unsupported", "404 ⇒ 老后端,当前只支持最近一轮");
same(classifyTimelineFailure(new ApiError(501, "not_implemented", "nope")), "unsupported", "501 同样算「没有这个出口」");
same(classifyTimelineFailure(new ApiError(500, "flow_unavailable", "boom")), "failed", "500 是「这次没读到」");
same(classifyTimelineFailure(new ApiError(0, "network_error", "offline")), "failed", "网络失败也是「这次没读到」");
same(classifyTimelineFailure(new Error("boom")), "failed", "非 ApiError ⇒ 按失败处理(不声称老后端)");
// ── ⑪ 响应解包:缺键按诚实缺省 ──────────────────────────────────────────────
const packed = timelinePageOf({
rounds: [r1],
retentionHours: 168,
truncated: true,
});
same(packed.rounds.length, 1, "解包保留回合");
same(packed.retentionHours, 168, "解包保留期");
same(packed.truncated, true, "解包截断标记");
const packedEmpty = timelinePageOf(null);
same(packedEmpty.rounds.length, 0, "没有响应 ⇒ 空回合");
same(packedEmpty.retentionHours, null, "没有响应 ⇒ 保留期未知(不是 0)");
same(packedEmpty.truncated, false, "没有响应 ⇒ 不宣称「已过保留期」");
// ── ⑫ 真跑 api 客户端:URL 形状 + 游标**原样**回传(不换算、不拼 id) ───────
type Call = { url: string; method: string };
const calls: Call[] = [];
const originalFetch = globalThis.fetch;
const originalLocalStorage = globalThis.localStorage;
/** api 客户端的 `request` 会读 `localStorage` 取 token;node 下没有它。 */
globalThis.localStorage = {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
} as unknown as Storage;
globalThis.fetch = ((url: string, init?: { method?: string }) => {
calls.push({ url: String(url), method: init?.method ?? "GET" });
return Promise.resolve({
ok: true,
status: 200,
json: () =>
Promise.resolve({ data: { rounds: [r1], retentionHours: 168, truncated: false } }),
} as unknown as Response);
}) as typeof globalThis.fetch;
const resp = await adminFlowTimeline(MAIN_SESSION_ID, { limit: TIMELINE_PAGE_SIZE });
same(resp.rounds.length, 1, "真解析回包(`{data}` 信封)");
ok(
calls[0].url.endsWith("/admin/flow/timeline?sessionId=web&limit=50"),
`请求形状:${calls[0].url}`
);
same(calls[0].method, "GET", "时间轴取数是只读 GET");
await adminFlowTimeline(MAIN_SESSION_ID, { limit: TIMELINE_PAGE_SIZE, before: r1.startedAt });
ok(
decodeURIComponent(calls[1]?.url ?? "").includes(`before=${r1.startedAt}`),
`带游标时**原样**回传后端给的时间字符串(不换算成毫秒、不重排):${calls[1]?.url}`
);
/** 一个**非 2xx** 的桩:`body = null` 表示正文不是 JSON(真后端 404 就是这种:
* `HTTP 404 / 404 page not found`,实测于 2026-10-06 的 :10942)。 */
function stubError(status: number, body: unknown): void {
globalThis.fetch = ((url: string, init?: { method?: string }) => {
calls.push({ url: String(url), method: init?.method ?? "GET" });
return Promise.resolve({
ok: false,
status,
json: () => (body === null ? Promise.reject(new Error("not json")) : Promise.resolve(body)),
} as unknown as Response);
}) as typeof globalThis.fetch;
}
/** 真跑一遍「今天的老后端」那条路:路由不存在 ⇒ 404 + 非 JSON 正文。 */
async function classifyRealError(status: number, body: unknown): Promise<string> {
stubError(status, body);
try {
await adminFlowTimeline(MAIN_SESSION_ID, { limit: TIMELINE_PAGE_SIZE });
return "no-error";
} catch (err: unknown) {
return classifyTimelineFailure(err);
}
}
same(
await classifyRealError(404, null),
"unsupported",
"路由不存在(404 + `404 page not found` 非 JSON 正文)⇒ 落到「当前只支持最近一轮」那一支"
);
same(
await classifyRealError(404, { error: { code: "not_found", message: "no route" } }),
"unsupported",
"404 + 规范信封 ⇒ 同样按「没有这个出口」"
);
same(
await classifyRealError(500, { error: { code: "flow_unavailable", message: "boom" } }),
"failed",
"500 ⇒ 按「这次没读到」(不是老后端)"
);
globalThis.fetch = originalFetch;
globalThis.localStorage = originalLocalStorage;
console.log(`flow timeline logic test: ${checks} checks passed`);