// 「链路时间轴」的**纯逻辑**测试(切片 W2;`design/19 §7`;W1 的新出口)。 // // 为什么需要它:时间轴的四条硬口径全是**判定**而非渲染 —— ① 顺序与分页游标(`before` // 恒向更早走,与展示顺序无关);② **不臆造关系**(去重只认后端给的回合标识,标识缺席 // 也不自己拼一个);③ 状态与文案必须把「无图 / 未收尾 / 完成」分开说;④ 老后端(404) // 与「没有回合」必须分成两件事。⑤(v0.81 追补)**「已过保留期」只认权威键** // `retentionBoundaryReached === true`:`truncated` / `retentionEnabled` / 「翻到底了没」 // 的任何组合都**不再是**判据,权威键缺席(老后端)⇒ 按最保守话术(一句也不说)。本项目 // 没有前端测试运行器,因此这是无依赖的纯 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, pageHasOlder, pageMayHaveOlder, retentionText, roundExpanded, roundGraphs, roundLabel, roundStats, roundStatus, roundStatusLabel, roundStatusTag, shortTurnId, timelineCursor, timelineCursorOf, timelineHints, timelinePageOf, toggleRoundExpansion, } from "./flowTimeline"; import type { TimelineHints } from "./flowTimeline"; let checks = 0; function ok(cond: boolean, what: string): void { checks += 1; if (!cond) throw new Error(`FAILED: ${what}`); } function same(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 } = {} ): 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, retentionBoundaryReached: true, }); same(packed.rounds.length, 1, "解包保留回合"); same(packed.retentionHours, 168, "解包保留期"); same(packed.truncated, true, "解包截断标记"); same(packed.retentionBoundaryReached, true, "解包**权威键**(v0.81):不能丢、不能反相"); const packedEmpty = timelinePageOf(null); same(packedEmpty.rounds.length, 0, "没有响应 ⇒ 空回合"); same(packedEmpty.retentionHours, null, "没有响应 ⇒ 保留期未知(不是 0)"); same(packedEmpty.truncated, false, "没有响应 ⇒ 不宣称「本次不是全部」"); same(packedEmpty.retentionBoundaryReached, null, "没有响应 ⇒ 权威键为**未知**(不冒充 false)"); // 老后端(v0.80 出口,没有这个键):必须落成 `null`(不知道),**不许**用别的字段回推。 const packedOld = timelinePageOf({ rounds: [r1], retentionHours: 168, truncated: true }); same( packedOld.retentionBoundaryReached, null, "老后端没给权威键 ⇒ 解包成 null(不知道),**不**退回启发式" ); // ── ⑫ 真跑 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 { 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; // ── ⑬ 分页判据:`hasMore` 权威 / `nextBefore` 权威(契约 v0.80 §4.11.1(a)) ─── ok(pageHasOlder(true, 2, 50), "后端说 hasMore ⇒ 可能还有(**哪怕这一页是短页**)"); ok(!pageHasOlder(false, 50, 50), "后端说 hasMore=false ⇒ 到头了(**哪怕这一页是满页**,不要白打请求)"); ok(pageHasOlder(null, 50, 50), "老后端没给 hasMore ⇒ 退回满页启发式"); ok(!pageHasOlder(null, 3, 50), "老后端没给 hasMore + 短页 ⇒ 到头了"); same( timelineCursorOf({ nextBefore: r1.startedAt, rounds: [r1, r2] }), r1.startedAt, "游标优先用后端的 nextBefore" ); same( timelineCursorOf({ nextBefore: null, rounds: [r1, r2] }), r1.startedAt, "后端没给 nextBefore ⇒ 退回自己算的「最旧那一回合」(原样字符串)" ); same(timelineCursorOf({ nextBefore: null, rounds: [] }), null, "都没有 ⇒ 没有游标(不瞎凑)"); // ── ⑭ 权威键缺席 = 不知道(老后端不得拿别的字段回推) ──────────────────────── // v0.81 起「已过保留期」的判据**只有** `retentionBoundaryReached === true`:老后端(v0.80 // 出口)没这个键 ⇒ 解包成 `null`,「保留期开着」这件事本身**不足以**支撑那句话。 const oldBackendPage = timelinePageOf({ rounds: [r1], retentionHours: 168, retentionEnabled: true, truncated: true, }); same(oldBackendPage.retentionBoundaryReached, null, "老后端缺权威键 ⇒ 状态里是 null(不知道)"); // 喂给判定的入参:与 `timelineHints` 的真实入参同形(`canLoadMore` 由取数层算,这里取最保守的 false)。 const oldBackendHints = timelineHints({ truncated: oldBackendPage.truncated, canLoadMore: false, retentionBoundaryReached: oldBackendPage.retentionBoundaryReached, source: oldBackendPage.source, }); ok( !oldBackendHints.expired, "「保留期启用 + truncated」**不构成**过期的判据(v0.81 明令禁止自己拼)" ); ok(oldBackendHints.hidden, "缺权威键 + truncated ⇒ 只能挂「更早的轮次未显示」"); // ── ⑮ 页首三句话的判定(`timelineHints`):互不冒充 ───────────────────────── // 入参只认「后端给的键」:`truncated` / `canLoadMore` / `retentionBoundaryReached` / `source`。 // ⚠️ `retentionHours` / `retentionEnabled` **已不再**是入参(它们去掉了判据资格)。 const hintBase = { truncated: true, canLoadMore: false, retentionBoundaryReached: null, source: null }; /** 三支一起比(对象比较要逐字段,`same` 只比标量)。 */ function hintsOf(h: TimelineHints): string { return `${h.hidden ? "hidden" : "-"}/${h.expired ? "expired" : "-"}/${h.liveOnly ? "liveOnly" : "-"}`; } same(hintsOf(timelineHints({ ...hintBase, truncated: false })), "-/-/-", "没说缺 ⇒ 三支都为假"); same( hintsOf(timelineHints({ ...hintBase, canLoadMore: true })), "hidden/-/-", "还有更早可拉 ⇒ 只能「未显示」(**绝不**说「已过保留期」)" ); same( hintsOf(timelineHints({ ...hintBase, retentionBoundaryReached: false })), "hidden/-/-", "后端明确说 false(本次不是全部但没被清扫)⇒ 说「未显示」" ); same( hintsOf(timelineHints({ ...hintBase, truncated: false, canLoadMore: false, retentionBoundaryReached: true })), "-/expired/-", "权威键 true ⇒ 才说「已过保留期」(哪怕这一页 truncated=false:已到最早一端)" ); same( hintsOf(timelineHints({ ...hintBase, retentionBoundaryReached: true })), "-/expired/-", "权威键 true 时**不再**同时挂「未显示」(一句话,不是两句)" ); same( hintsOf(timelineHints({ ...hintBase, source: "live", retentionBoundaryReached: false })), "hidden/-/liveOnly", "本页全来自内存(落盘源未接线)⇒ 「只拿到最近一轮」" ); same( hintsOf(timelineHints({ ...hintBase, source: "live", retentionBoundaryReached: true })), "hidden/-/liveOnly", "本页全来自内存时**压住**过期声明(本页没有落盘对照,无从复核 ⇒ 少说)" ); same( hintsOf(timelineHints({ truncated: false, canLoadMore: false, retentionBoundaryReached: null, source: null })), "-/-/-", "一切未知 / 什么都没说 ⇒ 一句话都不说(最保守话术)" ); console.log(`flow timeline logic test: ${checks} checks passed`);