- 时间轴:页首「已过保留期」的**唯一**判据改为 retentionBoundaryReached === true(truncated 只出
「未显示」);缺席(老后端)一句话都不说;删掉旧的 truncated+retentionEnabled+翻到底 启发式
- Backthink:筛选轴接上服务端 ?trigger=(客户端筛选保留作兜底)+ 自排闸用量行(三态文案)
- plugin/nav.ts 加 browser 图标
- 真机(隔离实例,46/46):翻页不重不漏、三种「必须 false」的权威键语义、**重启后 round 不重复**
⚠️ 未验:真机上没构造出权威键 true(需真有更早轮次被清扫)——别读成「真机验过 true」
439 lines
23 KiB
TypeScript
439 lines
23 KiB
TypeScript
// 「链路时间轴」的**纯逻辑**测试(切片 W2;`design/19 §7`;W1 的新出口)。
|
||
//
|
||
// 为什么需要它:时间轴的四条硬口径全是**判定**而非渲染 —— ① 顺序与分页游标(`before`
|
||
// 恒向更早走,与展示顺序无关);② **不臆造关系**(去重只认后端给的回合标识,标识缺席
|
||
// 也不自己拼一个);③ 状态与文案必须把「无图 / 未收尾 / 完成」分开说;④ 老后端(404)
|
||
// 与「没有回合」必须分成两件事。⑤(v0.81 追补)**「已过保留期」只认权威键**
|
||
// `retentionBoundaryReached === true`:`truncated` / `retentionEnabled` / 「翻到底了没」
|
||
// 的任何组合都**不再是**判据,权威键缺席(老后端)⇒ 按最保守话术(一句也不说)。本项目
|
||
// 没有前端测试运行器,因此这是无依赖的纯 node 检查(与 `flowHistory.test.ts` /
|
||
// `flowSession.test.ts` 同一手法)。
|
||
//
|
||
// 跑法:
|
||
//
|
||
// cd frontend/web \
|
||
// && ./node_modules/.bin/esbuild src/admin/modules/flow/flowTimeline.test.ts \
|
||
// --bundle --platform=node --format=esm \
|
||
// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \
|
||
// --outfile=/tmp/cyrene-flow-timeline.test.mjs \
|
||
// && node /tmp/cyrene-flow-timeline.test.mjs
|
||
//
|
||
// `tsc --noEmit` 会对本文件做类型检查,因此它不 import `node:*`(用 globalThis 取 fetch)。
|
||
|
||
import { ApiError, adminFlowTimeline } from "../../../lib/api";
|
||
import type { AdminFlowTimelineRound } from "../../../lib/api";
|
||
import type { FlowGraph } from "../../../lib/types";
|
||
import { MAIN_SESSION_ID } from "../../../store/sessionCache";
|
||
import {
|
||
RETENTION_TRUNCATED_TEXT,
|
||
TIMELINE_PAGE_SIZE,
|
||
appendTimelinePage,
|
||
classifyTimelineFailure,
|
||
mergeTimelineRounds,
|
||
normalizeTimelineRounds,
|
||
orderRounds,
|
||
pageHasOlder,
|
||
pageMayHaveOlder,
|
||
retentionText,
|
||
roundExpanded,
|
||
roundGraphs,
|
||
roundLabel,
|
||
roundStats,
|
||
roundStatus,
|
||
roundStatusLabel,
|
||
roundStatusTag,
|
||
shortTurnId,
|
||
timelineCursor,
|
||
timelineCursorOf,
|
||
timelineHints,
|
||
timelinePageOf,
|
||
toggleRoundExpansion,
|
||
} from "./flowTimeline";
|
||
import type { TimelineHints } 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)}`);
|
||
}
|
||
|
||
/** 一路的图:一个 `input` 节点 + 若干指定状态的节点(哨兵用 label)。 */
|
||
function graph(id: string, statuses: ReadonlyArray<"running" | "done" | "error" | "blocked"> = ["done"]): FlowGraph {
|
||
return {
|
||
id,
|
||
sessionId: MAIN_SESSION_ID,
|
||
nodes: [
|
||
{ id: "input", label: "输入", kind: "input", status: "done" },
|
||
...statuses.map((s, i) => ({ id: `n${i}`, label: `节点${i}`, kind: "tool", status: s })),
|
||
],
|
||
edges: [{ from: "input", to: "n0" }],
|
||
};
|
||
}
|
||
|
||
/** 一个回合:`startedAt` 决定它在时间轴上的位置。 */
|
||
function round(
|
||
turnId: string,
|
||
startedAt: string,
|
||
opts: { round?: number | null; endedAt?: string | null; graphs?: ReadonlyArray<FlowGraph | null> } = {}
|
||
): AdminFlowTimelineRound {
|
||
const graphs = opts.graphs ?? [graph(`${turnId}-1`)];
|
||
return {
|
||
turnId,
|
||
round: opts.round === undefined ? 1 : opts.round,
|
||
startedAt,
|
||
endedAt: opts.endedAt === undefined ? "2026-10-06T10:00:10+08:00" : opts.endedAt,
|
||
lanes: graphs.map((g, i) => ({ turnId: `${turnId}#leg${i + 1}`, graph: g })),
|
||
};
|
||
}
|
||
|
||
// ── ① 归一:去重(只认后端给的标识)、按开始时间倒序 ─────────────────────────
|
||
const r1 = round("web-1#turn-a", "2026-10-06T10:00:00+08:00", { round: 1 });
|
||
const r2 = round("web-2#turn-b", "2026-10-06T10:05:00+08:00", { round: 2 });
|
||
const r3 = round("web-3#turn-c", "2026-10-06T10:09:00+08:00", { round: 3 });
|
||
// 后端按**时间正序**返回(设计 §7.1),面板必须自己摆成「最新在前」。
|
||
const normalized = normalizeTimelineRounds([r1, r2, r3]);
|
||
same(normalized.length, 3, "三个回合就是三条(不吞不造)");
|
||
same(normalized[0].turnId, "web-3#turn-c", "归一后最新在前");
|
||
same(normalized[2].turnId, "web-1#turn-a", "归一后最旧在最后");
|
||
same(
|
||
normalizeTimelineRounds([r1, r1, r2]).length,
|
||
2,
|
||
"同一个回合标识出现两次 ⇒ 只留一条(分页重叠不重)"
|
||
);
|
||
same(normalizeTimelineRounds(null).length, 0, "没有回合 ⇒ 空数组(不编造)");
|
||
same(normalizeTimelineRounds(undefined).length, 0, "响应缺 rounds 键 ⇒ 空数组");
|
||
|
||
// 标识缺席:按「开始时间 + 轮次」去重;既无标识又无时间 ⇒ **不去重**(无法判定是同一回合)。
|
||
const anonA: AdminFlowTimelineRound = { turnId: "", round: 7, startedAt: "2026-10-06T10:01:00+08:00", lanes: [] };
|
||
const anonB: AdminFlowTimelineRound = { turnId: "", round: 7, startedAt: "2026-10-06T10:01:00+08:00", lanes: [] };
|
||
same(normalizeTimelineRounds([anonA, anonB]).length, 1, "无标识但同时间同轮次 ⇒ 视作同一回合");
|
||
const noTimeA: AdminFlowTimelineRound = { turnId: "", round: null, startedAt: "", lanes: [] };
|
||
const noTimeB: AdminFlowTimelineRound = { turnId: "", round: null, startedAt: "坏时间", lanes: [] };
|
||
same(
|
||
normalizeTimelineRounds([noTimeA, noTimeB]).length,
|
||
2,
|
||
"既无标识又无可用时间 ⇒ 不去重(宁可多显示,也不吞掉一条)"
|
||
);
|
||
|
||
// ── ② 展示顺序:可切,且**不影响分页游标** ───────────────────────────────────
|
||
same(orderRounds(normalized, "newest-first")[0].turnId, "web-3#turn-c", "默认顺序 = 最新在上");
|
||
same(orderRounds(normalized, "oldest-first")[0].turnId, "web-1#turn-a", "切顺序 ⇒ 最旧在上");
|
||
same(timelineCursor(normalized), r1.startedAt, "游标 = 手上**最旧**那一回合的开始时间(原样回传)");
|
||
same(
|
||
timelineCursor(orderRounds(normalized, "oldest-first")),
|
||
r1.startedAt,
|
||
"切展示顺序不改变游标(分页恒向更早走)"
|
||
);
|
||
same(timelineCursor([]), null, "没有回合 ⇒ 没有游标(不发请求)");
|
||
same(
|
||
timelineCursor([noTimeA, noTimeB]),
|
||
null,
|
||
"最旧那一回合没有可用时间 ⇒ 游标为 null(绝不瞎凑一个游标)"
|
||
);
|
||
same(
|
||
timelineCursor([r1, { ...r2, startedAt: "不是时间" }]),
|
||
r1.startedAt,
|
||
"坏时间的那一条只被跳过,不吃掉能用的游标"
|
||
);
|
||
|
||
// ── ③ 分页:满页才可能还有更早的 ────────────────────────────────────────────
|
||
ok(pageMayHaveOlder(TIMELINE_PAGE_SIZE, TIMELINE_PAGE_SIZE), "满页 ⇒ 还可能更早");
|
||
ok(!pageMayHaveOlder(3, TIMELINE_PAGE_SIZE), "短页 ⇒ 到头了(否则会在同一游标上打转)");
|
||
ok(!pageMayHaveOlder(0, 0), "页大小非法 ⇒ 不宣称还有更早的");
|
||
|
||
const page1 = normalizeTimelineRounds([r2, r3]);
|
||
const merged = mergeTimelineRounds(page1, normalizeTimelineRounds([r1, r2]));
|
||
same(merged.length, 3, "翻页重叠 ⇒ 合并后不重(3 条不是 4 条)");
|
||
same(merged.map((r) => r.turnId).join(","), "web-3#turn-c,web-2#turn-b,web-1#turn-a", "合并后仍是最新在前");
|
||
|
||
// 追加一页的判定:满页 + 真有新回合才继续翻。
|
||
const r0 = round("web-0#turn-0", "2026-10-06T09:00:00+08:00", { round: 0 });
|
||
const fullPage = [r1, r0];
|
||
const appendFresh = appendTimelinePage([r2, r3], fullPage, 2);
|
||
same(appendFresh.added, 2, "追加一页:落在外面的都算新增");
|
||
same(appendFresh.rounds.length, 4, "追加后 4 条(去重不重)");
|
||
ok(appendFresh.canLoadMore, "满页 + 有新增 ⇒ 还能继续翻");
|
||
const appendStale = appendTimelinePage([r1, r2], [r1, r2], 2);
|
||
same(appendStale.added, 0, "整页都是已有的回合 ⇒ 新增 0 条");
|
||
ok(!appendStale.canLoadMore, "没有新增 ⇒ 停住(否则会在同一游标上打转)");
|
||
const shortPage = appendTimelinePage([r2, r3], [r1], 2);
|
||
same(shortPage.rounds.length, 3, "短页里的新回合照旧并入(不因为停翻就丢掉已拿到的)");
|
||
ok(!shortPage.canLoadMore, "短页(哪怕有新增)⇒ 仍到头了");
|
||
|
||
// ── ④ 短标识:只截断展示,不反解、不拼 id ────────────────────────────────────
|
||
same(shortTurnId("web-7#turn-3f9c8ab1c2d3"), "turn-3f9c8ab…", "带 # 的标识取 # 之后那段并截断");
|
||
same(shortTurnId("web-7"), "web-7", "老行没有 # ⇒ 原样(短就不用截断)");
|
||
same(shortTurnId(""), "无回合标识", "标识缺席 ⇒ 如实说没有标识(绝不现编一个)");
|
||
|
||
// ── ⑤ 轮次号:缺席 / null ⇒ 「轮次未知」,不得用 0 冒充 ──────────────────────
|
||
same(roundLabel(3), "第 3 轮", "有轮次号就报号");
|
||
same(roundLabel(0), "第 0 轮", "0 是后端真的给的号就照报(本切片不去猜它的含义)");
|
||
same(roundLabel(undefined), "轮次未知", "轮次号缺席 ⇒ 「轮次未知」");
|
||
same(roundLabel(null), "轮次未知", "轮次号为 null ⇒ 「轮次未知」");
|
||
|
||
// ── ⑥ 状态:运行 / 错误 / 阻塞优先;未收尾 ≠ 完成;无图 ≠ 空闲 ───────────────
|
||
same(roundStatus(round("a", "t", { graphs: [graph("g1", ["running", "done"])] })), "running", "有在跑的节点 ⇒ 运行中");
|
||
same(roundStatus(round("a", "t", { graphs: [graph("g1", ["done"]), graph("g2", ["error"])] })), "error", "一路出错 ⇒ 整回合错误(并行下不许说「完成」)");
|
||
same(roundStatus(round("a", "t", { graphs: [graph("g1", ["done"])] })), "done", "节点都完成且有收尾时间 ⇒ 完成");
|
||
same(
|
||
roundStatus(round("a", "t", { graphs: [graph("g1", ["done"])], endedAt: null })),
|
||
"unfinished",
|
||
"没有收尾时间 ⇒ 未收尾(不是「完成」,也不是「运行中」)"
|
||
);
|
||
same(
|
||
roundStatus(round("a", "t", { graphs: [graph("g1", ["done"])], endedAt: "" })),
|
||
"unfinished",
|
||
"收尾时间为空串 ⇒ 同样按未收尾(不把空当收尾过)"
|
||
);
|
||
same(
|
||
roundStatus(round("a", "t", { graphs: [null, null] })),
|
||
"none",
|
||
"一路图都没有 ⇒ 「无图」(不是「空闲」)"
|
||
);
|
||
same(roundStatus(round("a", "t", { graphs: [] })), "none", "没有路 ⇒ 「无图」");
|
||
same(roundStatus(null), "none", "没有这个回合 ⇒ 「无图」");
|
||
same(roundStatusLabel("none"), "无图", "「无图」的文案");
|
||
same(roundStatusLabel("unfinished"), "未收尾", "「未收尾」的文案");
|
||
same(roundStatusLabel("running"), "运行中", "运行中文案与既有泳道口径一致");
|
||
same(roundStatusTag("none"), "neutral", "「无图」不着色(不是告警)");
|
||
same(roundStatusTag("unfinished"), "warn", "「未收尾」着警示色");
|
||
|
||
// ── ⑦ 观测量:路数 / 有图的路数 / 节点数 / 缺席的路数 ───────────────────────
|
||
const mixed = round("a", "t", { graphs: [graph("g1", ["done", "done"]), null] });
|
||
const stats = roundStats(mixed);
|
||
same(stats.legs, 2, "路数按后端报的算(含没图的)");
|
||
same(stats.drawn, 1, "有图可画的只有一路");
|
||
same(stats.missing, 1, "缺席的路数必须能让用户看见");
|
||
same(stats.nodes, 3, "节点数按有图的那些算");
|
||
same(roundGraphs(mixed).length, 1, "取图只取真有的(null 不进画布)");
|
||
same(roundStats(null).legs, 0, "没有回合 ⇒ 全零(不编造)");
|
||
|
||
// ── ⑧ 折叠记忆:最新默认展开,用户点过以用户为准 ────────────────────────────
|
||
ok(roundExpanded("web-3#turn-c", {}, true), "没人点过时:最新那回合默认展开");
|
||
ok(!roundExpanded("web-1#turn-a", {}, false), "没人点过时:更早的回合默认折叠");
|
||
const toggled = toggleRoundExpansion({}, "web-1#turn-a", false);
|
||
ok(toggled["web-1#turn-a"] === true, "点一下 ⇒ 展开(并按用户的选择记住)");
|
||
ok(
|
||
roundExpanded("web-1#turn-a", toggled, true) === true,
|
||
"用户点开过的块保持开 —— 即使它成为「最新」也不被默认规则改写"
|
||
);
|
||
const newestCollapsed = toggleRoundExpansion({}, "web-3#turn-c", true);
|
||
ok(
|
||
roundExpanded("web-3#turn-c", newestCollapsed, true) === false,
|
||
"最新那回合被用户点收起 ⇒ 保持收起(用户的选择优先于「最新默认展开」)"
|
||
);
|
||
const toggledTwice = toggleRoundExpansion(toggled, "web-1#turn-a", false);
|
||
ok(toggledTwice["web-1#turn-a"] === false, "再点一下 ⇒ 收起");
|
||
same(toggleRoundExpansion({ web: true }, "", false).web, true, "标识为空时不动别人的状态(不写空键)");
|
||
|
||
// ── ⑨ 保留期文案:三支互不冒充 ───────────────────────────────────────────────
|
||
same(retentionText(168), "保留期 168 小时", "保留期如实报小时数");
|
||
ok(retentionText(0).includes("未启用"), "≤0 = 后端配置为不回收(既有口径)");
|
||
ok(retentionText(-1).includes("未启用"), "负值同样按「未启用」");
|
||
ok(retentionText(null).includes("未知"), "缺失 ⇒ 未知(不编一个数)");
|
||
ok(retentionText(undefined).includes("未知"), "undefined ⇒ 未知");
|
||
ok(RETENTION_TRUNCATED_TEXT.includes("保留期"), "过期提示必须是「已过保留期」这件事(不是「没有回合」)");
|
||
|
||
// ── ⑩ 失败分类:老后端(没这个出口)≠ 这次没读到 ──────────────────────────
|
||
same(classifyTimelineFailure(new ApiError(404, "not_found", "no route")), "unsupported", "404 ⇒ 老后端,当前只支持最近一轮");
|
||
same(classifyTimelineFailure(new ApiError(501, "not_implemented", "nope")), "unsupported", "501 同样算「没有这个出口」");
|
||
same(classifyTimelineFailure(new ApiError(500, "flow_unavailable", "boom")), "failed", "500 是「这次没读到」");
|
||
same(classifyTimelineFailure(new ApiError(0, "network_error", "offline")), "failed", "网络失败也是「这次没读到」");
|
||
same(classifyTimelineFailure(new Error("boom")), "failed", "非 ApiError ⇒ 按失败处理(不声称老后端)");
|
||
|
||
// ── ⑪ 响应解包:缺键按诚实缺省 ──────────────────────────────────────────────
|
||
const packed = timelinePageOf({
|
||
rounds: [r1],
|
||
retentionHours: 168,
|
||
truncated: true,
|
||
retentionBoundaryReached: true,
|
||
});
|
||
same(packed.rounds.length, 1, "解包保留回合");
|
||
same(packed.retentionHours, 168, "解包保留期");
|
||
same(packed.truncated, true, "解包截断标记");
|
||
same(packed.retentionBoundaryReached, true, "解包**权威键**(v0.81):不能丢、不能反相");
|
||
const packedEmpty = timelinePageOf(null);
|
||
same(packedEmpty.rounds.length, 0, "没有响应 ⇒ 空回合");
|
||
same(packedEmpty.retentionHours, null, "没有响应 ⇒ 保留期未知(不是 0)");
|
||
same(packedEmpty.truncated, false, "没有响应 ⇒ 不宣称「本次不是全部」");
|
||
same(packedEmpty.retentionBoundaryReached, null, "没有响应 ⇒ 权威键为**未知**(不冒充 false)");
|
||
// 老后端(v0.80 出口,没有这个键):必须落成 `null`(不知道),**不许**用别的字段回推。
|
||
const packedOld = timelinePageOf({ rounds: [r1], retentionHours: 168, truncated: true });
|
||
same(
|
||
packedOld.retentionBoundaryReached,
|
||
null,
|
||
"老后端没给权威键 ⇒ 解包成 null(不知道),**不**退回启发式"
|
||
);
|
||
|
||
// ── ⑫ 真跑 api 客户端:URL 形状 + 游标**原样**回传(不换算、不拼 id) ───────
|
||
type Call = { url: string; method: string };
|
||
const calls: Call[] = [];
|
||
const originalFetch = globalThis.fetch;
|
||
const originalLocalStorage = globalThis.localStorage;
|
||
|
||
/** api 客户端的 `request` 会读 `localStorage` 取 token;node 下没有它。 */
|
||
globalThis.localStorage = {
|
||
getItem: () => null,
|
||
setItem: () => {},
|
||
removeItem: () => {},
|
||
} as unknown as Storage;
|
||
|
||
globalThis.fetch = ((url: string, init?: { method?: string }) => {
|
||
calls.push({ url: String(url), method: init?.method ?? "GET" });
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: () =>
|
||
Promise.resolve({ data: { rounds: [r1], retentionHours: 168, truncated: false } }),
|
||
} as unknown as Response);
|
||
}) as typeof globalThis.fetch;
|
||
|
||
const resp = await adminFlowTimeline(MAIN_SESSION_ID, { limit: TIMELINE_PAGE_SIZE });
|
||
same(resp.rounds.length, 1, "真解析回包(`{data}` 信封)");
|
||
ok(
|
||
calls[0].url.endsWith("/admin/flow/timeline?sessionId=web&limit=50"),
|
||
`请求形状:${calls[0].url}`
|
||
);
|
||
same(calls[0].method, "GET", "时间轴取数是只读 GET");
|
||
await adminFlowTimeline(MAIN_SESSION_ID, { limit: TIMELINE_PAGE_SIZE, before: r1.startedAt });
|
||
ok(
|
||
decodeURIComponent(calls[1]?.url ?? "").includes(`before=${r1.startedAt}`),
|
||
`带游标时**原样**回传后端给的时间字符串(不换算成毫秒、不重排):${calls[1]?.url}`
|
||
);
|
||
|
||
/** 一个**非 2xx** 的桩:`body = null` 表示正文不是 JSON(真后端 404 就是这种:
|
||
* `HTTP 404 / 404 page not found`,实测于 2026-10-06 的 :10942)。 */
|
||
function stubError(status: number, body: unknown): void {
|
||
globalThis.fetch = ((url: string, init?: { method?: string }) => {
|
||
calls.push({ url: String(url), method: init?.method ?? "GET" });
|
||
return Promise.resolve({
|
||
ok: false,
|
||
status,
|
||
json: () => (body === null ? Promise.reject(new Error("not json")) : Promise.resolve(body)),
|
||
} as unknown as Response);
|
||
}) as typeof globalThis.fetch;
|
||
}
|
||
|
||
/** 真跑一遍「今天的老后端」那条路:路由不存在 ⇒ 404 + 非 JSON 正文。 */
|
||
async function classifyRealError(status: number, body: unknown): Promise<string> {
|
||
stubError(status, body);
|
||
try {
|
||
await adminFlowTimeline(MAIN_SESSION_ID, { limit: TIMELINE_PAGE_SIZE });
|
||
return "no-error";
|
||
} catch (err: unknown) {
|
||
return classifyTimelineFailure(err);
|
||
}
|
||
}
|
||
|
||
same(
|
||
await classifyRealError(404, null),
|
||
"unsupported",
|
||
"路由不存在(404 + `404 page not found` 非 JSON 正文)⇒ 落到「当前只支持最近一轮」那一支"
|
||
);
|
||
same(
|
||
await classifyRealError(404, { error: { code: "not_found", message: "no route" } }),
|
||
"unsupported",
|
||
"404 + 规范信封 ⇒ 同样按「没有这个出口」"
|
||
);
|
||
same(
|
||
await classifyRealError(500, { error: { code: "flow_unavailable", message: "boom" } }),
|
||
"failed",
|
||
"500 ⇒ 按「这次没读到」(不是老后端)"
|
||
);
|
||
|
||
globalThis.fetch = originalFetch;
|
||
globalThis.localStorage = originalLocalStorage;
|
||
|
||
// ── ⑬ 分页判据:`hasMore` 权威 / `nextBefore` 权威(契约 v0.80 §4.11.1(a)) ───
|
||
ok(pageHasOlder(true, 2, 50), "后端说 hasMore ⇒ 可能还有(**哪怕这一页是短页**)");
|
||
ok(!pageHasOlder(false, 50, 50), "后端说 hasMore=false ⇒ 到头了(**哪怕这一页是满页**,不要白打请求)");
|
||
ok(pageHasOlder(null, 50, 50), "老后端没给 hasMore ⇒ 退回满页启发式");
|
||
ok(!pageHasOlder(null, 3, 50), "老后端没给 hasMore + 短页 ⇒ 到头了");
|
||
same(
|
||
timelineCursorOf({ nextBefore: r1.startedAt, rounds: [r1, r2] }),
|
||
r1.startedAt,
|
||
"游标优先用后端的 nextBefore"
|
||
);
|
||
same(
|
||
timelineCursorOf({ nextBefore: null, rounds: [r1, r2] }),
|
||
r1.startedAt,
|
||
"后端没给 nextBefore ⇒ 退回自己算的「最旧那一回合」(原样字符串)"
|
||
);
|
||
same(timelineCursorOf({ nextBefore: null, rounds: [] }), null, "都没有 ⇒ 没有游标(不瞎凑)");
|
||
|
||
// ── ⑭ 权威键缺席 = 不知道(老后端不得拿别的字段回推) ────────────────────────
|
||
// v0.81 起「已过保留期」的判据**只有** `retentionBoundaryReached === true`:老后端(v0.80
|
||
// 出口)没这个键 ⇒ 解包成 `null`,「保留期开着」这件事本身**不足以**支撑那句话。
|
||
const oldBackendPage = timelinePageOf({
|
||
rounds: [r1],
|
||
retentionHours: 168,
|
||
retentionEnabled: true,
|
||
truncated: true,
|
||
});
|
||
same(oldBackendPage.retentionBoundaryReached, null, "老后端缺权威键 ⇒ 状态里是 null(不知道)");
|
||
// 喂给判定的入参:与 `timelineHints` 的真实入参同形(`canLoadMore` 由取数层算,这里取最保守的 false)。
|
||
const oldBackendHints = timelineHints({
|
||
truncated: oldBackendPage.truncated,
|
||
canLoadMore: false,
|
||
retentionBoundaryReached: oldBackendPage.retentionBoundaryReached,
|
||
source: oldBackendPage.source,
|
||
});
|
||
ok(
|
||
!oldBackendHints.expired,
|
||
"「保留期启用 + truncated」**不构成**过期的判据(v0.81 明令禁止自己拼)"
|
||
);
|
||
ok(oldBackendHints.hidden, "缺权威键 + truncated ⇒ 只能挂「更早的轮次未显示」");
|
||
|
||
// ── ⑮ 页首三句话的判定(`timelineHints`):互不冒充 ─────────────────────────
|
||
// 入参只认「后端给的键」:`truncated` / `canLoadMore` / `retentionBoundaryReached` / `source`。
|
||
// ⚠️ `retentionHours` / `retentionEnabled` **已不再**是入参(它们去掉了判据资格)。
|
||
const hintBase = { truncated: true, canLoadMore: false, retentionBoundaryReached: null, source: null };
|
||
/** 三支一起比(对象比较要逐字段,`same` 只比标量)。 */
|
||
function hintsOf(h: TimelineHints): string {
|
||
return `${h.hidden ? "hidden" : "-"}/${h.expired ? "expired" : "-"}/${h.liveOnly ? "liveOnly" : "-"}`;
|
||
}
|
||
same(hintsOf(timelineHints({ ...hintBase, truncated: false })), "-/-/-", "没说缺 ⇒ 三支都为假");
|
||
same(
|
||
hintsOf(timelineHints({ ...hintBase, canLoadMore: true })),
|
||
"hidden/-/-",
|
||
"还有更早可拉 ⇒ 只能「未显示」(**绝不**说「已过保留期」)"
|
||
);
|
||
same(
|
||
hintsOf(timelineHints({ ...hintBase, retentionBoundaryReached: false })),
|
||
"hidden/-/-",
|
||
"后端明确说 false(本次不是全部但没被清扫)⇒ 说「未显示」"
|
||
);
|
||
same(
|
||
hintsOf(timelineHints({ ...hintBase, truncated: false, canLoadMore: false, retentionBoundaryReached: true })),
|
||
"-/expired/-",
|
||
"权威键 true ⇒ 才说「已过保留期」(哪怕这一页 truncated=false:已到最早一端)"
|
||
);
|
||
same(
|
||
hintsOf(timelineHints({ ...hintBase, retentionBoundaryReached: true })),
|
||
"-/expired/-",
|
||
"权威键 true 时**不再**同时挂「未显示」(一句话,不是两句)"
|
||
);
|
||
same(
|
||
hintsOf(timelineHints({ ...hintBase, source: "live", retentionBoundaryReached: false })),
|
||
"hidden/-/liveOnly",
|
||
"本页全来自内存(落盘源未接线)⇒ 「只拿到最近一轮」"
|
||
);
|
||
same(
|
||
hintsOf(timelineHints({ ...hintBase, source: "live", retentionBoundaryReached: true })),
|
||
"hidden/-/liveOnly",
|
||
"本页全来自内存时**压住**过期声明(本页没有落盘对照,无从复核 ⇒ 少说)"
|
||
);
|
||
same(
|
||
hintsOf(timelineHints({ truncated: false, canLoadMore: false, retentionBoundaryReached: null, source: null })),
|
||
"-/-/-",
|
||
"一切未知 / 什么都没说 ⇒ 一句话都不说(最保守话术)"
|
||
);
|
||
|
||
console.log(`flow timeline logic test: ${checks} checks passed`);
|