// 「最新在上」日志面的滚动锚定逻辑测试(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)`);