Files
Cyrene_v2/frontend/web/src/admin/modules/flow/flowTimelineWiring.render.test.ts
T
AskaEth 745365c7df fix(admin): 时间轴分页与保留期文案按契约收紧(V2)
- 类型补上后端真在返回的 source/nextBefore/hasMore/retentionEnabled(此前类型里没有)
- 新增纯函数 pageHasOlder(hasMore 权威,老后端退满页启发式)/timelineCursorOf/retentionClaimable/
  timelineHints;取数体抽成可执行导出函数 ⇒ effect 本体首次被真跑(真 fetch + 真状态机 + 真 SSR)
- 🔴 修一处成对契约缺陷:后端 truncated = hasMore || !storeWired,而契约/设计要求 truncated ⇒
  「已过保留期」⇒ 旧前端会对主人说假话。现改为:只有「已翻到底 + 保留期启用 + 非 live-only」
  才说「已过保留期」,其余说「更早的轮次未显示」
- 3 个变异自检(去掉 canLoadMore 短路/恒可翻页/忽略 hasMore)
⚠️ 未验(如实):鉴权后的真机 HTTP 往返拿不到 owner 凭据;前端产物未部署(面板仍是旧文案)
2026-10-06 20:25:06 +08:00

263 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 「真取数 → 真状态 → 真渲染」的**完整回路**测试(切片 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`);
})();