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