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 凭据;前端产物未部署(面板仍是旧文案)
This commit is contained in:
AskaEth committed 2026-10-06 20:25:06 +08:00
1 parent ed5005307a
commit 745365c7df
8 files changed
+1047 -99

No files matched your search

@@ -0,0 +1,262 @@
// 「真取数 → 真状态 → 真渲染」的**完整回路**测试(切片 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`);
})();