import { act, render } from "@testing-library/react"; import { useRef } from "react"; import { useCinematicScroll } from "./useCinematicScroll"; /** * Testes de caminhos-base de useCinematicScroll (complemento de useCinematicScroll.test.tsx), com o hook montado * numa página de verdade: * - cliques em âncoras: cada guarda que devolve o clique ao navegador, alvo dentro de uma cena x a própria cena * x fora de qualquer cena, desconto do cabeçalho, pushState só quando a âncora muda, foco no alvo; * - salto direto (acima de 3 telas) x rolagem de até 0,8 s, nos limites exatos, com Lenis e sem ele (toque); * - ref vazia, movimento reduzido e a limpeza dos ouvintes ao desmontar. * O Lenis é trocado por um dublê que registra o que recebe; o GSAP nem existe mais (se alguém o importar, o * dublê abaixo quebra o teste). */ jest.mock( "gsap", () => { throw new Error("gsap não deve ser carregado"); }, { virtual: true }, ); jest.mock( "gsap/ScrollTrigger", () => { throw new Error("gsap/ScrollTrigger não deve ser carregado"); }, { virtual: true }, ); const mockLenis: MockLenis[] = []; class MockLenis { isScrolling: false | "smooth" = false; raf = jest.fn(); scrollTo = jest.fn(); destroy = jest.fn(); constructor(public options: unknown) { mockLenis.push(this); } } jest.mock("lenis", () => ({ __esModule: true, get default() { return MockLenis; }, })); const TOPS: Record = { home: 0, projects: 1800, "projects-title": 1900, edge: 2400, contact: 25000, "footer-note": 26000, }; function Home({ attach = true }: { attach?: boolean }) { const ref = useRef(null); useCinematicScroll(ref); return ( <>
contato projetos titulo dos projetos limite nota fora da cena fantasma malformado outra pagina outro site sem ancora ancora vazia nova aba mesma aba

texto

Projetos

Limite

{/* biome-ignore lint/a11y/noNoninteractiveTabindex: alvo de âncora que já traz tabindex, para testar que ele é mantido */}

Contato

); } const originalMatchMedia = window.matchMedia; const originalOffsetTop = Object.getOwnPropertyDescriptor( HTMLElement.prototype, "offsetTop", ); function mockMedia(active: string[]) { window.matchMedia = jest.fn().mockImplementation((query: string) => ({ matches: active.includes(query), media: query, addEventListener() {}, removeEventListener() {}, })); } async function flushImports() { await act(async () => { for (let i = 0; i < 10; i++) await Promise.resolve(); }); } async function mount(props: { attach?: boolean } = {}) { const view = render(); await flushImports(); return view; } function link(text: string) { const found = [...document.querySelectorAll("a, svg a")].find( (element) => element.textContent === text, ); if (!found) throw new Error(`Missing link ${text}`); return found as Element; } /** * Dispara um clique e diz se o hook cancelou a ação padrão. A ação padrão do navegador (navegar) é cancelada * por um ouvinte na fase de bolha, depois de o hook ter decidido, para o jsdom não navegar de verdade. */ function click( target: Element | Document, init: MouseEventInit = {}, ): { prevented: boolean } { const event = new MouseEvent("click", { bubbles: true, cancelable: true, button: 0, ...init, }); let prevented = false; const observe = (clickEvent: Event) => { prevented = clickEvent.defaultPrevented; clickEvent.preventDefault(); }; window.addEventListener("click", observe); try { act(() => { target.dispatchEvent(event); }); } finally { window.removeEventListener("click", observe); } return { prevented }; } function mockHeaderOffset(px: string) { jest .spyOn(window, "getComputedStyle") .mockReturnValue({ scrollPaddingTop: px } as CSSStyleDeclaration); } beforeAll(() => { Object.defineProperty(HTMLElement.prototype, "offsetTop", { configurable: true, get(this: HTMLElement) { return Number(this.dataset.top ?? 0); }, }); }); afterAll(() => { if (originalOffsetTop) Object.defineProperty( HTMLElement.prototype, "offsetTop", originalOffsetTop, ); }); beforeEach(() => { mockLenis.length = 0; mockMedia([]); window.history.replaceState(null, "", "/"); jest.spyOn(window, "scrollTo").mockImplementation(() => {}); Object.defineProperty(window, "innerHeight", { configurable: true, value: 800, }); Object.defineProperty(window, "scrollY", { configurable: true, value: 0 }); }); afterEach(() => { window.matchMedia = originalMatchMedia; jest.useRealTimers(); jest.restoreAllMocks(); }); describe("useCinematicScroll: cliques que voltam ao navegador", () => { it.each(["metaKey", "ctrlKey", "shiftKey", "altKey"] as const)( "path: click with %s held -> left alone (new tab / selection behavior)", async (modifier) => { await mount(); const { prevented } = click(link("contato"), { [modifier]: true }); expect(prevented).toBe(false); expect(mockLenis[0].scrollTo).not.toHaveBeenCalled(); }, ); it.each([1, 2])( "path: click with mouse button %i -> left alone", async (button) => { await mount(); const { prevented } = click(link("contato"), { button }); expect(prevented).toBe(false); expect(mockLenis[0].scrollTo).not.toHaveBeenCalled(); }, ); it("path: another handler already cancelled the click -> not handled again", async () => { await mount(); const cancel = (event: Event) => event.preventDefault(); window.addEventListener("click", cancel, true); try { click(link("contato")); } finally { window.removeEventListener("click", cancel, true); } expect(mockLenis[0].scrollTo).not.toHaveBeenCalled(); }); it("path: click on text that is not inside a link -> ignored", async () => { await mount(); const { prevented } = click(document.getElementById("plain") as Element); expect(prevented).toBe(false); expect(mockLenis[0].scrollTo).not.toHaveBeenCalled(); }); it("path: the click target is the document itself (not an element) -> ignored", async () => { await mount(); const { prevented } = click(document); expect(prevented).toBe(false); expect(mockLenis[0].scrollTo).not.toHaveBeenCalled(); }); it("path: the closest link is an SVG anchor -> ignored", async () => { await mount(); const { prevented } = click( document.querySelector("svg a text") as Element, ); expect(prevented).toBe(false); expect(mockLenis[0].scrollTo).not.toHaveBeenCalled(); }); it("path: link opens in a new tab -> ignored", async () => { await mount(); const { prevented } = click(link("nova aba")); expect(prevented).toBe(false); expect(mockLenis[0].scrollTo).not.toHaveBeenCalled(); }); it("path: link with target=_self -> handled like a plain link", async () => { await mount(); const { prevented } = click(link("mesma aba")); expect(prevented).toBe(true); expect(mockLenis[0].scrollTo).toHaveBeenCalledTimes(1); }); it.each([ ["to another site", "outro site"], ["to another page of this site", "outra pagina"], ["without an anchor", "sem ancora"], ["with an empty anchor (#)", "ancora vazia"], ])("path: link %s -> ignored", async (_label, text) => { await mount(); const { prevented } = click(link(text)); expect(prevented).toBe(false); expect(mockLenis[0].scrollTo).not.toHaveBeenCalled(); }); it("path: anchor with broken percent-encoding -> no error, left to the browser", async () => { await mount(); const { prevented } = click(link("malformado")); expect(prevented).toBe(false); expect(mockLenis[0].scrollTo).not.toHaveBeenCalled(); }); it("path: anchor to an id that does not exist -> left to the browser", async () => { await mount(); const { prevented } = click(link("fantasma")); expect(prevented).toBe(false); expect(mockLenis[0].scrollTo).not.toHaveBeenCalled(); }); }); describe("useCinematicScroll: para onde a página vai", () => { it("path: target inside a scene -> goes to the target minus the fixed header, never above the scene top", async () => { await mount(); mockHeaderOffset("80px"); click(link("titulo dos projetos")); expect(mockLenis[0].scrollTo.mock.calls[0][0]).toBe(1900 - 80); }); it("path: header taller than the gap to the scene top -> clamped to the scene top", async () => { await mount(); mockHeaderOffset("300px"); click(link("titulo dos projetos")); expect(mockLenis[0].scrollTo.mock.calls[0][0]).toBe(1800); }); it("path: target is the whole scene -> the scene top, ignoring the header offset", async () => { await mount(); mockHeaderOffset("80px"); click(link("projetos")); expect(mockLenis[0].scrollTo.mock.calls[0][0]).toBe(1800); }); it("path: target outside every scene -> measured from the page top, minus the header", async () => { await mount(); mockHeaderOffset("100px"); const { prevented } = click(link("nota fora da cena")); expect(prevented).toBe(true); expect(mockLenis[0].scrollTo.mock.calls[0][0]).toBe(26000 - 100); }); it("path: no readable header offset -> treated as zero", async () => { await mount(); mockHeaderOffset(""); click(link("titulo dos projetos")); expect(mockLenis[0].scrollTo.mock.calls[0][0]).toBe(1900); }); }); describe("useCinematicScroll: histórico e foco", () => { it("path: a new anchor -> pushes it to the history and cancels the default jump", async () => { await mount(); const push = jest.spyOn(window.history, "pushState"); const { prevented } = click(link("projetos")); expect(prevented).toBe(true); expect(push).toHaveBeenCalledWith(null, "", "#projects"); expect(window.location.hash).toBe("#projects"); }); it("path: the anchor is already the current one -> the history is not touched, the page still scrolls", async () => { window.history.replaceState(null, "", "/#projects"); await mount(); const push = jest.spyOn(window.history, "pushState"); click(link("projetos")); expect(push).not.toHaveBeenCalled(); expect(mockLenis[0].scrollTo).toHaveBeenCalledTimes(1); }); it("path: destination without tabindex -> gets tabindex -1 and the focus (no scroll jump)", async () => { await mount(); const focus = jest.spyOn(HTMLElement.prototype, "focus"); const target = document.getElementById("projects-title") as HTMLElement; expect(target.hasAttribute("tabindex")).toBe(false); click(link("titulo dos projetos")); expect(target.getAttribute("tabindex")).toBe("-1"); expect(focus).toHaveBeenCalledWith({ preventScroll: true }); expect(document.activeElement).toBe(target); }); it("path: destination that already has a tabindex -> kept as it was", async () => { await mount(); const target = document.getElementById("contact") as HTMLElement; expect(target.getAttribute("tabindex")).toBe("0"); click(link("contato")); expect(target.getAttribute("tabindex")).toBe("0"); expect(document.activeElement).toBe(target); }); }); describe("useCinematicScroll: salto direto x rolagem curta", () => { it("path: anchor exactly 3 screens away -> still animated, capped at 0.8 s", async () => { await mount(); click(link("limite")); // 2400 px = 3 telas de 800 px const [, options] = mockLenis[0].scrollTo.mock.calls[0]; expect(options.immediate).toBe(false); expect(options.duration).toBe(0.8); }); it("path: anchor just beyond 3 screens -> instant jump with zero duration", async () => { await mount(); Object.defineProperty(window, "innerHeight", { configurable: true, value: 799, }); click(link("limite")); // 2400 px passam de 3 x 799 const [, options] = mockLenis[0].scrollTo.mock.calls[0]; expect(options.immediate).toBe(true); expect(options.duration).toBe(0); }); it("path: anchor already at the current position -> a minimal animation (0.3 s), not a jump", async () => { Object.defineProperty(window, "scrollY", { configurable: true, value: 1800, }); await mount(); click(link("projetos")); const [top, options] = mockLenis[0].scrollTo.mock.calls[0]; expect(top).toBe(1800); expect(options.immediate).toBe(false); expect(options.duration).toBeCloseTo(0.3, 9); }); it("path: scrolled far down, a near anchor above -> distance counts in absolute value", async () => { Object.defineProperty(window, "scrollY", { configurable: true, value: 2400, }); await mount(); click(link("projetos")); // 600 px acima const [, options] = mockLenis[0].scrollTo.mock.calls[0]; expect(options.immediate).toBe(false); expect(options.duration).toBeCloseTo(0.3 + (600 / 800) * 0.2, 9); }); it("path: touch screen (no Lenis) -> the native scroll is used: smooth when near, instant when far", async () => { mockMedia(["(pointer: coarse)"]); await mount(); click(link("projetos")); click(link("contato")); expect(window.scrollTo).toHaveBeenNthCalledWith(1, { top: 1800, behavior: "smooth", }); expect(window.scrollTo).toHaveBeenNthCalledWith(2, { top: 25000, behavior: "instant", }); }); }); describe("useCinematicScroll: montagem e desmontagem", () => { it("path: ref not attached to an element -> the hook does nothing", async () => { await mount({ attach: false }); expect(mockLenis).toHaveLength(0); const { prevented } = click(link("projetos")); expect(prevented).toBe(false); }); it("path: reduced motion -> no Lenis, and each anchor click is an instant jump, never an animation", async () => { mockMedia(["(prefers-reduced-motion: reduce)"]); await mount(); const first = click(link("projetos")); const second = click(link("projetos")); expect(first.prevented).toBe(true); expect(second.prevented).toBe(true); expect(mockLenis).toHaveLength(0); expect(window.scrollTo).toHaveBeenCalledTimes(2); for (const [options] of (window.scrollTo as jest.Mock).mock.calls) expect(options).toMatchObject({ behavior: "instant" }); }); it("path: touch -> anchors are still handled (the click listener does not wait for any package)", async () => { mockMedia(["(pointer: coarse)"]); const view = render(); // sem esperar importação nenhuma: o clique já é tratado const { prevented } = click(link("projetos")); expect(prevented).toBe(true); expect(window.scrollTo).toHaveBeenCalledTimes(1); expect(mockLenis).toHaveLength(0); view.unmount(); }); it("path: unmount -> the document click listener and the wheel/scroll listeners are removed", async () => { const view = await mount(); const removeDocument = jest.spyOn(document, "removeEventListener"); const removeWindow = jest.spyOn(window, "removeEventListener"); view.unmount(); expect(removeDocument).toHaveBeenCalledWith( "click", expect.any(Function), true, ); expect(removeWindow.mock.calls.map(([type]) => type)).toEqual( expect.arrayContaining(["wheel", "scroll"]), ); }); it("path: after unmount an anchor click is no longer intercepted", async () => { const view = await mount(); const target = link("projetos"); view.unmount(); document.body.append(target); const { prevented } = click(target); expect(prevented).toBe(false); target.remove(); }); });