Files
Cyrene_v2/frontend/web/src/admin/logScroll.test.ts
T
AskaEth a3482ed06e
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(admin): 日志类列表统一「最新在上」+ 顶部前插不顶跑阅读位置
- 审计 15 个日志/事件面后统一口径:改 3 个(渠道总日志 / 每账号消息日志弹窗 / 适配器专属页实时日志),7 个本已合规未动,聊天页**刻意保持正序**(本质是读对话,正序+自动滚底才自然)
- 排序口径唯一出处 channelLogOrder.ts(历史+WS 追帧按 id 去重、时间倒序)+ 展示层不倒序(与 channelEventViews 同分工);三面共用 useChannelMessageLog 取数
- 新增 logScroll.ts:贴顶跟着最新走、已下读按新增高度补偿(**新条目不把正在看的内容顶跑**);窗口化容器的滚动锚定一并接上
- 后端无需改(三处消息日志读取本就是 created_at DESC,已核)
- 诚实边界:滚动锚定的 DOM 那半只有纯函数有自动化证据(项目无 jsdom/Playwright,渲染测试跑 react-dom/server、effect 不执行)⇒ 已如实标注
- 变异实测:排序改回旧→新 / 取消补偿 / 渲染层再倒一次 / 改坏共享文案 —— 四处均打红
- ⚠️ 两处未改(在别的切片文件面内,已转交):flow/NodeDetail 节点实时日志 → 归 F2;Agents 任务日志 → 归 H1
2026-10-05 15:37:04 +08:00

100 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 「最新在上」日志面的滚动锚定逻辑测试(L1「日志界面倒序统一」)。
//
// 锁两件事(都是主人报过痛的那一类):
// ① 顶部前插新条目时,**已经往下读**的用户不能被顶跑 —— scrollTop 必须按新增高度
// 补偿,正在看的那几条留在原地;
// ② 贴在顶部的用户要跟着最新一条走(保持贴顶),不能停在半路。
// 顺序与滚动在浏览器里「画出来才算数」,但位置数学是纯函数:项目没有前端测试框架,
// 这里用零依赖的 plain-node 检查直接钉住它。
//
// 跑法(用 vite 自带的 esbuild,不新增依赖、不改 package.json):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/logScroll.test.ts \
// --bundle --platform=node --format=esm \
// --outfile=/tmp/cyrene-log-scroll.test.mjs \
// && node /tmp/cyrene-log-scroll.test.mjs
//
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它
// 只从项目内导入、不 import node:*。
import { LOG_TOP_EPSILON, initialLogScroll, isAtLogTop, topAnchoredScrollTop } from "./logScroll";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function same(got: number, want: number, what: string): void {
ok(got === want, `${what} — got ${got}, want ${want}`);
}
// ── ① 「贴着顶部」的判定(容差内算贴顶)────────────────────────────────────────
ok(initialLogScroll().atTop, "初始快照按「贴着顶部」处理(首屏不该被当成在读旧内容)");
same(initialLogScroll().scrollTop, 0, "初始滚动位置为 0");
same(initialLogScroll().contentHeight, 0, "初始内容高度为 0(还没测量)");
ok(isAtLogTop(0), "scrollTop=0 是贴顶");
ok(isAtLogTop(LOG_TOP_EPSILON), "容差边缘(=LOG_TOP_EPSILON)仍算贴顶");
ok(!isAtLogTop(LOG_TOP_EPSILON + 1), "超出容差就不算贴顶");
// ── ② 贴顶用户:新条目落在顶部 ⇒ 保持贴顶(最新一条立刻可见)──────────────────
same(
topAnchoredScrollTop({ scrollTop: 0, contentHeight: 1000, atTop: true }, 1180),
0,
"贴顶时保持 0:新条目就在眼前,不该被挪到半路",
);
same(
topAnchoredScrollTop({ scrollTop: 3, contentHeight: 1000, atTop: true }, 900),
0,
"贴顶且内容反而变短时也回到顶部(不留在悬空位置)",
);
// ── ③ 已经往下读的用户:按新增高度补偿 ⇒ 阅读位置不动 ─────────────────────────
same(
topAnchoredScrollTop({ scrollTop: 400, contentHeight: 1000, atTop: false }, 1180),
580,
"顶部前插 180px ⇒ scrollTop 加 180,正在看的内容留在原地",
);
same(
topAnchoredScrollTop({ scrollTop: 400, contentHeight: 1000, atTop: false }, 1000),
400,
"没有新条目(高度没变)⇒ 位置一点不动",
);
same(
topAnchoredScrollTop({ scrollTop: 400, contentHeight: 1000, atTop: false }, 820),
400,
"内容变短时不补偿:不编造一个不存在的位移",
);
// ── ④ 端到端语义:被阅读的那一行在补偿前后都停在视口同样的位置 ────────────────
// 先把「用户停在 scrollTop=300」这件事换算成「某一行距列表顶 300px」;顶部前插 3 行
// (每行 76px = 228px)后,这一行跑到 528px —— 补偿后它必须仍然正好在视口顶。
const anchored = topAnchoredScrollTop({ scrollTop: 300, contentHeight: 5000, atTop: false }, 5228);
same(anchored, 528, "补偿后 scrollTop 等于该行的新绝对位置(视口顶仍是同一行)");
let scrollTop = 300;
let contentHeight = 5000;
for (let frame = 0; frame < 3; frame += 1) {
const grew = 76;
scrollTop = topAnchoredScrollTop({ scrollTop, contentHeight, atTop: false }, contentHeight + grew);
contentHeight += grew;
}
same(scrollTop, 300 + 3 * 76, "连来三帧(每次前插 1 行)后累计补偿 3 行高度");
ok(scrollTop >= 0, "补偿永远不会把 scrollTop 推到负数");
// ── ⑤ 贴顶 / 已下读两种用户的对照:同一份新数据,两种正确的行为 ────────────────
const frame = { contentHeight: 1000 } as const;
same(
topAnchoredScrollTop({ scrollTop: 0, contentHeight: frame.contentHeight, atTop: true }, 1240),
0,
"贴顶用户:跟住最新一条",
);
same(
topAnchoredScrollTop({ scrollTop: 600, contentHeight: frame.contentHeight, atTop: false }, 1240),
840,
"已下读用户:留在原来那几条上",
);
console.log(`log scroll test: ${checks} checks passed(初始快照 + 贴顶判定 6 + 贴顶行为 2 + 下读补偿 3 + 连续帧累计 3 + 两态对照 2)`);