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:
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`);
|
||||
})();
|
||||
Reference in new issue
Block a user