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
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 读成
|
||||
* 「这段时间没有回合」。 */
|
||||
|
||||
Reference in new issue
Block a user