/** * hash-scroll 测试:钉住锚点跳转的解码与手动偏移滚动行为。 * 黑盒驱动:只通过 window.__scrollToHash 入口。 * * 现在用 window.scrollTo 手动扣除 sticky header 高度(不再依赖 scrollIntoView + * scroll-margin-top,因为浏览器原生 fragment-scroll 对后者的应用时序不稳定)。 * * 布局稳定期(ResizeObserver)测试:钉住异步布局位移(mermaid 懒加载注入 SVG、 * 图片/字体加载撑高内容)后落点会重新校正,且用户主动滚动后停止校正。 */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import './index'; // --- ResizeObserver mock:happy-dom 不实现,手动提供回调钩子 --- let resizeCallback: | ((entries: { target: Element; contentRect: { width: number; height: number } }[]) => void) | null = null; const roDisconnect = vi.fn(); vi.stubGlobal( 'ResizeObserver', class { constructor(cb: typeof resizeCallback) { resizeCallback = cb; } observe() {} disconnect() { roDisconnect(); } }, ); describe('scrollToHash', () => { beforeEach(() => { document.body.innerHTML = ''; history.replaceState(null, '', '#'); vi.restoreAllMocks(); resizeCallback = null; roDisconnect.mockClear(); }); afterEach(() => { document.body.innerHTML = ''; history.replaceState(null, '', '#'); }); it('hash 为 CJK 百分号编码时解码后命中并滚动', () => { const heading = document.createElement('h2'); heading.id = '三-五-零法则'; document.body.appendChild(heading); const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); history.replaceState(null, '', `#${encodeURIComponent(heading.id)}`); window.__scrollToHash(); expect(spy).toHaveBeenCalled(); }); it('hash 为纯 ASCII 时直接命中', () => { const heading = document.createElement('h2'); heading.id = 'getting-started'; document.body.appendChild(heading); const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); history.replaceState(null, '', '#getting-started'); window.__scrollToHash(); expect(spy).toHaveBeenCalled(); }); it('滚动落点扣除 sticky header 高度', () => { // 模拟 sticky header:getBoundingClientRect 返回 height 80。 const header = document.createElement('header'); header.className = 'sticky'; header.getBoundingClientRect = () => ({ top: 0, left: 0, right: 0, bottom: 80, width: 1000, height: 80, x: 0, y: 0, toJSON() {}, }) as DOMRect; document.body.appendChild(header); // 目标标题:放在 scrollY=1000 处(getBoundingClientRect.top 反映相对视口位置)。 const heading = document.createElement('h2'); heading.id = 'mid'; heading.getBoundingClientRect = () => ({ top: 1000, left: 0, right: 0, bottom: 1050, width: 800, height: 50, x: 0, y: 1000, toJSON() {}, }) as DOMRect; document.body.appendChild(heading); // scrollY 参与 scrollTo 目标计算:top = rect.top + scrollY - headerH - offset。 Object.defineProperty(window, 'scrollY', { value: 500, writable: true, configurable: true }); const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); history.replaceState(null, '', '#mid'); window.__scrollToHash(); // 第一帧调用(smooth):top = 1000 + 500 - 80 - 16 = 1404 expect(spy).toHaveBeenCalled(); const firstCall = spy.mock.calls[0][0] as ScrollToOptions; expect(firstCall.top).toBe(1000 + 500 - 80 - 16); expect(firstCall.behavior).toBe('smooth'); }); it('hash 为空时不滚动也不报错', () => { const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); history.replaceState(null, '', '#'); expect(() => window.__scrollToHash()).not.toThrow(); expect(spy).not.toHaveBeenCalled(); }); it('目标元素不存在时不报错', () => { const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); history.replaceState(null, '', '#no-such-heading'); expect(() => window.__scrollToHash()).not.toThrow(); expect(spy).not.toHaveBeenCalled(); }); // ---------- 布局稳定期(ResizeObserver) ---------- // 复现 bug 根因:mermaid 懒加载等异步位移源发生在两帧校正之后,撑高内容把标题 // 推离落点。稳定期监听内容容器 resize,位移后重新校正一次。 /** 构造一个带 .post-content 容器和带 id 标题的 DOM,用于稳定期测试。 */ function setupPostWithHeading(headingId: string): { root: HTMLElement; heading: HTMLElement } { const root = document.createElement('div'); root.className = 'post-content'; const heading = document.createElement('h2'); heading.id = headingId; heading.getBoundingClientRect = () => ({ top: 1000, left: 0, right: 0, bottom: 1050, width: 800, height: 50, x: 0, y: 0, toJSON() {}, }) as DOMRect; root.appendChild(heading); document.body.appendChild(root); Object.defineProperty(window, 'scrollY', { value: 500, writable: true, configurable: true }); return { root, heading }; } it('内容容器 resize 后再次校正落点(异步布局位移修正)', async () => { vi.useFakeTimers(); const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); setupPostWithHeading('mermaid-tu-biao'); history.replaceState(null, '', '#mermaid-tu-biao'); window.__scrollToHash(); // 推进两帧校正的 rAF(fake rAF 周期 ~16ms),不触发 2s 超时。 await vi.advanceTimersByTimeAsync(40); spy.mockClear(); expect(resizeCallback).not.toBeNull(); // 模拟 mermaid 注入 SVG 撑高内容:触发一次 resize。 resizeCallback!([ { target: document.querySelector('.post-content')!, contentRect: { width: 800, height: 2000 }, }, ]); // rAF 合并的校正在下一帧执行。 await vi.advanceTimersByTimeAsync(20); expect(spy).toHaveBeenCalled(); // 推进超时,让稳定器在本测试内自清理,避免泄漏到后续测试。 await vi.advanceTimersByTimeAsync(2000); vi.useRealTimers(); }); it('用户主动滚动(wheel)后停止校正,避免与用户交互打架', () => { vi.useFakeTimers(); vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); setupPostWithHeading('h2'); history.replaceState(null, '', '#h2'); window.__scrollToHash(); expect(roDisconnect).not.toHaveBeenCalled(); // 用户主动滚动:派发 wheel,应触发 dispose(RO 断开、监听移除、超时清除)。 window.dispatchEvent(new WheelEvent('wheel')); expect(roDisconnect).toHaveBeenCalled(); // dispose 后 resize 不再触发新的稳定器:RO 已断开,回调不会再被调用。 expect(resizeCallback).not.toBeNull(); vi.useRealTimers(); }); it('重入安全:再次 scrollToHash 时旧稳定器被清理(不泄漏/不叠加)', () => { vi.useFakeTimers(); vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); setupPostWithHeading('h3'); history.replaceState(null, '', '#h3'); window.__scrollToHash(); expect(roDisconnect).not.toHaveBeenCalled(); // 第二次调用(模拟主题切换触发 use_effect 重跑)。 window.__scrollToHash(); expect(roDisconnect).toHaveBeenCalled(); vi.useRealTimers(); }); });