import { act, render, renderHook } from "@testing-library/react"; import { type RefObject, useRef } from "react"; import { renderToString } from "react-dom/server"; import { MOBILE_QUERY } from "@/lib/device"; import { NEAR_MARGIN, PINNED_QUERY, useNearViewport, usePinnedViewport, useSaveData, useViewportDevice, } from "./useShowcaseMedia"; /** * Testes de caminhos-base dos hooks de mídia da vitrine: aparelho (celular x desktop x sem matchMedia), * vitrine fixa cabe ou não, "economizar dados" e a proximidade com a janela (com e sem IntersectionObserver). */ const originalMatchMedia = window.matchMedia; const OriginalObserver = global.IntersectionObserver; type MediaListener = () => void; type FakeQuery = { query: string; matches: boolean; listeners: Set; }; let queries: FakeQuery[] = []; /** matchMedia falso: `matching` diz quais consultas valem; cada chamada devolve um objeto que guarda os ouvintes. */ function mockMedia(matching: string[]) { queries = []; window.matchMedia = jest.fn().mockImplementation((query: string) => { const fake: FakeQuery = { query, matches: matching.includes(query), listeners: new Set(), }; queries.push(fake); return { get matches() { return fake.matches; }, media: query, addEventListener: (_type: string, listener: MediaListener) => fake.listeners.add(listener), removeEventListener: (_type: string, listener: MediaListener) => fake.listeners.delete(listener), }; }); } function mockNavigator(values: Record) { for (const [key, value] of Object.entries(values)) Object.defineProperty(navigator, key, { configurable: true, value }); } afterEach(() => { window.matchMedia = originalMatchMedia; global.IntersectionObserver = OriginalObserver; mockNavigator({ connection: undefined }); }); describe("useViewportDevice", () => { it("path: server render (no effect yet) -> null, so no media file is chosen", () => { function Probe() { return {String(useViewportDevice())}; } expect(renderToString()).toContain("null"); }); it("path: browser without matchMedia -> desktop", () => { // @ts-expect-error simula um navegador sem matchMedia window.matchMedia = undefined; const { result } = renderHook(() => useViewportDevice()); expect(result.current).toBe("desktop"); }); it("path: phone width matches -> mobile", () => { mockMedia([MOBILE_QUERY]); const { result } = renderHook(() => useViewportDevice()); expect(result.current).toBe("mobile"); expect(queries.map((q) => q.query)).toEqual([MOBILE_QUERY]); }); it("path: phone width does not match -> desktop", () => { mockMedia([]); const { result } = renderHook(() => useViewportDevice()); expect(result.current).toBe("desktop"); }); it("path: the window is resized across the breakpoint -> follows both ways", () => { mockMedia([]); const { result } = renderHook(() => useViewportDevice()); expect(result.current).toBe("desktop"); act(() => { queries[0].matches = true; for (const listener of queries[0].listeners) listener(); }); expect(result.current).toBe("mobile"); act(() => { queries[0].matches = false; for (const listener of queries[0].listeners) listener(); }); expect(result.current).toBe("desktop"); }); it("path: unmount -> the media listener is removed", () => { mockMedia([]); const { unmount } = renderHook(() => useViewportDevice()); expect(queries[0].listeners.size).toBe(1); unmount(); expect(queries[0].listeners.size).toBe(0); }); }); describe("usePinnedViewport", () => { it("path: server render -> null until the browser mounts", () => { function Probe() { return {String(usePinnedViewport())}; } expect(renderToString()).toContain("null"); }); it("path: browser without matchMedia -> the pinned reel is allowed", () => { // @ts-expect-error simula um navegador sem matchMedia window.matchMedia = undefined; const { result } = renderHook(() => usePinnedViewport()); expect(result.current).toBe(true); }); it("path: the screen is wide and tall enough -> true, using the shared query", () => { mockMedia([PINNED_QUERY]); const { result } = renderHook(() => usePinnedViewport()); expect(result.current).toBe(true); expect(queries.map((q) => q.query)).toEqual([PINNED_QUERY]); expect(PINNED_QUERY).toBe("(min-width: 768px) and (min-height: 561px)"); }); it("path: the screen is too small (phone or short window) -> false", () => { mockMedia([]); const { result } = renderHook(() => usePinnedViewport()); expect(result.current).toBe(false); }); it("path: the window changes size -> follows, and unmount removes the listener", () => { mockMedia([]); const { result, unmount } = renderHook(() => usePinnedViewport()); expect(result.current).toBe(false); act(() => { queries[0].matches = true; for (const listener of queries[0].listeners) listener(); }); expect(result.current).toBe(true); unmount(); expect(queries[0].listeners.size).toBe(0); }); }); describe("useSaveData", () => { it.each([ ["saveData on", { saveData: true }, true], ["saveData off", { saveData: false }, false], ["no connection info", undefined, false], ])("path: %s -> %s", (_label, connection, expected) => { mockNavigator({ connection }); const { result } = renderHook(() => useSaveData()); expect(result.current).toBe(expected); }); it("path: server render -> false (the lightest assumption is not 'save data')", () => { mockNavigator({ connection: { saveData: true } }); function Probe() { return {String(useSaveData())}; } expect(renderToString()).toContain("false"); }); }); describe("useNearViewport", () => { type Entry = { isIntersecting: boolean }; let callbacks: Array<(entries: Entry[]) => void> = []; let observers: Array<{ observe: jest.Mock; disconnect: jest.Mock; options?: IntersectionObserverInit; }> = []; beforeEach(() => { callbacks = []; observers = []; global.IntersectionObserver = class { observe = jest.fn(); disconnect = jest.fn(); constructor( callback: (entries: Entry[]) => void, options?: IntersectionObserverInit, ) { callbacks.push(callback); observers.push({ observe: this.observe, disconnect: this.disconnect, options, }); } } as unknown as typeof IntersectionObserver; }); function Probe({ attach = true }: { attach?: boolean }) { const ref = useRef(null); const near = useNearViewport(ref as RefObject); return (
{attach &&
} {String(near)}
); } it("path: element far from the window -> false, observing it with a one-screen margin", () => { const view = render(); expect(view.container.querySelector("output")).toHaveTextContent("false"); expect(observers).toHaveLength(1); expect(observers[0].options).toEqual({ rootMargin: NEAR_MARGIN }); expect(observers[0].observe).toHaveBeenCalledWith( view.getByTestId("target"), ); }); it("path: the observer reports nothing intersecting -> still false", () => { const view = render(); act(() => callbacks[0]([{ isIntersecting: false }])); expect(view.container.querySelector("output")).toHaveTextContent("false"); }); it("path: the element gets within a screen -> true, and it stays true when it leaves again", () => { const view = render(); act(() => callbacks[0]([{ isIntersecting: false }, { isIntersecting: true }]), ); expect(view.container.querySelector("output")).toHaveTextContent("true"); act(() => callbacks[0]([{ isIntersecting: false }])); expect(view.container.querySelector("output")).toHaveTextContent("true"); }); it("path: no ref target -> no observer is created and the answer stays false", () => { const view = render(); expect(observers).toHaveLength(0); expect(view.container.querySelector("output")).toHaveTextContent("false"); }); it("path: browser without IntersectionObserver -> assumes near so the poster still loads", () => { // @ts-expect-error simula um navegador sem IntersectionObserver global.IntersectionObserver = undefined; const view = render(); expect(view.container.querySelector("output")).toHaveTextContent("true"); }); it("path: unmount -> the observer is disconnected", () => { const view = render(); view.unmount(); expect(observers[0].disconnect).toHaveBeenCalledTimes(1); }); });