import { render, cleanup } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { useRef } from 'react'; import { useScrollWindow, getScrollStart, setScrollStart, type UseScrollWindowOptions } from '../use-scroll-window'; // #759: the mechanics shared by the freely scrolling calendar views. type IOCallback = (entries: Array<{ isIntersecting: boolean }>) => void; let observerCallbacks: IOCallback[] = []; let observed: Element[] = []; class FakeIntersectionObserver { constructor(cb: IOCallback) { observerCallbacks.push(cb); } observe(el: Element) { observed.push(el); } disconnect() {} unobserve() {} } type HarnessProps = Omit & { axis?: 'vertical' | 'horizontal'; scrollHeight?: number; onRender?: (api: ReturnType) => void; }; function Harness({ axis = 'vertical', scrollHeight = 1000, onRender, ...options }: HarnessProps) { const scrollRef = useRef(null); const startRef = useRef(null); const endRef = useRef(null); const api = useScrollWindow({ ...options, axis, scrollRef, startSentinelRef: startRef, endSentinelRef: endRef }); onRender?.(api); return (
); } describe('useScrollWindow', () => { beforeEach(() => { observerCallbacks = []; observed = []; vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver); }); afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); it('scrolls to the focus on mount, on navigation, and after the first load of a window', () => { const scrollToFocus = vi.fn(); const base = { isLoading: false, windowKey: 'w1', focusNonce: 0, scrollToFocus }; const { rerender } = render(); expect(scrollToFocus).toHaveBeenCalledTimes(1); rerender(); rerender(); expect(scrollToFocus).toHaveBeenCalledTimes(2); // Later loads (extensions) do not move the view ... rerender(); rerender(); expect(scrollToFocus).toHaveBeenCalledTimes(2); // ... a navigation does, and so does the first load of a fresh window. rerender(); expect(scrollToFocus).toHaveBeenCalledTimes(3); rerender(); rerender(); expect(scrollToFocus).toHaveBeenCalledTimes(4); }); it('arms the sentinels only after the first load and fires each side once per fetch', () => { const onExtendStart = vi.fn(); const onExtendEnd = vi.fn(); const base = { isLoading: false, windowKey: 'w1', focusNonce: 0, scrollToFocus: vi.fn(), onExtendStart, onExtendEnd }; const { rerender } = render(); expect(observed).toHaveLength(0); rerender(); rerender(); expect(observed).toHaveLength(2); // Two observers: start first, then end. observerCallbacks[0]([{ isIntersecting: true }]); expect(onExtendStart).toHaveBeenCalledTimes(1); observerCallbacks[1]([{ isIntersecting: true }]); expect(onExtendEnd).not.toHaveBeenCalled(); // one extension outstanding rerender(); expect(observed).toHaveLength(2); // nothing new observed while loading rerender(); observerCallbacks.at(-1)?.([{ isIntersecting: true }]); expect(onExtendEnd).toHaveBeenCalledTimes(1); }); it('keeps the visible content in place while the start side grows', () => { let api: ReturnType | null = null; const base = { isLoading: false, windowKey: 'w1', focusNonce: 0, scrollToFocus: vi.fn(), onExtendStart: vi.fn(), onRender: (a: ReturnType) => { api = a; } }; const { rerender, getByTestId } = render(); const scroller = getByTestId('scroller'); // jsdom has no layout: fake the scroll geometry. let height = 1000; Object.defineProperty(scroller, 'scrollHeight', { get: () => height, configurable: true }); scroller.scrollTop = 200; rerender(); rerender(); api!.requestStart(); expect(api!.pendingSide).toBeNull(); // state updates on the next render // The rows for the wider window render right away, before the fetch ... height = 1600; rerender(); expect(scroller.scrollTop).toBe(800); expect(api!.pendingSide).toBe('start'); // ... and grow again when their events arrive with the fetch. rerender(); height = 1650; rerender(); expect(scroller.scrollTop).toBe(850); expect(api!.pendingSide).toBeNull(); // Growth that is not a start extension leaves the position alone. height = 2000; rerender(); expect(scroller.scrollTop).toBe(850); }); it('reads and writes the logical scroll start in both directions', () => { const el = document.createElement('div'); document.body.appendChild(el); // computed styles need an attached element el.scrollLeft = 40; expect(getScrollStart(el, 'horizontal')).toBe(40); setScrollStart(el, 'horizontal', 70); expect(el.scrollLeft).toBe(70); el.style.direction = 'rtl'; setScrollStart(el, 'horizontal', 30); expect(el.scrollLeft).toBe(-30); expect(getScrollStart(el, 'horizontal')).toBe(30); setScrollStart(el, 'vertical', 12); expect(getScrollStart(el, 'vertical')).toBe(12); }); });