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,379 @@
// 「链路时间轴取数(`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`);