- 类型补上后端真在返回的 source/nextBefore/hasMore/retentionEnabled(此前类型里没有) - 新增纯函数 pageHasOlder(hasMore 权威,老后端退满页启发式)/timelineCursorOf/retentionClaimable/ timelineHints;取数体抽成可执行导出函数 ⇒ effect 本体首次被真跑(真 fetch + 真状态机 + 真 SSR) - 🔴 修一处成对契约缺陷:后端 truncated = hasMore || !storeWired,而契约/设计要求 truncated ⇒ 「已过保留期」⇒ 旧前端会对主人说假话。现改为:只有「已翻到底 + 保留期启用 + 非 live-only」 才说「已过保留期」,其余说「更早的轮次未显示」 - 3 个变异自检(去掉 canLoadMore 短路/恒可翻页/忽略 hasMore) ⚠️ 未验(如实):鉴权后的真机 HTTP 往返拿不到 owner 凭据;前端产物未部署(面板仍是旧文案)
263 lines
11 KiB
TypeScript
263 lines
11 KiB
TypeScript
// 「真取数 → 真状态 → 真渲染」的**完整回路**测试(切片 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<T>(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`);
|
||
})();
|