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

@@ -5,6 +5,8 @@ import { LaneTracks } from "./LaneTracks";
import type { FlowTimelineState } from "./useFlowTimeline";
import type { FlowTimelineOrder, RoundStats } from "./flowTimeline";
import {
LIVE_ONLY_TEXT,
MORE_HIDDEN_TEXT,
RETENTION_TRUNCATED_TEXT,
fmtRoundTime,
orderRounds,
@@ -17,6 +19,7 @@ import {
roundStatusLabel,
roundStatusTag,
shortTurnId,
timelineHints,
} from "./flowTimeline";
/**
@@ -28,7 +31,10 @@ import {
* - 每个回合一个**可折叠块**,标题 = 时间 + `round` + `turnId` 短标识 + 状态;
* - 块内**复用 `LaneTracks`** 画该回合各路的**并列泳道**(真并行不止一张);
* - 🔴 **块与块之间不画因果连线**(不臆造关系):只用时间与序号分隔,页首明写这一点;
* - 过期轮次不在返回值里 ⇒ 后端给 `truncated` 时页首**如实标注**「更早的轮次已过保留期」。
* - 页首的三句话**各有判据**(`flowTimeline.ts#timelineHints`):`hasMore`(还有更早的可拉)
* ⇒ 「更早的轮次未显示」;**已翻到底**却仍不是全部且保留期启用 ⇒ 才说「更早的轮次已过
* 保留期」;本页全来自内存 ⇒ 「只拿到最近一轮」。**不许**拿 `truncated` 直接当「已过保留
* 期」(契约 v0.80 §4.11.1(b).3:那会对着一页仍可翻的历史说假话)。
*
* 全部 **props 驱动**(不读 store、不取数):本项目没有 jsdom,渲染测试只能喂 props
* (见 `flowSnapshot.render.test.ts` 头注释的同一理由)。改一个块的展开态由父层持有
@@ -90,6 +96,7 @@ function TimelineHeader({
state: FlowTimelineState;
binding: TimelineBinding;
}): React.ReactNode {
const hints = timelineHints(state);
return (
<Surface className="space-y-2">
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--mu-text-mid)]">
@@ -124,7 +131,9 @@ function TimelineHeader({
</p>
<p className="text-[11px] text-[var(--mu-text-lo)]">
{retentionText(state.retentionHours)}
{state.truncated ? ` —— ${RETENTION_TRUNCATED_TEXT}` : null}
{hints.expired ? ` —— ${RETENTION_TRUNCATED_TEXT}` : null}
{hints.hidden ? ` —— ${MORE_HIDDEN_TEXT}` : null}
{hints.liveOnly ? ` —— ${LIVE_ONLY_TEXT}` : null}
 · 数据源:<code>GET /api/v1/admin/flow/timeline</code>(只读回看,不写库、不发布帧)
</p>
</Surface>
@@ -98,7 +98,9 @@ function tlState(
rounds,
total: rounds.length,
retentionHours: 168,
retentionEnabled: null,
truncated: false,
source: null,
loading: false,
canLoadMore: false,
loadingMore: false,
@@ -365,4 +367,41 @@ const bodyUnwired = renderToStaticMarkup(
);
ok(has(bodyUnwired, "时间轴取数未接线"), "切到时间轴但没喂数据 ⇒ 如实说未接线(不假装空态)");
// ── ⑬ 「未显示」与「已过保留期」是**两件事**(契约 v0.80 §4.11.1(b).3) ─────────
// 真机上 `truncated = hasMore || 落盘源未接线` ⇒ 只要还有更早的可拉它就是 true。拿它直接
// 当「已过保留期」,面板就会对着一页**仍然可翻**的历史说假话 —— 这条是核出来的成对契约缺陷。
const moreToLoad = view(tlState(NEWEST_FIRST, { truncated: true, canLoadMore: true }));
ok(has(moreToLoad, "更早的轮次未显示"), "还有更早的可拉 ⇒ 只能说「未显示」");
ok(
!has(moreToLoad, "更早的轮次已过保留期"),
"还有更早的可拉 ⇒ **绝不**能说「已过保留期」(那是对着能翻的历史说假话)"
);
ok(has(moreToLoad, "加载更早的回合"), "同一份状态里翻页入口仍在(缺失不是静默的)");
const retentionOff = view(
tlState(NEWEST_FIRST, { truncated: true, retentionHours: 0, retentionEnabled: false })
);
ok(has(retentionOff, "更早的轮次未显示"), "保留期未启用 ⇒ 只能说「未显示」");
ok(
!has(retentionOff, "更早的轮次已过保留期"),
"保留期未启用 ⇒ 不得说「已过保留期」(那些回合不是被清扫掉的)"
);
ok(has(retentionOff, "保留期未启用"), "「保留期未启用」这件事本身照实说");
const expiredNow = view(tlState(NEWEST_FIRST, { truncated: true, canLoadMore: false }));
ok(
has(expiredNow, "更早的轮次已过保留期"),
"已翻到底仍不是全部 + 保留期启用 ⇒ 才可以说「已过保留期」(design/19 §7.1 的逐字文案)"
);
ok(!has(expiredNow, "更早的轮次未显示"), "同一份状态不得两种说法都给");
// ── ⑭ 三种真实状态的呈现(有数据 / 只有最近一轮 / 读取失败) ────────────────
const liveOnly = view(tlState([C], { source: "live", truncated: true, canLoadMore: false }));
ok(has(liveOnly, "只拿到内存里的最近一轮"), "source=live(本页全部来自内存)⇒ 如实说只拿到最近一轮");
same(blocks(liveOnly).length, 1, "「只有最近一轮」仍然画出那一块(不是空态)");
ok(!has(liveOnly, "保留期内没有可回看的回合"), "「只有最近一轮」≠「没有回合」(两件事不许混)");
const failedWithRounds = view(tlState([B], { failed: true }));
same(blocks(failedWithRounds).length, 1, "首屏失败标记不吞掉已拿到的回合(回归:只有空态才说读不到)");
console.log(`flow timeline render test: ${checks} checks passed`);
@@ -29,7 +29,9 @@ import {
mergeTimelineRounds,
normalizeTimelineRounds,
orderRounds,
pageHasOlder,
pageMayHaveOlder,
retentionClaimable,
retentionText,
roundExpanded,
roundGraphs,
@@ -40,9 +42,12 @@ import {
roundStatusTag,
shortTurnId,
timelineCursor,
timelineCursorOf,
timelineHints,
timelinePageOf,
toggleRoundExpansion,
} from "./flowTimeline";
import type { TimelineHints } from "./flowTimeline";
let checks = 0;
@@ -333,4 +338,59 @@ same(
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, "都没有 ⇒ 没有游标(不瞎凑)");
// ── ⑭ 保留期能不能用来解释缺失(`retentionClaimable` 的三支) ────────────────
ok(retentionClaimable({ retentionHours: 168, retentionEnabled: null }), "有小时数且 >0 ⇒ 可以解释");
ok(!retentionClaimable({ retentionHours: 0, retentionEnabled: null }), "≤0 = 配置为不回收 ⇒ 不许说过期");
ok(
!retentionClaimable({ retentionHours: 168, retentionEnabled: false }),
"后端显式说没启用 ⇒ **永远**不许说过期(哪怕小时数字段还带着 168)"
);
ok(retentionClaimable({ retentionHours: null, retentionEnabled: true }), "显式说启用了 ⇒ 可以解释");
ok(!retentionClaimable({ retentionHours: null, retentionEnabled: null }), "两样都不知道 ⇒ 不说");
// ── ⑮ 页首三句话的判定(`timelineHints`):互不冒充 ─────────────────────────
const hintBase = { truncated: true, canLoadMore: false, retentionHours: 168, retentionEnabled: true, 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)),
"-/expired/-",
"翻到底仍不是全部 + 保留期启用 ⇒ 才说「已过保留期」"
);
same(
hintsOf(timelineHints({ ...hintBase, retentionHours: 0, retentionEnabled: false })),
"hidden/-/-",
"保留期未启用 ⇒ 只能说「未显示」"
);
same(
hintsOf(timelineHints({ ...hintBase, source: "live" })),
"hidden/-/liveOnly",
"本页全来自内存(落盘源未接线)⇒ 「只拿到最近一轮」,且**不**说成过期"
);
console.log(`flow timeline logic test: ${checks} checks passed`);
@@ -29,9 +29,22 @@ export const TIMELINE_PAGE_SIZE = 50;
export type FlowTimelineOrder = "newest-first" | "oldest-first";
/** 「更早的轮次已过保留期」——**逐字**文案(`design/19 §7.1`:过期轮次不在返回值里时,
* 必须由面板如实标注,**不许静默少一块**)。 */
* 必须由面板如实标注,**不许静默少一块**)。
*
* ⚠️ 这句话**有判据**(契约 v0.80 §4.11.1(b).3):只有「分页已到底(`hasMore` 为假)
* 却仍不是全部,且保留期**启用**」才可以说;`truncated` 本身只等于「本次不是全部」,
* 拿它直接当「已过保留期」会对着一页**仍然可翻**的历史说假话(本切片核出的成对契约缺陷)。 */
export const RETENTION_TRUNCATED_TEXT = "更早的轮次已过保留期";
/** 「更早的轮次**未显示**」:本次不是全部,但原因**不是**保留期 —— 要么还有更早的可拉
* (`hasMore`,翻页入口就在旁边),要么后端配置为**不回收**(`retentionEnabled:false`)。
* 与「已过保留期」是**两件事**,不许互相冒充。 */
export const MORE_HIDDEN_TEXT = "更早的轮次未显示";
/** 「只拿到内存里的最近一轮」:本页**全部**来自内存(`source:"live"`)= 落盘源未接线,
* 或该会话还没有落盘回合。**不**等于「这台会话没有回合」(那是空态该说的)。 */
export const LIVE_ONLY_TEXT = "落盘源未接线:只拿到内存里的最近一轮";
/** 时间读数:不可解析的值一律算 0(不伪造时间,也不让比较器算出 NaN)。
* 与 `flowHistory.ts#timeOf` 同一口径 —— 两处都只服务排序。 */
function timeOf(iso: string | null | undefined): number {
@@ -108,26 +121,54 @@ export function timelineCursor(
return cursor;
}
/** 后端这一页是否**可能还有更早的**:满页才可能还有(短页 = 到头了)。
* 与 `Notifications.tsx` 的游标翻页同一口径(短页即止),避免在同一个游标上打转。 */
/** 「短页即止」的启发式:满页才可能还有更早的,短页 = 到头了。
* 只在后端**没给** `hasMore` 时兜底(见 `pageHasOlder`);与 `Notifications.tsx` 的
* 游标翻页同一口径,避免在同一个游标上打转。 */
export function pageMayHaveOlder(receivedCount: number, pageSize: number): boolean {
return pageSize > 0 && receivedCount >= pageSize;
}
/** 后端这一页是否**可能还有更早的**:`hasMore` 是**权威判据**(契约 §4.11.1(a));
* 老后端没给这个键(`null`)时才退回「满页才可能还有」的启发式(`pageMayHaveOlder`,
* 与 `Notifications.tsx` 的短页即止同一口径)。
*
* ⚠️ 只靠启发式会**对着满页的最后一页白打一次请求**(并短暂宣称「还有更早的」)——
* 因此后端给了 `hasMore` 就一律以它为准。 */
export function pageHasOlder(
hasMore: boolean | null | undefined,
receivedCount: number,
pageSize: number
): boolean {
if (typeof hasMore === "boolean") return hasMore;
return pageMayHaveOlder(receivedCount, pageSize);
}
/** 下一页的游标:**优先后端给的 `nextBefore`**(权威 = 本页最早一轮的开始时刻;它连
* 「开始时刻不可解析 ⇒ 该行的落盘时刻」这种情形都在服务端处理过了);后端没给才退回
* 自己算的 `timelineCursor`。两者都拿不到 ⇒ `null`(**不能翻页**,面板如实停住)。 */
export function timelineCursorOf(state: {
nextBefore?: string | null;
rounds: readonly AdminFlowTimelineRound[];
}): string | null {
return state.nextBefore ? state.nextBefore : timelineCursor(state.rounds);
}
/** **追加一页**的判定(纯函数,取数层与逻辑测试共用):
* - `rounds` = 合并去重后的全量(`mergeTimelineRounds`,不重不漏);
* - `added` = 这一页真正**新增**了几条;
* - `canLoadMore` = 还能不能继续翻 —— 要求 ① **真有新回合**(否则游标没推进,再翻只会在
* 同一页上打转)且 ② 后端这一页是**满页**(`pageMayHaveOlder`)。 */
* 同一页上打转)且 ② 后端这一页**后面还有更早的**(`hasMore` 权威;老后端没给该键时
* 退回满页启发式,见 `pageHasOlder`)。 */
export function appendTimelinePage(
existing: readonly AdminFlowTimelineRound[],
incoming: readonly AdminFlowTimelineRound[] | null | undefined,
pageSize: number
pageSize: number,
hasMore: boolean | null = null
): { rounds: AdminFlowTimelineRound[]; added: number; canLoadMore: boolean } {
const page = incoming ?? [];
const rounds = mergeTimelineRounds(existing, page);
const added = rounds.length - existing.length;
return { rounds, added, canLoadMore: added > 0 && pageMayHaveOlder(page.length, pageSize) };
return { rounds, added, canLoadMore: added > 0 && pageHasOlder(hasMore, page.length, pageSize) };
}
/** 回合标识的**短标识**(块标题用):取最后一个 `#` 之后那一段(v0.79 起标识形如
@@ -259,6 +300,58 @@ export function classifyTimelineFailure(err: unknown): TimelineFailure {
return isNotImplemented(err) ? "unsupported" : "failed";
}
/** 保留期是否**可以用来解释「更早的轮次不见了」**(`retentionText` 三支的同源判据):
* `retentionEnabled:false` ⇒ **永远不许**说过期;给了小时数就看它是否 `>0`;两者都缺 ⇒
* 只有当 `retentionEnabled === true` 才认(否则**不知道**,就不说)。 */
export function retentionClaimable(state: {
retentionHours: number | null | undefined;
retentionEnabled: boolean | null | undefined;
}): boolean {
if (state.retentionEnabled === false) return false;
if (typeof state.retentionHours === "number" && Number.isFinite(state.retentionHours)) {
return state.retentionHours > 0;
}
return state.retentionEnabled === true;
}
/** 时间轴页首要如实交代的三件事(**只看后端给的字段**,不猜、不美化)。
*
* - `liveOnly`:本页**全部来自内存**(`source:"live"`)⇒ 说「只拿到最近一轮」—— 缺的那些
* 回合**不是**被保留期清扫掉的(落盘源未接线),因此**永不**并入「已过保留期」;
* - `expired`:**分页已到底**(`hasMore` 为假)却仍「不是全部」,且保留期**可用它来解释**
* ⇒ 才可以说「更早的轮次已过保留期」(`design/19 §7.1`/§7.4 的逐字文案);
* - `hidden`:仍不是全部,但**不能**用保留期解释(还有更早的可拉 / 落盘源未接线 / 后端
* 配置为不回收)⇒ 说「更早的轮次未显示」,**绝不**冒充「已过保留期」
* (契约 v0.80 §4.11.1(b).3)。
*
* 三者互不冒充;`truncated` 为假时三支都为假(没有可交代的缺失)。 */
export interface TimelineHints {
/** 「更早的轮次未显示」(不是保留期的锅)。 */
hidden: boolean;
/** 「更早的轮次已过保留期」(保留期启用 + 已翻到底 + 不是「只有最近一轮」)。 */
expired: boolean;
/** 「只拿到内存里的最近一轮」。 */
liveOnly: boolean;
}
/** `timelineHints` 的入参(与 `FlowTimelineState` 结构兼容,便于渲染层直接喂 state)。 */
export interface TimelineHintsInput {
truncated: boolean;
canLoadMore: boolean;
retentionHours: number | null | undefined;
retentionEnabled: boolean | null | undefined;
source: TimelineSource | null | undefined;
}
/** 页首那三句话的判定(纯函数,逻辑测试与渲染测试共用**唯一真源**)。 */
export function timelineHints(state: TimelineHintsInput): TimelineHints {
const liveOnly = state.source === "live";
if (!state.truncated) return { hidden: false, expired: false, liveOnly };
if (liveOnly || state.canLoadMore) return { hidden: true, expired: false, liveOnly };
const expired = retentionClaimable(state);
return { hidden: !expired, expired, liveOnly };
}
/** 折叠默认值:**用户没点过**的块 ⇒ 只有**最新一回合**默认展开(打开页面即看到最近的
* 那条链路),其余折叠成索引;用户点过就**以用户的为准**(点开过的保持开、点收过的
* 保持收)。`newest` 由调用方按**数据**判定(`rounds[0]`),与展示顺序无关。 */
@@ -282,12 +375,23 @@ export function toggleRoundExpansion(
return { ...expanded, [turnId]: !roundExpanded(turnId, expanded, newest) };
}
/** 后端给出的**数据源**(契约 §4.11.1(a) 的 `source`):本页至少一轮来自落盘 / 本页全部
* 来自内存 / 没有任何回合。缺席(老后端)= `null`(不猜)。 */
export type TimelineSource = NonNullable<AdminFlowTimelineResponse["source"]>;
/** 时间轴响应 → 取数层状态用的**纯**解包(缺键一律按诚实缺省:没有回合就是空数组、
* `truncated` 缺席当 false、保留期缺席当未知)。 */
* `truncated` 缺席当 false、保留期/游标/`hasMore` 缺席当**未知**(`null`),不冒充 0)。 */
export interface TimelinePage {
rounds: AdminFlowTimelineRound[];
retentionHours: number | null;
/** 保留期清扫**是否启用**;缺席 = 未知(由 `retentionHours` 推断,见 `retentionClaimable`)。 */
retentionEnabled: boolean | null;
truncated: boolean;
/** 后端说「还有更早的」;缺席 = 不知道(取数层退回满页启发式)。 */
hasMore: boolean | null;
/** 后端给的下一页游标(本页最早一轮的开始时刻);缺席 / 空串 = 没有。 */
nextBefore: string | null;
source: TimelineSource | null;
}
/** 解包一页响应(**只读后端给的字段**:不补字段、不编值)。 */
@@ -300,6 +404,16 @@ export function timelinePageOf(
typeof resp?.retentionHours === "number" && Number.isFinite(resp.retentionHours)
? resp.retentionHours
: null,
retentionEnabled: typeof resp?.retentionEnabled === "boolean" ? resp.retentionEnabled : null,
truncated: Boolean(resp?.truncated),
hasMore: typeof resp?.hasMore === "boolean" ? resp.hasMore : null,
nextBefore: resp?.nextBefore ? resp.nextBefore : null,
source: readSource(resp?.source),
};
}
/** 读 `source`:只认契约登记的三个取值,别的一律当**未知**(`null`)——不把没见过的字符串
* 说成三种里的任何一种。 */
function readSource(raw: string | undefined): TimelineSource | null {
return raw === "persisted" || raw === "live" || raw === "none" ? raw : null;
}
@@ -0,0 +1,262 @@
// 「真取数 → 真状态 → 真渲染」的**完整回路**测试(切片 V2;契约 v0.80 §4.11.1)。
//
// 为什么需要它:本项目没有 jsdom ⇒ 既跑不了 hook,也点不了界面。W2 的渲染测试是**替身级**
// (手写 state 当 props),取数测试此前根本不存在。本文件把两头接起来:
//
// 真 `fetch`(照契约的假后端:`before` 严格更早 / `hasMore` / `nextBefore` / `truncated`)
// → 真的取数状态机(`loadFirstPage` / `loadOlderPage`)
// → 真的状态映射(`flowTimelineViewState`,就是 hook 返回的那个)
// → 真的视图(`FlowTimelineView` 的 SSR 输出)
// ⇒ 对**三种真实状态**逐条断言界面说了什么、**没说**什么:
// ① 有数据(多轮 + 分页到底)② 只有最近一轮(落盘源未接线)③ 读取失败(500)
// ④ 附:老后端(404)—— 与 ③ 是两件事,必须分开说。
//
// 跑法(渲染类必须 `--format=cjs`;异步用 IIFE,抛错 ⇒ unhandled rejection ⇒ 非零退出):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/flow/flowTimelineWiring.render.test.ts \
// --bundle --platform=node --format=cjs \
// --outfile=/tmp/cyrene-flow-timeline-wiring.render.test.cjs \
// && node /tmp/cyrene-flow-timeline-wiring.render.test.cjs
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import type { FlowGraph } from "../../../lib/types";
import { MAIN_SESSION_ID } from "../../../store/sessionCache";
import { FlowBody } from "../Flow";
import { type TimelineBinding } from "./TimelineView";
import {
emptyState,
flowTimelineViewState,
loadFirstPage,
loadOlderPage,
type Send,
type TimelineData,
} from "./useFlowTimeline";
import { timelineCursorOf } 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)}`);
}
function has(haystack: string, needle: string): boolean {
return haystack.includes(needle);
}
const originalLocalStorage = globalThis.localStorage;
globalThis.localStorage = {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
} as unknown as Storage;
// ── 照契约的假后端(同 `useFlowTimeline.test.ts` 的语义,这里只留渲染回路需要的档位) ──
const STAMPS = [
"2026-10-06T02:00:00.000Z",
"2026-10-06T02:05:00.000Z",
"2026-10-06T02:09:00.000Z",
];
/** 3 个回合(第 2 轮两路);页大小 2 ⇒ 走一次翻页。 */
function wireRounds(): unknown[] {
return STAMPS.map((startedAt, i) => {
const round = i + 1;
const turnId = `web-${round}#turn-${round}`;
const graph: FlowGraph = {
id: turnId,
sessionId: MAIN_SESSION_ID,
turnId,
round,
startedAt,
nodes: [
{ id: "input", label: `哨兵${round}输入`, kind: "input", status: "done" },
{ id: "out", label: `哨兵${round}回复`, kind: "output", status: "done" },
],
edges: [{ from: "input", to: "out" }],
};
const lanes = [{ turnId, graph }];
if (round === 2) lanes.push({ turnId: `${turnId}#leg2`, graph: { ...graph, id: `${turnId}#leg2` } });
return { turnId, round, startedAt, endedAt: startedAt, lanes };
});
}
type Mode = "persisted" | "liveOnly" | "failed" | "oldBackend";
function installBackend(mode: Mode): void {
globalThis.fetch = ((url: string) => {
if (mode === "failed") {
return Promise.resolve({
ok: false,
status: 500,
headers: { get: () => "application/json" },
json: () => Promise.resolve({ error: { code: "flow_unavailable", message: "boom" } }),
});
}
if (mode === "oldBackend") {
return Promise.resolve({
ok: false,
status: 404,
headers: { get: () => "text/plain" },
json: () => Promise.reject(new Error("not json")),
});
}
const all = wireRounds();
const q = new URLSearchParams(String(url).split("?")[1] ?? "");
const before = q.get("before") ?? "";
if (mode === "liveOnly") {
return Promise.resolve({
ok: true,
status: 200,
headers: { get: () => "application/json" },
json: () =>
Promise.resolve({
data: {
sessionId: MAIN_SESSION_ID,
source: "live",
rounds: [all[all.length - 1]],
nextBefore: "",
hasMore: false,
truncated: true,
retentionHours: 168,
retentionEnabled: true,
},
}),
});
}
const pool = before ? all.filter((r) => (r as { startedAt: string }).startedAt < before) : all;
const page = pool.slice(-2);
const hasMore = pool.length > page.length;
return Promise.resolve({
ok: true,
status: 200,
headers: { get: () => "application/json" },
json: () =>
Promise.resolve({
data: {
sessionId: MAIN_SESSION_ID,
source: page.length > 0 ? "persisted" : "none",
rounds: page,
nextBefore: (page[0] as { startedAt: string } | undefined)?.startedAt ?? "",
hasMore,
truncated: hasMore,
retentionHours: 168,
retentionEnabled: true,
},
}),
});
}) as unknown as typeof globalThis.fetch;
}
/** 状态机宿主(等价于 hook 的 setState)。 */
interface Host {
get: () => TimelineData;
send: Send;
}
function host(): Host {
let state = emptyState(MAIN_SESSION_ID);
const send: Send = (patch) => {
state = patch(state);
};
return { get: () => state, send };
}
/** 把真取数状态按 **hook 自己的映射** 翻成视图状态,再经**真正的 Flow 页视图**(`FlowBody`
* 的 `view="timeline"`,即面板上「时间轴」页签选中时的形态)SSR 成 HTML。 */
function render(h: Host): string {
const state = flowTimelineViewState(h.get(), () => {}, () => {});
return renderToStaticMarkup(
createElement(FlowBody, {
graph: {
id: "web-88",
sessionId: MAIN_SESSION_ID,
nodes: [{ id: "input", label: "最近一轮哨兵", kind: "input", status: "done" }],
edges: [],
},
sessionId: MAIN_SESSION_ID,
source: "live",
view: "timeline",
onSelectView: () => {},
timeline: state,
timelineBinding: {
order: "newest-first",
onToggleOrder: () => {},
expanded: {},
onToggleRound: () => {},
onReload: () => {},
} satisfies TimelineBinding,
})
);
}
void (async () => {
// ── ① 有数据:真 fetch 翻到底 ⇒ 三块 + 保留期口径,且**没有**任何「过期」假话 ──
installBackend("persisted");
const filled = host();
await loadFirstPage(MAIN_SESSION_ID, filled.send);
same(filled.get().rounds.length, 2, "第一页 2 个回合");
await loadOlderPage(MAIN_SESSION_ID, timelineCursorOf(filled.get()) ?? "", filled.send);
same(filled.get().rounds.length, 3, "翻一次后 3 个回合(三页数据两页取完,不重不漏)");
same(filled.get().canLoadMore, false, "翻到底 ⇒ 不再有更早的");
const filledHtml = render(filled);
same(
(filledHtml.match(/data-flow-timeline-round="/g) ?? []).length,
3,
"三个回合 ⇒ 真渲染出三个块(数量与取数结果一致)"
);
ok(has(filledHtml, "共 3 个回合"), "页首的回合数来自真取数结果");
ok(has(filledHtml, "保留期 168 小时"), "页首如实转达后端给的保留期");
ok(!has(filledHtml, "更早的轮次已过保留期"), "翻到底 + 后端没给清扫信号 ⇒ **不**说「已过保留期」");
ok(!has(filledHtml, "更早的轮次未显示"), "翻到底 ⇒ 也不挂「未显示」");
ok(has(filledHtml, "没有更早的回合了"), "翻到底 ⇒ 如实说没有更早的(不是静默消失)");
ok(has(filledHtml, "2 路 · "), "真并行的那一轮两路都在(块标题如实报路数)");
ok(has(filledHtml, "视图"), "页面上有视图切换页签(切到时间轴这件事在界面上是有出口的)");
ok(!has(filledHtml, "最近一轮哨兵"), "时间轴视图**不**画「最近一轮」的图(两套不混)");
// ── ② 只有最近一轮(落盘源未接线):说「只拿到最近一轮」,**不等于**空态 ──
installBackend("liveOnly");
const live = host();
await loadFirstPage(MAIN_SESSION_ID, live.send);
same(live.get().source, "live", "真取数状态标出来源是内存(source=live)");
const liveHtml = render(live);
same(
(liveHtml.match(/data-flow-timeline-round="/g) ?? []).length,
1,
"只拿到最近一轮 ⇒ 仍然画出那一块(不是空态)"
);
ok(has(liveHtml, "只拿到内存里的最近一轮"), "如实说明「只有内存里这一轮」(落盘源未接线)");
ok(!has(liveHtml, "保留期内没有可回看的回合"), "「只有最近一轮」**不是**「没有回合」");
ok(!has(liveHtml, "更早的轮次已过保留期"), "落盘源未接线也**不**能说成过期");
// ── ③ 读取失败(500):如实说读不到 + 给重试,不落进空态文案 ──────────
installBackend("failed");
const broke = host();
await loadFirstPage(MAIN_SESSION_ID, broke.send);
same(broke.get().failed, true, "真取数状态是 failed");
const failedHtml = render(broke);
ok(has(failedHtml, "暂时读不到"), "读取失败 ⇒ 如实说「暂时读不到」");
ok(has(failedHtml, "重试"), "读取失败 ⇒ 给重试入口");
ok(!has(failedHtml, "保留期内没有可回看的回合"), "读取失败 ≠「没有回合」(两件事不许说成一件)");
ok(!has(failedHtml, "当前只支持最近一轮"), "读取失败 ≠ 老后端(再说一遍也不能混)");
// ── ④ 老后端(404):与「读取失败」分开说 ─────────────────────────────
installBackend("oldBackend");
const old = host();
await loadFirstPage(MAIN_SESSION_ID, old.send);
same(old.get().unsupported, true, "真取数状态是 unsupported");
const oldHtml = render(old);
ok(has(oldHtml, "当前只支持最近一轮"), "老后端 ⇒ 明确说「当前只支持最近一轮」");
ok(!has(oldHtml, "暂时读不到"), "老后端 ≠ 读取失败");
globalThis.localStorage = originalLocalStorage;
console.log(`flow timeline wiring test: ${checks} checks passed`);
})();
@@ -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`);
@@ -5,29 +5,38 @@ import {
TIMELINE_PAGE_SIZE,
appendTimelinePage,
classifyTimelineFailure,
pageMayHaveOlder,
timelineCursor,
pageHasOlder,
timelineCursorOf,
timelinePageOf,
} from "./flowTimeline";
import type { TimelineSource } from "./flowTimeline";
/**
* 链路**时间轴**的取数 hook(切片 W2;`design/19 §7.1`;W1 的新出口
* `GET /api/v1/admin/flow/timeline`)。
* `GET /api/v1/admin/flow/timeline`;契约 v0.80 §4.11.1)。
*
* 分工:`useFlowSnapshot`(F1)补**最近一轮**、`useFlowTurns`(F2)翻**单轮历史**,
* 本 hook 一次给**多轮**(每轮连同它每一路),供时间轴视图把「所有消息按时间顺序」摊开。
* 它**不写 store**(不碰 `flowGraphs` / `flowLanes`)—— 时间轴是**只读回看**,写进图桶
* 会把正在跑的那一轮顶掉(裁决 16.4.2 的同一理由)。
*
* 四条刻意口径:
* 五条刻意口径:
* 1. **按需取数**(`enabled`):只有真的切到时间轴视图才打这个端点(默认不打扰后端);
* 2. **分页只向更早走**:游标 = 手上**最旧**那一回合的 `startedAt`(`flowTimeline.ts`
* 的纯函数),与展示顺序无关;一页没带来任何**新**回合 ⇒ 立刻判定到头(否则会在同
* 一个游标上打转到天荒地老);
* 2. **分页只向更早走,且认后端的判据**:游标优先用响应里的 `nextBefore`、能不能继续翻
* 优先用 `hasMore`(契约 §4.11.1(a) 的权威字段)—— 自己算的「最旧那一回合的开始时间」
* 与「满页启发式」只在老后端**没给**这两个键时兜底;一页没带来任何**新**回合 ⇒ 立刻
* 判定到头(否则会在同一个游标上打转到天荒地老);
* 3. **老后端如实降级**:404/501 ⇒ `unsupported`(面板文案「当前只支持最近一轮」),
* **绝不**把「没有这个出口」说成「这段时间没有回合」;
* 4. **失败不伪装**:首屏失败 ⇒ `failed` + 可重试;翻页失败只记在 `moreFailed`,
* 已拿到的回合**照旧显示**(不回退成空态)。
* 已拿到的回合**照旧显示**(不回退成空态);
* 5. **保留期口径原样转达**:`retentionHours` / `retentionEnabled` / `truncated` / `source`
* 四个字段**逐字**落进状态,由 `flowTimeline.ts#timelineHints` 判定「未显示」与
* 「已过保留期」—— 取数层**不替**后端解释(那正是「已过保留期」说假话的来路)。
*
* 取数体的两个函数(`loadFirstPage` / `loadOlderPage`)**导出**是为了能在无 jsdom 的项目里
* 被真跑:测试驱动**真的** `lib/api` 客户端(request → fetch)+ **真的**这几个状态机函数
* (同 `useFlowSnapshot.ts` 的手法),而不是只断言纯函数。
*/
export interface FlowTimelineState {
/** 保留期内的回合(**最新在前**,与展示顺序无关;渲染时再用 `orderRounds` 摆)。 */
@@ -36,11 +45,16 @@ export interface FlowTimelineState {
total: number;
/** 保留期(小时);后端没给 ⇒ `null`(面板显示「未知」,不编造)。 */
retentionHours: number | null;
/** 更早的回合已过保留期(后端口径)—— 面板必须**如实标注**。 */
/** 保留期清扫**是否启用**;缺失 ⇒ `null`(由小时数推断,见 `retentionClaimable`)。 */
retentionEnabled: boolean | null;
/** 后端**最近这次响应**说「本次不是全部」(`hasMore` 或落盘源未接线)—— 面板据此如实
* 标注「更早的轮次未显示」;**不是**「已过保留期」(判据见 `timelineHints`)。 */
truncated: boolean;
/** 本页数据源:`persisted` / `live`(只拿到内存里的最近一轮)/ `none`;缺失 ⇒ `null`。 */
source: TimelineSource | null;
/** 首屏加载中(已有数据后翻页不算)。 */
loading: boolean;
/** 还有更早的回合可拉(短页 / 游标不可用 / 已拉空 ⇒ false)。 */
/** 还有更早的回合可拉(后端 `hasMore` 或老后端的短页启发式 ⇒ false 时停)。 */
canLoadMore: boolean;
/** 正在拉更早的一页。 */
loadingMore: boolean;
@@ -56,11 +70,16 @@ export interface FlowTimelineState {
reload: () => void;
}
interface TimelineData {
/** 取数层的内部状态(= 上面那个快照 + 归属会话与分页游标)。 */
export interface TimelineData {
sessionId: string;
rounds: AdminFlowTimelineRound[];
retentionHours: number | null;
retentionEnabled: boolean | null;
truncated: boolean;
source: TimelineSource | null;
/** 后端给的下一页游标(本页最早一轮的开始时刻);`null` = 没有 / 老后端没给。 */
nextBefore: string | null;
loading: boolean;
loadingMore: boolean;
unsupported: boolean;
@@ -70,12 +89,15 @@ interface TimelineData {
}
/** 空态初值(会话还没确定 / 视图还没切过来)。 */
function emptyState(sessionId: string): TimelineData {
export function emptyState(sessionId: string): TimelineData {
return {
sessionId,
rounds: [],
retentionHours: null,
retentionEnabled: null,
truncated: false,
source: null,
nextBefore: null,
loading: false,
loadingMore: false,
unsupported: false,
@@ -85,6 +107,25 @@ function emptyState(sessionId: string): TimelineData {
};
}
/** 首屏该不该拉(effect 的守卫;抽成纯函数以便测试直接钉住 —— 本项目没有 jsdom)。
*
* 不拉的情形只有一种:**这个会话已经拿到过回合,且没失败、也不是老后端**(切视图来回
* 不该每次都打后端;真要重拉走 `reload`)。其余(没数据 / 上次失败 / 老后端修复后)
* 一律要拉。 */
export function timelineNeedsFirstPage(known: TimelineData, target: string): boolean {
if (!target) return false;
if (known.sessionId !== target) return true;
if (known.failed || known.unsupported) return true;
return known.rounds.length === 0;
}
/** 能不能翻更早的一页(`loadMore` 的守卫):会话已定 + 后端说还有更早 + 不在加载中。 */
export function timelineCanLoadMore(state: TimelineData): boolean {
return (
Boolean(state.sessionId) && state.canLoadMore && !state.loadingMore && !state.loading
);
}
export function useFlowTimeline(sessionId: string, enabled: boolean): FlowTimelineState {
const target = enabled ? sessionId : "";
const [state, setState] = useState<TimelineData>(() => emptyState(""));
@@ -92,29 +133,29 @@ export function useFlowTimeline(sessionId: string, enabled: boolean): FlowTimeli
const stateRef = useRef(state);
stateRef.current = state;
// 首屏(会话变了 / 切到时间轴 / 手动重试)。已拉到数据的会话**不重复拉** —— 切视图来回
// 不该每次都打后端;真要重拉有 `reload`。
// 首屏(会话变了 / 切到时间轴 / 手动重试)。
useEffect(() => {
if (!target) return;
const known = stateRef.current;
if (known.sessionId === target && !known.failed && !known.unsupported && known.rounds.length > 0) {
return;
}
return fetchFirstPage(target, setState);
if (!target || !timelineNeedsFirstPage(stateRef.current, target)) return;
let cancelled = false;
// 卸载 / 换会话后到达的响应一律丢弃(与既有 hook 的 cleanup 同一口径)。
const send: Send = (patch) => setState((s) => (cancelled ? s : patch(s)));
void loadFirstPage(target, send);
return () => {
cancelled = true;
};
}, [target, seq]);
const loadMore = useCallback(() => {
const current = stateRef.current;
if (!current.sessionId || !current.canLoadMore || current.loadingMore || current.loading) return;
const sid = current.sessionId;
const cursor = timelineCursor(current.rounds);
if (!timelineCanLoadMore(current)) return;
const cursor = timelineCursorOf(current);
if (!cursor) {
// 最旧那一回合没有可用的开始时间 ⇒ 没法构造游标 ⇒ 停住(不瞎凑游标),
// 由界面如实说明「没有更早的」。
// 最旧那一回合既没有 `nextBefore`、开始时间也不可解析 ⇒ 没法构造游标 ⇒ 停住
//(不瞎凑游标),由界面如实说明「没有更早的」。
setState((s) => ({ ...s, canLoadMore: false }));
return;
}
fetchOlderPage(sid, cursor, setState);
void loadOlderPage(current.sessionId, cursor, setState);
}, []);
const reload = useCallback(() => {
@@ -123,75 +164,99 @@ export function useFlowTimeline(sessionId: string, enabled: boolean): FlowTimeli
setSeq((n) => n + 1);
}, []);
return flowTimelineViewState(state, loadMore, reload);
}
/** 内部取数状态 → **视图状态**(hook 的返回值就是这个映射)。
*
* 抽出来是为了让「真取数 → 真状态 → 真渲染」这条回路能在无 jsdom 的项目里跑完:
* 测试把**真 fetch 跑出来的状态**经这里映射后当 props 喂给 `FlowTimelineView`,于是
* 渲染断言针对的是「取数层真的会得到的状态」,而不是手写的替身。 */
export function flowTimelineViewState(
data: TimelineData,
loadMore: () => void,
reload: () => void
): FlowTimelineState {
return {
rounds: state.rounds,
total: state.rounds.length,
retentionHours: state.retentionHours,
truncated: state.truncated,
loading: state.loading,
canLoadMore: state.canLoadMore,
loadingMore: state.loadingMore,
unsupported: state.unsupported,
failed: state.failed,
moreFailed: state.moreFailed,
rounds: data.rounds,
total: data.rounds.length,
retentionHours: data.retentionHours,
retentionEnabled: data.retentionEnabled,
truncated: data.truncated,
source: data.source,
loading: data.loading,
canLoadMore: data.canLoadMore,
loadingMore: data.loadingMore,
unsupported: data.unsupported,
failed: data.failed,
moreFailed: data.moreFailed,
loadMore,
reload,
};
}
/** 取数体的类型(把「落进状态」这一动作注入,使下面两个取数函数不必认识 React)。 */
type Send = (patch: (prev: TimelineData) => TimelineData) => void;
export type Send = (patch: (prev: TimelineData) => TimelineData) => void;
/** 首屏取数:先置加载态,再把「一页 + 还能不能翻」落进状态;失败按 `classifyTimelineFailure`
* 分「老后端」与「这次没读到」两支。返回取消函数(effect 的 cleanup)。 */
function fetchFirstPage(target: string, send: Send): () => void {
let cancelled = false;
* 分「老后端」与「这次没读到」两支。
*
* ⚠️ 它**不自带取消**:取消由调用方(effect 的 cleanup)用带守卫的 `send` 表达 —— 这样
* 同一份逻辑既能在 hook 里跑,也能在测试里被 `await`(本项目没有 jsdom / 没有测试运行器)。 */
export async function loadFirstPage(target: string, send: Send): Promise<void> {
send(() => ({ ...emptyState(target), loading: true }));
adminFlowTimeline(target, { limit: TIMELINE_PAGE_SIZE })
.then((resp) => {
if (cancelled) return;
const page = timelinePageOf(resp);
send(() => ({
...emptyState(target),
...page,
canLoadMore: pageMayHaveOlder(page.rounds.length, TIMELINE_PAGE_SIZE),
}));
})
.catch((err: unknown) => {
if (cancelled) return;
const mark = classifyTimelineFailure(err);
send(() => ({
...emptyState(target),
unsupported: mark === "unsupported",
failed: mark === "failed",
}));
});
return () => {
cancelled = true;
};
try {
const resp = await adminFlowTimeline(target, { limit: TIMELINE_PAGE_SIZE });
const page = timelinePageOf(resp);
send(() => ({
...emptyState(target),
rounds: page.rounds,
retentionHours: page.retentionHours,
retentionEnabled: page.retentionEnabled,
truncated: page.truncated,
source: page.source,
nextBefore: page.nextBefore,
canLoadMore: pageHasOlder(page.hasMore, page.rounds.length, TIMELINE_PAGE_SIZE),
}));
} catch (err: unknown) {
const mark = classifyTimelineFailure(err);
send(() => ({
...emptyState(target),
unsupported: mark === "unsupported",
failed: mark === "failed",
}));
}
}
/** 取更早的一页:合并去重、保留期取「已拿到的优先」、`truncated` 一旦为真就不再回落
* (过一个保留期边界就够了);失败只影响翻页,**已拿到的回合照旧显示**。 */
function fetchOlderPage(sid: string, cursor: string, send: Send): void {
/** 取更早的一页:合并去重、保留期取「已拿到的优先」;失败只影响翻页,**已拿到的回合照旧
* 显示**。
*
* ⚠️ `truncated` **逐页覆盖,不累积** —— 它描述的是**最近这次响应**「是不是全部」,
* 而不是「历史上曾经缺过」(翻到底之后它就该消失;累积会让面板在翻到底后仍说
* 「更早的轮次已过保留期」)。
*
* `cursor` 由 `timelineCursorOf` 给出(优先后端的 `nextBefore`)。 */
export async function loadOlderPage(sid: string, cursor: string, send: Send): Promise<void> {
send((s) => (s.sessionId === sid ? { ...s, loadingMore: true, moreFailed: false } : s));
adminFlowTimeline(sid, { limit: TIMELINE_PAGE_SIZE, before: cursor })
.then((resp) => {
const page = timelinePageOf(resp);
send((s) => {
if (s.sessionId !== sid) return s;
const next = appendTimelinePage(s.rounds, page.rounds, TIMELINE_PAGE_SIZE);
return {
...s,
rounds: next.rounds,
retentionHours: page.retentionHours ?? s.retentionHours,
truncated: s.truncated || page.truncated,
loadingMore: false,
canLoadMore: next.canLoadMore,
};
});
})
.catch(() => {
send((s) => (s.sessionId === sid ? { ...s, loadingMore: false, moreFailed: true } : s));
try {
const resp = await adminFlowTimeline(sid, { limit: TIMELINE_PAGE_SIZE, before: cursor });
const page = timelinePageOf(resp);
send((s) => {
if (s.sessionId !== sid) return s;
const next = appendTimelinePage(s.rounds, page.rounds, TIMELINE_PAGE_SIZE, page.hasMore);
return {
...s,
rounds: next.rounds,
retentionHours: page.retentionHours ?? s.retentionHours,
retentionEnabled: page.retentionEnabled ?? s.retentionEnabled,
truncated: page.truncated,
source: page.source ?? s.source,
nextBefore: page.nextBefore ?? s.nextBefore,
loadingMore: false,
canLoadMore: next.canLoadMore,
};
});
} catch {
send((s) => (s.sessionId === sid ? { ...s, loadingMore: false, moreFailed: true } : s));
}
}
+27 -7
View File
@@ -116,16 +116,33 @@ export interface AdminFlowTimelineRound {
lanes: AdminFlowLane[];
}
/** 链路**时间轴**端点的响应(`design/19 §7.1`;W1 新增的只读出口)。 */
/** 链路**时间轴**端点的响应(契约 v0.80 §4.11.1;W1 的新出口)。
*
* 🔴 **`truncated` 不等于「已过保留期」**(契约 §4.11.1(b).3 的原话):它的含义是
* **「本次不是全部」** = `hasMore`(分页未到底)**或** 落盘源未接线(只剩内存最近一轮)。
* 「更早的回合已过保留期」这句话的判据只能是 `retentionEnabled` + `retentionHours` +
* `truncated` **三者一起读**,**不得**因为「返回的回合比想象中少」就断言被清扫了
* —— 否则面板会对着一页**仍然可翻**的历史说「已过保留期」(本切片核出的成对契约缺陷)。 */
export interface AdminFlowTimelineResponse {
/** 解析后的会话(回显)。取数只认调用时给的 `sessionId`,不依赖这一键。 */
sessionId?: string;
/** 本页**至少一轮**来自落盘 `persisted` / 本页**全部**来自内存 `live`(落盘源未接线,
* 或该会话还没有落盘轮)/ `none`(没有任何回合)。 */
source?: "persisted" | "live" | "none";
/** 保留期内的回合。后端按**时间正序**(最旧在前)返回;面板渲染前自己归一排序。 */
rounds: AdminFlowTimelineRound[];
/** `flow_graphs` 的保留期(小时)。`≤0` = 后端配置为不回收(见 `FLOW_GRAPH_RETENTION`)。 */
retentionHours: number;
/** **更早的回合已过保留期**(`design/19 §7.1`)⇒ 面板必须**如实标注**,不许静默少块。 */
/** 本页**最早一轮**的开始时刻;`hasMore` 为真时**原样回带**成下一次请求的 `before`
* 即可连续向更早翻页(游标精度到毫秒)。没有回合时是空串。 */
nextBefore?: string;
/** 库里**还有更早的回合**没被本次返回(= 还能继续翻页)。**这是唯一权威的翻页判据**;
* 面板不要用「本页是否满页」去猜(满页的最后一页会被猜成「还有更早」,白打一次请求)。 */
hasMore?: boolean;
/** 本次**不是全部**:`hasMore`(分页未到底)或落盘源未接线。**不是**「已过保留期」。 */
truncated: boolean;
/** `flow_graphs` 的保留期(小时,**下取整**)。`≤0` = 后端配置为不回收。 */
retentionHours: number;
/** 保留期清扫**是否启用**。`false` ⇒ 「更早的回合」**不是**被清扫掉的,别说成过期。 */
retentionEnabled?: boolean;
}
/** GET /api/v1/admin/flow/timeline?sessionId=&limit=&before= — **多轮时间轴**(`design/19 §7`;
@@ -133,9 +150,12 @@ export interface AdminFlowTimelineResponse {
* **增量兄弟**:一次给**多轮**,每轮连同它**每一路**的图,供面板画「所有消息按时间顺序串
* 起来」的时间轴。
*
* 分页:`limit` 缺省 50(后端硬上限 200);`before` 是**开始时间游标** —— 传「当前手上
* 最旧那一回合的 `startedAt`(原样回传后端给的值)」即继续取更早的。⚠️ 本函数**不解释**
* `before`(不做字符串/毫秒换算),只原样转交:面板的游标就是后端给的时间字符串。
* 分页(契约 §4.11.1(a)):`limit` 缺省 50(后端硬上限 200);`before` 是**开始时间游标**
* —— 「开始时刻**严格早于**它」的回合才返回(省略 = 最新那一页)。游标取响应里的
* `nextBefore`(= 本页最早一轮的开始时刻)**原样回带**即可连续翻页,**不重不漏**。
* ⚠️ 本函数**不解释** `before`(不做字符串/毫秒换算),只原样转交:面板的游标就是后端
* 给的时间字符串。⚠️ **也不要**用「本页是否满页」代替 `hasMore`:满页的最后一页会被误
* 判成「还有更早的」,白打一次请求(`hasMore` 才是权威判据)。
*
* 老后端(该出口未接线)⇒ `404`;调用方按「当前只支持最近一轮」诚实降级,别把 404 读成
* 「这段时间没有回合」。 */