// 「链路时间轴取数(`useFlowTimeline`)」的**真跑**测试(切片 V2;契约 v0.80 §4.11.1)。 // // 为什么需要它:W2 片自认的最大缺口是「`useFlowTimeline` 的 effect 本体**没有任何可执行 // 测试**」(本项目没有 jsdom、没有测试运行器 ⇒ hook 与 effect 跑不了)。本测试把取数体 // 从 hook 里抽成两个**导出**的异步函数(`loadFirstPage` / `loadOlderPage`)与两条导出守卫 // (`timelineNeedsFirstPage` / `timelineCanLoadMore`),于是可以在 node 里**真跑**: // // 真的 api 客户端(`adminFlowTimeline` → `request` → `fetch`) // + 一个**照契约实现**的假后端(按 `before` 严格更早、`hasMore`、`nextBefore`、`truncated`) // + 真的状态机函数与真的纯函数(归一/去重/游标) // ⇒ 断言「分页**不重不漏**、到底即停、零新增即停、失败不吞已拿到的、老后端 ≠ 空态」。 // // 跑法(与 `flowTimeline.test.ts` 同一手法:esbuild + node,无依赖): // // cd frontend/web \ // && ./node_modules/.bin/esbuild src/admin/modules/flow/useFlowTimeline.test.ts \ // --bundle --platform=node --format=esm \ // --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \ // --outfile=/tmp/cyrene-use-flow-timeline.test.mjs \ // && node /tmp/cyrene-use-flow-timeline.test.mjs // // `tsc --noEmit` 会对本文件做类型检查,因此它不 import `node:*`(用 globalThis 取 fetch)。 import { TIMELINE_PAGE_SIZE, timelineCursorOf, timelineHints } from "./flowTimeline"; import { emptyState, loadFirstPage, loadOlderPage, timelineCanLoadMore, timelineNeedsFirstPage, } from "./useFlowTimeline"; import type { Send, TimelineData } from "./useFlowTimeline"; import { MAIN_SESSION_ID } from "../../../store/sessionCache"; import type { FlowGraph } from "../../../lib/types"; 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)}`); } const originalFetch = globalThis.fetch; const originalLocalStorage = globalThis.localStorage; /** api 客户端的 `request` 会读 `localStorage` 取 token;node 下没有它。 */ globalThis.localStorage = { getItem: () => null, setItem: () => {}, removeItem: () => {}, } as unknown as Storage; // ── 假后端:**照契约**实现时间轴出口(契约 v0.80 §4.11.1) ───────────────────── // // 数据:5 个回合(时间正序),其中第 2 轮有**两路**(真并行),因此总路数 6。 // 页大小独立于 `limit`(设 2 ⇒ 等价于设计 §7.3 判据 4 的「`limit=2` 连续翻页」)。 // `hasMore` / `nextBefore` / `truncated` 一律按后端口径算,不自作主张。 interface WireLane { turnId: string; graph: FlowGraph; } interface WireRound { turnId: string; round: number; startedAt: string; endedAt: string; lanes: WireLane[]; } function wireGraph(id: string, turnId: string, round: number, startedAt: string): FlowGraph { return { id, sessionId: MAIN_SESSION_ID, turnId, round, startedAt, endedAt: startedAt, nodes: [ { id: "input", label: `${turnId}输入`, kind: "input", status: "done" }, { id: "out", label: `${turnId}回复`, kind: "output", status: "done" }, ], edges: [{ from: "input", to: "out" }], }; } /** 5 个回合:`web-1#turn-a` … `web-5#turn-e`(第 2 轮两路)。 */ function dataset(): WireRound[] { const marks = [ ["turn-a", "2026-10-06T02:00:00.000Z"], ["turn-b", "2026-10-06T02:05:00.000Z"], ["turn-c", "2026-10-06T02:09:00.000Z"], ["turn-d", "2026-10-06T03:00:00.000Z"], ["turn-e", "2026-10-06T03:30:00.000Z"], ]; return marks.map(([turnId, startedAt], i) => { const round = i + 1; const lanes: WireLane[] = [ { turnId, graph: wireGraph(`web-${round}#${turnId}`, turnId, round, startedAt) }, ]; if (round === 2) { lanes.push({ turnId: `${turnId}#leg2`, graph: wireGraph(`web-2#${turnId}#leg2`, `${turnId}#leg2`, 2, startedAt), }); } return { turnId: `web-${round}#${turnId}`, round, startedAt, endedAt: startedAt, lanes }; }); } /** 假后端的可调档位(默认 = 正常后端)。 */ interface ServerMode { /** 一页几条(独立于 `limit`,用来把「`limit=2` 翻页」这条判据跑出来)。 */ pageSize: number; /** `persisted` = 正常;`liveOnly` = 落盘源未接线(只回内存最近一轮);`stuck` = 永远说还有更早。 */ kind: "persisted" | "liveOnly" | "stuck"; } const calls: string[] = []; /** 真跑 `fetch`:把假后端接在 `globalThis.fetch` 上(URL 逐条记下,供形状断言)。 * * 语义**照契约**(§4.11.1):`before` = 开始时刻**严格早于**它;`hasMore` = 库里还有更早的; * `nextBefore` = 本页最早一轮的开始时刻;`truncated` = `hasMore || 落盘源未接线`。 * `stuck` 档故意**忽略游标**并永远说 `hasMore`(模拟坏后端),用来钉住「零新增即停」。 */ function installBackend(mode: Partial = {}): ServerMode { const cfg: ServerMode = { pageSize: 2, kind: "persisted", ...mode }; globalThis.fetch = ((url: string) => { calls.push(String(url)); const q = new URLSearchParams(String(url).split("?")[1] ?? ""); const before = q.get("before") ?? ""; const all = dataset(); let pool = all; if (cfg.kind === "liveOnly") { pool = []; } else if (before && cfg.kind !== "stuck") { pool = all.filter((r) => r.startedAt < before); } const page = pool.slice(-cfg.pageSize); const hasMore = cfg.kind === "stuck" ? pool.length > 0 : pool.length > page.length; const payload = cfg.kind === "liveOnly" ? { sessionId: MAIN_SESSION_ID, source: "live", rounds: [all[all.length - 1]], nextBefore: "", hasMore: false, truncated: true, retentionHours: 168, retentionEnabled: true, } : { sessionId: MAIN_SESSION_ID, source: page.length > 0 ? "persisted" : "none", rounds: page, nextBefore: page.length > 0 ? page[0].startedAt : "", hasMore, truncated: hasMore, retentionHours: 168, retentionEnabled: true, }; return Promise.resolve({ ok: true, status: 200, headers: { get: () => "application/json" }, json: () => Promise.resolve({ data: payload }), }); }) as unknown as typeof globalThis.fetch; return cfg; } /** 从记下的 URL 里取某个查询参数(客户端发的是 `URLSearchParams` 编码过的,这里解回来)。 */ function queryOf(url: string, key: string): string | null { return new URLSearchParams(url.split("?")[1] ?? "").get(key); } /** 一个只回错误的假后端(`body === null` = 正文不是 JSON,真后端 404 就是这种)。 */ function installError(status: number, body: unknown): void { globalThis.fetch = ((url: string) => { calls.push(String(url)); return Promise.resolve({ ok: false, status, headers: { get: () => "text/plain" }, json: () => (body === null ? Promise.reject(new Error("not json")) : Promise.resolve(body)), }); }) as unknown as typeof globalThis.fetch; } /** 测试里的「状态机宿主」:把 `Send` 补丁真的落进一份状态(等价于 hook 的 setState)。 */ interface Host { get: () => TimelineData; send: Send; } function host(sessionId: string): Host { let state = emptyState(sessionId); const send: Send = (patch) => { state = patch(state); }; return { get: () => state, send }; } /** 按 hook 的取法翻到底(`loadMore` 的守卫 + `timelineCursorOf` 的游标),返回翻页次数。 */ async function pageToEnd(h: Host, sid: string, cap = 10): Promise { let pages = 0; while (timelineCanLoadMore(h.get()) && pages < cap) { pages += 1; const cursor = timelineCursorOf(h.get()); if (!cursor) break; await loadOlderPage(sid, cursor, h.send); } return pages; } // ── ① 首屏:一个请求、URL 形状正确、状态照后端字段落定 ────────────────────── installBackend(); calls.length = 0; const h = host(MAIN_SESSION_ID); await loadFirstPage(MAIN_SESSION_ID, h.send); same(calls.length, 1, "首屏恰好一个请求"); ok( calls[0].endsWith(`/api/v1/admin/flow/timeline?sessionId=${MAIN_SESSION_ID}&limit=${TIMELINE_PAGE_SIZE}`), `首屏请求形状(会话 + limit):${calls[0]}` ); same(h.get().rounds.length, 2, "假后端一页 2 条 ⇒ 状态里 2 个回合"); same(h.get().rounds[0].turnId, "web-5#turn-e", "状态里**最新在前**(渲染顺序与分页游标解耦)"); same(h.get().canLoadMore, true, "后端说 hasMore ⇒ 还能继续翻"); same(h.get().truncated, true, "后端说 truncated ⇒ 原样落进状态(取数层不替它解释)"); same(h.get().retentionHours, 168, "保留期原样落进状态"); same(h.get().retentionEnabled, true, "保留期启用标记原样落进状态"); same(h.get().source, "persisted", "数据源原样落进状态"); same(h.get().loading, false, "拿到首屏后不再是加载态"); same(h.get().failed, false, "首屏成功 ⇒ 没有失败标记"); // ── ② 分页收敛(`limit=2` 口径):不重不漏 + 到底即停 ─────────────────────── calls.length = 0; const pages = await pageToEnd(h, MAIN_SESSION_ID); same(pages, 2, "5 条按 2 条一页 ⇒ 首屏之后再翻 2 次(第 3 页只有 1 条,hasMore=false ⇒ 停)"); same(calls.length, 2, "翻页只打了 2 个请求(没有白打)"); same( queryOf(calls[0], "before"), dataset()[3].startedAt, `第 2 页的游标 = 上一页最早一轮的 startedAt(**原样回带**,不换算):${calls[0]}` ); same( queryOf(calls[1], "before"), dataset()[1].startedAt, `第 3 页的游标 = 第 2 页最早一轮的开始时刻(继续向更早推进):${calls[1]}` ); same(queryOf(calls[0], "limit"), String(TIMELINE_PAGE_SIZE), "翻页请求照旧带 limit"); same(h.get().rounds.length, 5, "3 页合并后 5 个回合(一个不多一个不少)"); same(h.get().canLoadMore, false, "后端 hasMore=false ⇒ 停住(不再白打请求)"); const ids = h.get().rounds.map((r) => r.turnId); same(new Set(ids).size, ids.length, "合并后**没有重复**回合"); same( ids.join(","), dataset() .map((r) => r.turnId) .reverse() .join(","), "合并后**没有漏**回合,且仍是「最新在前」" ); same( h.get().rounds.reduce((n, r) => n + r.lanes.length, 0), 6, "每轮的**路数**照后端给(第 2 轮两路 ⇒ 合计 6 路,不把两路并成一轮)" ); same(h.get().rounds[3].lanes.length, 2, "真并行的那一轮,两路都在(不丢任一路)"); // 到底之后再翻一次:守卫必须挡住(零请求)。 const beforeGuard = calls.length; same(timelineCanLoadMore(h.get()), false, "到底之后守卫为假"); same(calls.length, beforeGuard, "守卫为假 ⇒ 不再发请求"); // ── ③ 零新增即停:坏后端(永远说 hasMore)也不许在同一个游标上打转 ─────────── installBackend({ kind: "stuck" }); calls.length = 0; const stuckHost = host(MAIN_SESSION_ID); await loadFirstPage(MAIN_SESSION_ID, stuckHost.send); same(stuckHost.get().canLoadMore, true, "坏后端说 hasMore ⇒ 首屏后仍以为自己能翻"); const stuckPages = await pageToEnd(stuckHost, MAIN_SESSION_ID); same(stuckPages, 1, "坏后端每次回**同一页**(零新增)⇒ 只翻一次就停(否则无限打转)"); same(calls.length, 2, "坏后端下总请求数 = 1(首屏)+ 1(唯一一次翻页)"); same(stuckHost.get().rounds.length, 2, "零新增的那一页不改变已拿到的回合"); // ── ④ 首屏该不该拉(`enabled` 门 + 已拉到数据不重复拉) ───────────────────── same(timelineNeedsFirstPage(emptyState(MAIN_SESSION_ID), MAIN_SESSION_ID), true, "没有数据 ⇒ 要拉"); same(timelineNeedsFirstPage(h.get(), MAIN_SESSION_ID), false, "已经拿到回合 ⇒ 切视图来回**不重复拉**"); same(timelineNeedsFirstPage(emptyState(""), MAIN_SESSION_ID), true, "会话变了(状态还是空的)⇒ 要拉"); same(timelineNeedsFirstPage(emptyState(MAIN_SESSION_ID), "other"), true, "换成别的会话 ⇒ 要拉"); same( timelineNeedsFirstPage(emptyState(MAIN_SESSION_ID), ""), false, "视图没切到时间轴(hook 把 target 置空)⇒ **绝不打后端**(按需取数)" ); same( timelineNeedsFirstPage({ ...h.get(), failed: true }, MAIN_SESSION_ID), true, "上次失败 ⇒ 允许再拉(重试入口靠它)" ); same( timelineNeedsFirstPage({ ...h.get(), unsupported: true }, MAIN_SESSION_ID), true, "上次判定老后端 ⇒ 允许再拉(后端升级后重试)" ); // ── ⑤ 老后端(404 非 JSON 正文)≠ 空态;500 = 这次没读到 ──────────────────── installError(404, null); calls.length = 0; const old721 = host(MAIN_SESSION_ID); await loadFirstPage(MAIN_SESSION_ID, old721.send); same(old721.get().unsupported, true, "404(路由不存在)⇒ unsupported(不是空态)"); same(old721.get().failed, false, "404 不当成「这次没读到」(两件事分开)"); same(old721.get().rounds.length, 0, "老后端下没有回合"); same(old721.get().loading, false, "失败后不卡在加载态"); same(calls.length, 1, "失败只打一次请求(不重试风暴)"); installError(500, { error: { code: "flow_unavailable", message: "boom" } }); const broke = host(MAIN_SESSION_ID); await loadFirstPage(MAIN_SESSION_ID, broke.send); same(broke.get().failed, true, "500 ⇒ failed(如实说读不到)"); same(broke.get().unsupported, false, "500 不当成老后端"); const hintsWhenFailed = timelineHints(broke.get()); ok( !hintsWhenFailed.expired && !hintsWhenFailed.hidden, "读不到时**不**标注「未显示 / 已过保留期」(那是另一件事)" ); // ── ⑥ 翻页失败:已拿到的回合照旧显示 ──────────────────────────────────────── installBackend(); const flaky = host(MAIN_SESSION_ID); await loadFirstPage(MAIN_SESSION_ID, flaky.send); const heldBefore = flaky.get().rounds.length; installError(500, { error: { code: "flow_unavailable", message: "boom" } }); await loadOlderPage(MAIN_SESSION_ID, timelineCursorOf(flaky.get()) ?? "", flaky.send); same(flaky.get().moreFailed, true, "翻页失败 ⇒ moreFailed(界面据此说「这次没读到」)"); same(flaky.get().rounds.length, heldBefore, "翻页失败**不吞掉**已拿到的回合"); same(flaky.get().loadingMore, false, "翻页失败后不卡在 loadingMore"); // ── ⑦ 只有最近一轮(落盘源未接线):`source:"live"` + 如实提示 ────────────── installBackend({ kind: "liveOnly" }); const liveOnly = host(MAIN_SESSION_ID); await loadFirstPage(MAIN_SESSION_ID, liveOnly.send); same(liveOnly.get().source, "live", "本页全部来自内存 ⇒ source=live"); same(liveOnly.get().rounds.length, 1, "只拿到最近一轮(1 个回合,不是 0)"); same(liveOnly.get().canLoadMore, false, "live 页没有更早的可拉"); const liveHints = timelineHints(liveOnly.get()); ok(liveHints.liveOnly, "「只有最近一轮」必须能被标出来"); ok(!liveHints.expired, "落盘源未接线 ≠「已过保留期」(不能说成过期)"); // ── ⑧ 🔴 「未显示」与「已过保留期」不许混(契约 §4.11.1(b).3) ────────────── // 真机上 truncated = hasMore || 落盘源未接线 ⇒ 一页**还有更早可拉**时 truncated 就是 true。 installBackend(); const paging = host(MAIN_SESSION_ID); await loadFirstPage(MAIN_SESSION_ID, paging.send); const pagingHints = timelineHints(paging.get()); ok(pagingHints.hidden, "还有更早的可拉 + truncated ⇒ 只能说「更早的轮次未显示」"); ok(!pagingHints.expired, "**绝不**对着一页仍可翻的历史说「已过保留期」(这就是核出的缺陷)"); await pageToEnd(paging, MAIN_SESSION_ID); const endedHints = timelineHints(paging.get()); same(paging.get().truncated, false, "翻到底后 `truncated` 逐页覆盖成 false(不累积成「历史上曾经缺过」)"); ok(!endedHints.expired, "翻到底后**不再**说「已过保留期」(后端压根没给过清扫信号)"); ok(!endedHints.hidden, "翻到底后也不再挂「更早的轮次未显示」——手上已经是保留期内的全部"); // ── ⑨ 网络失败:仍然只是「这次没读到」(不伪装成空态 / 老后端) ───────────── globalThis.fetch = (() => Promise.reject(new Error("offline"))) as unknown as typeof globalThis.fetch; const offline = host(MAIN_SESSION_ID); await loadFirstPage(MAIN_SESSION_ID, offline.send); same(offline.get().failed, true, "网络失败 ⇒ failed(不伪装成空态 / 老后端)"); globalThis.fetch = originalFetch; globalThis.localStorage = originalLocalStorage; console.log(`use flow timeline test: ${checks} checks passed`);