import { renderHook, act } from '@testing-library/react'; import { RefObject } from 'react'; import useActiveThumbnailRect from '../useActiveThumbnailRect'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Build a ref whose .current points to a real (detached) div. */ const makeCloneRef = (): RefObject => { const clone = document.createElement('div'); return { current: clone } as unknown as RefObject; }; /** * Create a source thumbnail element (with the private data attribute) and * append it to the document so querySelectorAll can find it. */ const makeSource = ( rectOverrides: Partial, bgImage: string, ): HTMLElement => { const el = document.createElement('div'); el.setAttribute('data-adl-thumbnail-active', 'true'); el.style.backgroundImage = bgImage; el.getBoundingClientRect = jest.fn( () => ({ top: 0, left: 0, width: 100, height: 100, bottom: 100, right: 100, x: 0, y: 0, toJSON: jest.fn(), ...rectOverrides, }) as DOMRect, ); document.body.appendChild(el); return el; }; // --------------------------------------------------------------------------- // Suite // --------------------------------------------------------------------------- describe('useActiveThumbnailRect', () => { // Track the most-recently registered RAF callback so tests can trigger it. let pendingRaf: FrameRequestCallback | null = null; beforeEach(() => { pendingRaf = null; jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { pendingRaf = cb; return 1; }); jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(jest.fn()); jest .spyOn(window, 'getComputedStyle') .mockReturnValue({ borderRadius: '4px' } as unknown as CSSStyleDeclaration); }); afterEach(() => { jest.restoreAllMocks(); // Remove any source elements added to the DOM. document .querySelectorAll('[data-adl-thumbnail-active]') .forEach((el) => el.remove()); }); /** Run one RAF tick synchronously inside act(). */ const flush = () => act(() => { const cb = pendingRaf; pendingRaf = null; cb?.(0); }); // ------------------------------------------------------------------------- it('hides the clone when no active thumbnail element exists in the DOM', () => { const cloneRef = makeCloneRef(); renderHook(() => useActiveThumbnailRect(cloneRef, null)); flush(); expect(cloneRef.current!.style.display).toBe('none'); }); it('shows the clone and copies position + image from the source element', () => { makeSource({ top: 50, left: 120, width: 200, height: 150 }, 'url("thumb.jpg")'); const cloneRef = makeCloneRef(); renderHook(() => useActiveThumbnailRect(cloneRef, null)); flush(); const clone = cloneRef.current!; expect(clone.style.display).toBe('block'); expect(clone.style.top).toBe('50px'); expect(clone.style.left).toBe('120px'); expect(clone.style.width).toBe('200px'); expect(clone.style.height).toBe('150px'); // jsdom normalises url("x") → url(x) when reading back from element.style. expect(clone.style.backgroundImage).toBe('url(thumb.jpg)'); }); it('copies the border-radius from getComputedStyle of the source', () => { jest .spyOn(window, 'getComputedStyle') .mockReturnValue({ borderRadius: '12px' } as unknown as CSSStyleDeclaration); makeSource({}, 'url("r.jpg")'); const cloneRef = makeCloneRef(); renderHook(() => useActiveThumbnailRect(cloneRef, null)); flush(); expect(cloneRef.current!.style.borderRadius).toBe('12px'); }); it('hides the clone when the source element has no backgroundImage yet', () => { makeSource({ top: 10, left: 10, width: 80, height: 80 }, ''); const cloneRef = makeCloneRef(); renderHook(() => useActiveThumbnailRect(cloneRef, null)); flush(); expect(cloneRef.current!.style.display).toBe('none'); }); it('updates the clone on every RAF tick, tracking position changes', () => { const source = makeSource({ top: 10, left: 10, width: 100, height: 100 }, 'url("x.jpg")'); const cloneRef = makeCloneRef(); renderHook(() => useActiveThumbnailRect(cloneRef, null)); flush(); // first tick expect(cloneRef.current!.style.top).toBe('10px'); // Simulate the element moving (e.g. carousel sliding). (source.getBoundingClientRect as jest.Mock).mockReturnValue({ top: 200, left: 400, width: 100, height: 100, bottom: 300, right: 500, x: 400, y: 200, toJSON: jest.fn(), }); flush(); // second tick expect(cloneRef.current!.style.top).toBe('200px'); expect(cloneRef.current!.style.left).toBe('400px'); }); describe('when multiple candidates exist (infinite-scroll clones)', () => { it('picks the element whose horizontal centre falls inside the viewport', () => { // jsdom viewport is 1024×768. The off-screen clone's centre is at –150px. const offScreen = document.createElement('div'); offScreen.setAttribute('data-adl-thumbnail-active', 'true'); offScreen.style.backgroundImage = 'url("wrong.jpg")'; offScreen.getBoundingClientRect = jest.fn( () => ({ left: -200, width: 100, top: 0, height: 100, bottom: 100, right: -100, x: -200, y: 0, toJSON: jest.fn(), }) as DOMRect, ); document.body.appendChild(offScreen); // The real visible slide's centre is at 250px — inside the viewport. const onScreen = document.createElement('div'); onScreen.setAttribute('data-adl-thumbnail-active', 'true'); onScreen.style.backgroundImage = 'url("correct.jpg")'; onScreen.getBoundingClientRect = jest.fn( () => ({ left: 200, width: 100, top: 10, height: 100, bottom: 110, right: 300, x: 200, y: 10, toJSON: jest.fn(), }) as DOMRect, ); document.body.appendChild(onScreen); const cloneRef = makeCloneRef(); renderHook(() => useActiveThumbnailRect(cloneRef, null)); flush(); expect(cloneRef.current!.style.backgroundImage).toBe('url(correct.jpg)'); expect(cloneRef.current!.style.left).toBe('200px'); }); }); it('cancels the animation frame when the hook unmounts', () => { const cloneRef = makeCloneRef(); const { unmount } = renderHook(() => useActiveThumbnailRect(cloneRef, null)); unmount(); expect(window.cancelAnimationFrame).toHaveBeenCalled(); }); it('cancels the previous loop and starts a new one when dep changes', () => { const cancelSpy = window.cancelAnimationFrame as jest.Mock; const cloneRef = makeCloneRef(); const { rerender } = renderHook( ({ dep }: { dep: unknown }) => useActiveThumbnailRect(cloneRef, dep), { initialProps: { dep: 'post-a' } }, ); const callsBefore = cancelSpy.mock.calls.length; act(() => rerender({ dep: 'post-b' })); // The effect cleanup must have fired cancelAnimationFrame. expect(cancelSpy.mock.calls.length).toBeGreaterThan(callsBefore); }); });