// 「真取数 → 真状态 → 真渲染」的**完整回路**测试(切片 V2;契约 v0.80 §4.11.1)。 // // 为什么需要它:本项目没有 jsdom ⇒ 既跑不了 hook,也点不了界面。W2 的渲染测试是**替身级** // (手写 state 当 props),取数测试此前根本不存在。本文件把两头接起来: // // 真 `fetch`(照契约的假后端:`before` 严格更早 / `hasMore` / `nextBefore` / `truncated`) // → 真的取数状态机(`loadFirstPage` / `loadOlderPage`) // → 真的状态映射(`flowTimelineViewState`,就是 hook 返回的那个) // → 真的视图(`FlowTimelineView` 的 SSR 输出) // ⇒ 对**三种真实状态**逐条断言界面说了什么、**没说**什么: // ① 有数据(多轮 + 分页到底)② 只有最近一轮(落盘源未接线)③ 读取失败(500) // ④ 附:老后端(404)—— 与 ③ 是两件事,必须分开说。 // // 跑法(渲染类必须 `--format=cjs`;异步用 IIFE,抛错 ⇒ unhandled rejection ⇒ 非零退出): // // cd frontend/web \ // && ./node_modules/.bin/esbuild src/admin/modules/flow/flowTimelineWiring.render.test.ts \ // --bundle --platform=node --format=cjs \ // --outfile=/tmp/cyrene-flow-timeline-wiring.render.test.cjs \ // && node /tmp/cyrene-flow-timeline-wiring.render.test.cjs import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import type { FlowGraph } from "../../../lib/types"; import { MAIN_SESSION_ID } from "../../../store/sessionCache"; import { FlowBody } from "../Flow"; import { type TimelineBinding } from "./TimelineView"; import { emptyState, flowTimelineViewState, loadFirstPage, loadOlderPage, type Send, type TimelineData, } from "./useFlowTimeline"; import { timelineCursorOf } 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)}`); } function has(haystack: string, needle: string): boolean { return haystack.includes(needle); } const originalLocalStorage = globalThis.localStorage; globalThis.localStorage = { getItem: () => null, setItem: () => {}, removeItem: () => {}, } as unknown as Storage; // ── 照契约的假后端(同 `useFlowTimeline.test.ts` 的语义,这里只留渲染回路需要的档位) ── const STAMPS = [ "2026-10-06T02:00:00.000Z", "2026-10-06T02:05:00.000Z", "2026-10-06T02:09:00.000Z", ]; /** 3 个回合(第 2 轮两路);页大小 2 ⇒ 走一次翻页。 */ function wireRounds(): unknown[] { return STAMPS.map((startedAt, i) => { const round = i + 1; const turnId = `web-${round}#turn-${round}`; const graph: FlowGraph = { id: turnId, sessionId: MAIN_SESSION_ID, turnId, round, startedAt, nodes: [ { id: "input", label: `哨兵${round}输入`, kind: "input", status: "done" }, { id: "out", label: `哨兵${round}回复`, kind: "output", status: "done" }, ], edges: [{ from: "input", to: "out" }], }; const lanes = [{ turnId, graph }]; if (round === 2) lanes.push({ turnId: `${turnId}#leg2`, graph: { ...graph, id: `${turnId}#leg2` } }); return { turnId, round, startedAt, endedAt: startedAt, lanes }; }); } type Mode = "persisted" | "liveOnly" | "failed" | "oldBackend"; function installBackend(mode: Mode): void { globalThis.fetch = ((url: string) => { if (mode === "failed") { return Promise.resolve({ ok: false, status: 500, headers: { get: () => "application/json" }, json: () => Promise.resolve({ error: { code: "flow_unavailable", message: "boom" } }), }); } if (mode === "oldBackend") { return Promise.resolve({ ok: false, status: 404, headers: { get: () => "text/plain" }, json: () => Promise.reject(new Error("not json")), }); } const all = wireRounds(); const q = new URLSearchParams(String(url).split("?")[1] ?? ""); const before = q.get("before") ?? ""; if (mode === "liveOnly") { return Promise.resolve({ ok: true, status: 200, headers: { get: () => "application/json" }, json: () => Promise.resolve({ data: { sessionId: MAIN_SESSION_ID, source: "live", rounds: [all[all.length - 1]], nextBefore: "", hasMore: false, truncated: true, retentionHours: 168, retentionEnabled: true, }, }), }); } const pool = before ? all.filter((r) => (r as { startedAt: string }).startedAt < before) : all; const page = pool.slice(-2); const hasMore = pool.length > page.length; return Promise.resolve({ ok: true, status: 200, headers: { get: () => "application/json" }, json: () => Promise.resolve({ data: { sessionId: MAIN_SESSION_ID, source: page.length > 0 ? "persisted" : "none", rounds: page, nextBefore: (page[0] as { startedAt: string } | undefined)?.startedAt ?? "", hasMore, truncated: hasMore, retentionHours: 168, retentionEnabled: true, }, }), }); }) as unknown as typeof globalThis.fetch; } /** 状态机宿主(等价于 hook 的 setState)。 */ interface Host { get: () => TimelineData; send: Send; } function host(): Host { let state = emptyState(MAIN_SESSION_ID); const send: Send = (patch) => { state = patch(state); }; return { get: () => state, send }; } /** 把真取数状态按 **hook 自己的映射** 翻成视图状态,再经**真正的 Flow 页视图**(`FlowBody` * 的 `view="timeline"`,即面板上「时间轴」页签选中时的形态)SSR 成 HTML。 */ function render(h: Host): string { const state = flowTimelineViewState(h.get(), () => {}, () => {}); return renderToStaticMarkup( createElement(FlowBody, { graph: { id: "web-88", sessionId: MAIN_SESSION_ID, nodes: [{ id: "input", label: "最近一轮哨兵", kind: "input", status: "done" }], edges: [], }, sessionId: MAIN_SESSION_ID, source: "live", view: "timeline", onSelectView: () => {}, timeline: state, timelineBinding: { order: "newest-first", onToggleOrder: () => {}, expanded: {}, onToggleRound: () => {}, onReload: () => {}, } satisfies TimelineBinding, }) ); } void (async () => { // ── ① 有数据:真 fetch 翻到底 ⇒ 三块 + 保留期口径,且**没有**任何「过期」假话 ── installBackend("persisted"); const filled = host(); await loadFirstPage(MAIN_SESSION_ID, filled.send); same(filled.get().rounds.length, 2, "第一页 2 个回合"); await loadOlderPage(MAIN_SESSION_ID, timelineCursorOf(filled.get()) ?? "", filled.send); same(filled.get().rounds.length, 3, "翻一次后 3 个回合(三页数据两页取完,不重不漏)"); same(filled.get().canLoadMore, false, "翻到底 ⇒ 不再有更早的"); const filledHtml = render(filled); same( (filledHtml.match(/data-flow-timeline-round="/g) ?? []).length, 3, "三个回合 ⇒ 真渲染出三个块(数量与取数结果一致)" ); ok(has(filledHtml, "共 3 个回合"), "页首的回合数来自真取数结果"); ok(has(filledHtml, "保留期 168 小时"), "页首如实转达后端给的保留期"); ok(!has(filledHtml, "更早的轮次已过保留期"), "翻到底 + 后端没给清扫信号 ⇒ **不**说「已过保留期」"); ok(!has(filledHtml, "更早的轮次未显示"), "翻到底 ⇒ 也不挂「未显示」"); ok(has(filledHtml, "没有更早的回合了"), "翻到底 ⇒ 如实说没有更早的(不是静默消失)"); ok(has(filledHtml, "2 路 · "), "真并行的那一轮两路都在(块标题如实报路数)"); ok(has(filledHtml, "视图"), "页面上有视图切换页签(切到时间轴这件事在界面上是有出口的)"); ok(!has(filledHtml, "最近一轮哨兵"), "时间轴视图**不**画「最近一轮」的图(两套不混)"); // ── ② 只有最近一轮(落盘源未接线):说「只拿到最近一轮」,**不等于**空态 ── installBackend("liveOnly"); const live = host(); await loadFirstPage(MAIN_SESSION_ID, live.send); same(live.get().source, "live", "真取数状态标出来源是内存(source=live)"); const liveHtml = render(live); same( (liveHtml.match(/data-flow-timeline-round="/g) ?? []).length, 1, "只拿到最近一轮 ⇒ 仍然画出那一块(不是空态)" ); ok(has(liveHtml, "只拿到内存里的最近一轮"), "如实说明「只有内存里这一轮」(落盘源未接线)"); ok(!has(liveHtml, "保留期内没有可回看的回合"), "「只有最近一轮」**不是**「没有回合」"); ok(!has(liveHtml, "更早的轮次已过保留期"), "落盘源未接线也**不**能说成过期"); // ── ③ 读取失败(500):如实说读不到 + 给重试,不落进空态文案 ────────── installBackend("failed"); const broke = host(); await loadFirstPage(MAIN_SESSION_ID, broke.send); same(broke.get().failed, true, "真取数状态是 failed"); const failedHtml = render(broke); ok(has(failedHtml, "暂时读不到"), "读取失败 ⇒ 如实说「暂时读不到」"); ok(has(failedHtml, "重试"), "读取失败 ⇒ 给重试入口"); ok(!has(failedHtml, "保留期内没有可回看的回合"), "读取失败 ≠「没有回合」(两件事不许说成一件)"); ok(!has(failedHtml, "当前只支持最近一轮"), "读取失败 ≠ 老后端(再说一遍也不能混)"); // ── ④ 老后端(404):与「读取失败」分开说 ───────────────────────────── installBackend("oldBackend"); const old = host(); await loadFirstPage(MAIN_SESSION_ID, old.send); same(old.get().unsupported, true, "真取数状态是 unsupported"); const oldHtml = render(old); ok(has(oldHtml, "当前只支持最近一轮"), "老后端 ⇒ 明确说「当前只支持最近一轮」"); ok(!has(oldHtml, "暂时读不到"), "老后端 ≠ 读取失败"); globalThis.localStorage = originalLocalStorage; console.log(`flow timeline wiring test: ${checks} checks passed`); })();