Files
Cyrene_v2/frontend/web/src/admin/modules/flow/useFlowTimeline.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

380 lines
17 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.
// 「链路时间轴取数(`useFlowTimeline`)」的**真跑**测试(切片 V2;契约 v0.80 §4.11.1)。
//
// 为什么需要它:W2 片自认的最大缺口是「`useFlowTimeline` 的 effect 本体**没有任何可执行
// 测试**」(本项目没有 jsdom、没有测试运行器 ⇒ hook 与 effect 跑不了)。本测试把取数体
// 从 hook 里抽成两个**导出**的异步函数(`loadFirstPage` / `loadOlderPage`)与两条导出守卫
// (`timelineNeedsFirstPage` / `timelineCanLoadMore`),于是可以在 node 里**真跑**:
//
// 真的 api 客户端(`adminFlowTimeline` → `request` → `fetch`)
// + 一个**照契约实现**的假后端(按 `before` 严格更早、`hasMore`、`nextBefore`、`truncated`)
// + 真的状态机函数与真的纯函数(归一/去重/游标)
// ⇒ 断言「分页**不重不漏**、到底即停、零新增即停、失败不吞已拿到的、老后端 ≠ 空态」。
//
// 跑法(与 `flowTimeline.test.ts` 同一手法:esbuild + node,无依赖):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/flow/useFlowTimeline.test.ts \
// --bundle --platform=node --format=esm \
// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \
// --outfile=/tmp/cyrene-use-flow-timeline.test.mjs \
// && node /tmp/cyrene-use-flow-timeline.test.mjs
//
// `tsc --noEmit` 会对本文件做类型检查,因此它不 import `node:*`(用 globalThis 取 fetch)。
import { TIMELINE_PAGE_SIZE, timelineCursorOf, timelineHints } from "./flowTimeline";
import {
emptyState,
loadFirstPage,
loadOlderPage,
timelineCanLoadMore,
timelineNeedsFirstPage,
} from "./useFlowTimeline";
import type { Send, TimelineData } from "./useFlowTimeline";
import { MAIN_SESSION_ID } from "../../../store/sessionCache";
import type { FlowGraph } from "../../../lib/types";
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)}`);
}
const originalFetch = globalThis.fetch;
const originalLocalStorage = globalThis.localStorage;
/** api 客户端的 `request` 会读 `localStorage` 取 token;node 下没有它。 */
globalThis.localStorage = {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
} as unknown as Storage;
// ── 假后端:**照契约**实现时间轴出口(契约 v0.80 §4.11.1) ─────────────────────
//
// 数据:5 个回合(时间正序),其中第 2 轮有**两路**(真并行),因此总路数 6。
// 页大小独立于 `limit`(设 2 ⇒ 等价于设计 §7.3 判据 4 的「`limit=2` 连续翻页」)。
// `hasMore` / `nextBefore` / `truncated` 一律按后端口径算,不自作主张。
interface WireLane {
turnId: string;
graph: FlowGraph;
}
interface WireRound {
turnId: string;
round: number;
startedAt: string;
endedAt: string;
lanes: WireLane[];
}
function wireGraph(id: string, turnId: string, round: number, startedAt: string): FlowGraph {
return {
id,
sessionId: MAIN_SESSION_ID,
turnId,
round,
startedAt,
endedAt: startedAt,
nodes: [
{ id: "input", label: `${turnId}输入`, kind: "input", status: "done" },
{ id: "out", label: `${turnId}回复`, kind: "output", status: "done" },
],
edges: [{ from: "input", to: "out" }],
};
}
/** 5 个回合:`web-1#turn-a` … `web-5#turn-e`(第 2 轮两路)。 */
function dataset(): WireRound[] {
const marks = [
["turn-a", "2026-10-06T02:00:00.000Z"],
["turn-b", "2026-10-06T02:05:00.000Z"],
["turn-c", "2026-10-06T02:09:00.000Z"],
["turn-d", "2026-10-06T03:00:00.000Z"],
["turn-e", "2026-10-06T03:30:00.000Z"],
];
return marks.map(([turnId, startedAt], i) => {
const round = i + 1;
const lanes: WireLane[] = [
{ turnId, graph: wireGraph(`web-${round}#${turnId}`, turnId, round, startedAt) },
];
if (round === 2) {
lanes.push({
turnId: `${turnId}#leg2`,
graph: wireGraph(`web-2#${turnId}#leg2`, `${turnId}#leg2`, 2, startedAt),
});
}
return { turnId: `web-${round}#${turnId}`, round, startedAt, endedAt: startedAt, lanes };
});
}
/** 假后端的可调档位(默认 = 正常后端)。 */
interface ServerMode {
/** 一页几条(独立于 `limit`,用来把「`limit=2` 翻页」这条判据跑出来)。 */
pageSize: number;
/** `persisted` = 正常;`liveOnly` = 落盘源未接线(只回内存最近一轮);`stuck` = 永远说还有更早。 */
kind: "persisted" | "liveOnly" | "stuck";
}
const calls: string[] = [];
/** 真跑 `fetch`:把假后端接在 `globalThis.fetch` 上(URL 逐条记下,供形状断言)。
*
* 语义**照契约**(§4.11.1):`before` = 开始时刻**严格早于**它;`hasMore` = 库里还有更早的;
* `nextBefore` = 本页最早一轮的开始时刻;`truncated` = `hasMore || 落盘源未接线`。
* `stuck` 档故意**忽略游标**并永远说 `hasMore`(模拟坏后端),用来钉住「零新增即停」。 */
function installBackend(mode: Partial<ServerMode> = {}): ServerMode {
const cfg: ServerMode = { pageSize: 2, kind: "persisted", ...mode };
globalThis.fetch = ((url: string) => {
calls.push(String(url));
const q = new URLSearchParams(String(url).split("?")[1] ?? "");
const before = q.get("before") ?? "";
const all = dataset();
let pool = all;
if (cfg.kind === "liveOnly") {
pool = [];
} else if (before && cfg.kind !== "stuck") {
pool = all.filter((r) => r.startedAt < before);
}
const page = pool.slice(-cfg.pageSize);
const hasMore = cfg.kind === "stuck" ? pool.length > 0 : pool.length > page.length;
const payload =
cfg.kind === "liveOnly"
? {
sessionId: MAIN_SESSION_ID,
source: "live",
rounds: [all[all.length - 1]],
nextBefore: "",
hasMore: false,
truncated: true,
retentionHours: 168,
retentionEnabled: true,
}
: {
sessionId: MAIN_SESSION_ID,
source: page.length > 0 ? "persisted" : "none",
rounds: page,
nextBefore: page.length > 0 ? page[0].startedAt : "",
hasMore,
truncated: hasMore,
retentionHours: 168,
retentionEnabled: true,
};
return Promise.resolve({
ok: true,
status: 200,
headers: { get: () => "application/json" },
json: () => Promise.resolve({ data: payload }),
});
}) as unknown as typeof globalThis.fetch;
return cfg;
}
/** 从记下的 URL 里取某个查询参数(客户端发的是 `URLSearchParams` 编码过的,这里解回来)。 */
function queryOf(url: string, key: string): string | null {
return new URLSearchParams(url.split("?")[1] ?? "").get(key);
}
/** 一个只回错误的假后端(`body === null` = 正文不是 JSON,真后端 404 就是这种)。 */
function installError(status: number, body: unknown): void {
globalThis.fetch = ((url: string) => {
calls.push(String(url));
return Promise.resolve({
ok: false,
status,
headers: { get: () => "text/plain" },
json: () => (body === null ? Promise.reject(new Error("not json")) : Promise.resolve(body)),
});
}) as unknown as typeof globalThis.fetch;
}
/** 测试里的「状态机宿主」:把 `Send` 补丁真的落进一份状态(等价于 hook 的 setState)。 */
interface Host {
get: () => TimelineData;
send: Send;
}
function host(sessionId: string): Host {
let state = emptyState(sessionId);
const send: Send = (patch) => {
state = patch(state);
};
return { get: () => state, send };
}
/** 按 hook 的取法翻到底(`loadMore` 的守卫 + `timelineCursorOf` 的游标),返回翻页次数。 */
async function pageToEnd(h: Host, sid: string, cap = 10): Promise<number> {
let pages = 0;
while (timelineCanLoadMore(h.get()) && pages < cap) {
pages += 1;
const cursor = timelineCursorOf(h.get());
if (!cursor) break;
await loadOlderPage(sid, cursor, h.send);
}
return pages;
}
// ── ① 首屏:一个请求、URL 形状正确、状态照后端字段落定 ──────────────────────
installBackend();
calls.length = 0;
const h = host(MAIN_SESSION_ID);
await loadFirstPage(MAIN_SESSION_ID, h.send);
same(calls.length, 1, "首屏恰好一个请求");
ok(
calls[0].endsWith(`/api/v1/admin/flow/timeline?sessionId=${MAIN_SESSION_ID}&limit=${TIMELINE_PAGE_SIZE}`),
`首屏请求形状(会话 + limit):${calls[0]}`
);
same(h.get().rounds.length, 2, "假后端一页 2 条 ⇒ 状态里 2 个回合");
same(h.get().rounds[0].turnId, "web-5#turn-e", "状态里**最新在前**(渲染顺序与分页游标解耦)");
same(h.get().canLoadMore, true, "后端说 hasMore ⇒ 还能继续翻");
same(h.get().truncated, true, "后端说 truncated ⇒ 原样落进状态(取数层不替它解释)");
same(h.get().retentionHours, 168, "保留期原样落进状态");
same(h.get().retentionEnabled, true, "保留期启用标记原样落进状态");
same(h.get().source, "persisted", "数据源原样落进状态");
same(h.get().loading, false, "拿到首屏后不再是加载态");
same(h.get().failed, false, "首屏成功 ⇒ 没有失败标记");
// ── ② 分页收敛(`limit=2` 口径):不重不漏 + 到底即停 ───────────────────────
calls.length = 0;
const pages = await pageToEnd(h, MAIN_SESSION_ID);
same(pages, 2, "5 条按 2 条一页 ⇒ 首屏之后再翻 2 次(第 3 页只有 1 条,hasMore=false ⇒ 停)");
same(calls.length, 2, "翻页只打了 2 个请求(没有白打)");
same(
queryOf(calls[0], "before"),
dataset()[3].startedAt,
`第 2 页的游标 = 上一页最早一轮的 startedAt(**原样回带**,不换算):${calls[0]}`
);
same(
queryOf(calls[1], "before"),
dataset()[1].startedAt,
`第 3 页的游标 = 第 2 页最早一轮的开始时刻(继续向更早推进):${calls[1]}`
);
same(queryOf(calls[0], "limit"), String(TIMELINE_PAGE_SIZE), "翻页请求照旧带 limit");
same(h.get().rounds.length, 5, "3 页合并后 5 个回合(一个不多一个不少)");
same(h.get().canLoadMore, false, "后端 hasMore=false ⇒ 停住(不再白打请求)");
const ids = h.get().rounds.map((r) => r.turnId);
same(new Set(ids).size, ids.length, "合并后**没有重复**回合");
same(
ids.join(","),
dataset()
.map((r) => r.turnId)
.reverse()
.join(","),
"合并后**没有漏**回合,且仍是「最新在前」"
);
same(
h.get().rounds.reduce((n, r) => n + r.lanes.length, 0),
6,
"每轮的**路数**照后端给(第 2 轮两路 ⇒ 合计 6 路,不把两路并成一轮)"
);
same(h.get().rounds[3].lanes.length, 2, "真并行的那一轮,两路都在(不丢任一路)");
// 到底之后再翻一次:守卫必须挡住(零请求)。
const beforeGuard = calls.length;
same(timelineCanLoadMore(h.get()), false, "到底之后守卫为假");
same(calls.length, beforeGuard, "守卫为假 ⇒ 不再发请求");
// ── ③ 零新增即停:坏后端(永远说 hasMore)也不许在同一个游标上打转 ───────────
installBackend({ kind: "stuck" });
calls.length = 0;
const stuckHost = host(MAIN_SESSION_ID);
await loadFirstPage(MAIN_SESSION_ID, stuckHost.send);
same(stuckHost.get().canLoadMore, true, "坏后端说 hasMore ⇒ 首屏后仍以为自己能翻");
const stuckPages = await pageToEnd(stuckHost, MAIN_SESSION_ID);
same(stuckPages, 1, "坏后端每次回**同一页**(零新增)⇒ 只翻一次就停(否则无限打转)");
same(calls.length, 2, "坏后端下总请求数 = 1(首屏)+ 1(唯一一次翻页)");
same(stuckHost.get().rounds.length, 2, "零新增的那一页不改变已拿到的回合");
// ── ④ 首屏该不该拉(`enabled` 门 + 已拉到数据不重复拉) ─────────────────────
same(timelineNeedsFirstPage(emptyState(MAIN_SESSION_ID), MAIN_SESSION_ID), true, "没有数据 ⇒ 要拉");
same(timelineNeedsFirstPage(h.get(), MAIN_SESSION_ID), false, "已经拿到回合 ⇒ 切视图来回**不重复拉**");
same(timelineNeedsFirstPage(emptyState(""), MAIN_SESSION_ID), true, "会话变了(状态还是空的)⇒ 要拉");
same(timelineNeedsFirstPage(emptyState(MAIN_SESSION_ID), "other"), true, "换成别的会话 ⇒ 要拉");
same(
timelineNeedsFirstPage(emptyState(MAIN_SESSION_ID), ""),
false,
"视图没切到时间轴(hook 把 target 置空)⇒ **绝不打后端**(按需取数)"
);
same(
timelineNeedsFirstPage({ ...h.get(), failed: true }, MAIN_SESSION_ID),
true,
"上次失败 ⇒ 允许再拉(重试入口靠它)"
);
same(
timelineNeedsFirstPage({ ...h.get(), unsupported: true }, MAIN_SESSION_ID),
true,
"上次判定老后端 ⇒ 允许再拉(后端升级后重试)"
);
// ── ⑤ 老后端(404 非 JSON 正文)≠ 空态;500 = 这次没读到 ────────────────────
installError(404, null);
calls.length = 0;
const old721 = host(MAIN_SESSION_ID);
await loadFirstPage(MAIN_SESSION_ID, old721.send);
same(old721.get().unsupported, true, "404(路由不存在)⇒ unsupported(不是空态)");
same(old721.get().failed, false, "404 不当成「这次没读到」(两件事分开)");
same(old721.get().rounds.length, 0, "老后端下没有回合");
same(old721.get().loading, false, "失败后不卡在加载态");
same(calls.length, 1, "失败只打一次请求(不重试风暴)");
installError(500, { error: { code: "flow_unavailable", message: "boom" } });
const broke = host(MAIN_SESSION_ID);
await loadFirstPage(MAIN_SESSION_ID, broke.send);
same(broke.get().failed, true, "500 ⇒ failed(如实说读不到)");
same(broke.get().unsupported, false, "500 不当成老后端");
const hintsWhenFailed = timelineHints(broke.get());
ok(
!hintsWhenFailed.expired && !hintsWhenFailed.hidden,
"读不到时**不**标注「未显示 / 已过保留期」(那是另一件事)"
);
// ── ⑥ 翻页失败:已拿到的回合照旧显示 ────────────────────────────────────────
installBackend();
const flaky = host(MAIN_SESSION_ID);
await loadFirstPage(MAIN_SESSION_ID, flaky.send);
const heldBefore = flaky.get().rounds.length;
installError(500, { error: { code: "flow_unavailable", message: "boom" } });
await loadOlderPage(MAIN_SESSION_ID, timelineCursorOf(flaky.get()) ?? "", flaky.send);
same(flaky.get().moreFailed, true, "翻页失败 ⇒ moreFailed(界面据此说「这次没读到」)");
same(flaky.get().rounds.length, heldBefore, "翻页失败**不吞掉**已拿到的回合");
same(flaky.get().loadingMore, false, "翻页失败后不卡在 loadingMore");
// ── ⑦ 只有最近一轮(落盘源未接线):`source:"live"` + 如实提示 ──────────────
installBackend({ kind: "liveOnly" });
const liveOnly = host(MAIN_SESSION_ID);
await loadFirstPage(MAIN_SESSION_ID, liveOnly.send);
same(liveOnly.get().source, "live", "本页全部来自内存 ⇒ source=live");
same(liveOnly.get().rounds.length, 1, "只拿到最近一轮(1 个回合,不是 0)");
same(liveOnly.get().canLoadMore, false, "live 页没有更早的可拉");
const liveHints = timelineHints(liveOnly.get());
ok(liveHints.liveOnly, "「只有最近一轮」必须能被标出来");
ok(!liveHints.expired, "落盘源未接线 ≠「已过保留期」(不能说成过期)");
// ── ⑧ 🔴 「未显示」与「已过保留期」不许混(契约 §4.11.1(b).3) ──────────────
// 真机上 truncated = hasMore || 落盘源未接线 ⇒ 一页**还有更早可拉**时 truncated 就是 true。
installBackend();
const paging = host(MAIN_SESSION_ID);
await loadFirstPage(MAIN_SESSION_ID, paging.send);
const pagingHints = timelineHints(paging.get());
ok(pagingHints.hidden, "还有更早的可拉 + truncated ⇒ 只能说「更早的轮次未显示」");
ok(!pagingHints.expired, "**绝不**对着一页仍可翻的历史说「已过保留期」(这就是核出的缺陷)");
await pageToEnd(paging, MAIN_SESSION_ID);
const endedHints = timelineHints(paging.get());
same(paging.get().truncated, false, "翻到底后 `truncated` 逐页覆盖成 false(不累积成「历史上曾经缺过」)");
ok(!endedHints.expired, "翻到底后**不再**说「已过保留期」(后端压根没给过清扫信号)");
ok(!endedHints.hidden, "翻到底后也不再挂「更早的轮次未显示」——手上已经是保留期内的全部");
// ── ⑨ 网络失败:仍然只是「这次没读到」(不伪装成空态 / 老后端) ─────────────
globalThis.fetch = (() => Promise.reject(new Error("offline"))) as unknown as typeof globalThis.fetch;
const offline = host(MAIN_SESSION_ID);
await loadFirstPage(MAIN_SESSION_ID, offline.send);
same(offline.get().failed, true, "网络失败 ⇒ failed(不伪装成空态 / 老后端)");
globalThis.fetch = originalFetch;
globalThis.localStorage = originalLocalStorage;
console.log(`use flow timeline test: ${checks} checks passed`);