Files
Cyrene_v2/frontend/web/src/admin/modules/flow/flowTimeline.test.ts
T
AskaEth 57d0e8c543
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(admin): 时间轴只读权威键 + 自排筛选轴 + browser 面板图标(Y2+Y3+Y4 前端一并落)
- 时间轴:页首「已过保留期」的**唯一**判据改为 retentionBoundaryReached === true(truncated 只出
  「未显示」);缺席(老后端)一句话都不说;删掉旧的 truncated+retentionEnabled+翻到底 启发式
- Backthink:筛选轴接上服务端 ?trigger=(客户端筛选保留作兜底)+ 自排闸用量行(三态文案)
- plugin/nav.ts 加 browser 图标
- 真机(隔离实例,46/46):翻页不重不漏、三种「必须 false」的权威键语义、**重启后 round 不重复**
⚠️ 未验:真机上没构造出权威键 true(需真有更早轮次被清扫)——别读成「真机验过 true」
2026-10-06 21:48:52 +08:00

439 lines
23 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.
// 「链路时间轴」的**纯逻辑**测试(切片 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`);