import { act, render } from "@testing-library/react"; import { ScrollTrigger } from "gsap/ScrollTrigger"; import { useRef } from "react"; import { IDLE_SECONDS, useCinematicScroll } from "./useCinematicScroll"; /** * Testes de caminhos-base de useCinematicScroll (complemento de useCinematicScroll.test.tsx): * - onClick (â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; * - criação do Lenis (opções, ligação com o ScrollTrigger), relógio que dorme e acorda, desmontagem antes das * importações terminarem, ref vazia, e o ResizeObserver que remede as passagens com atraso. * GSAP, ScrollTrigger e Lenis são trocados por fakes que registram o que recebem. */ type Call = unknown[]; const mockTimelines: Array<{ calls: Call[] }> = []; const mockTicker = { add: jest.fn(), remove: jest.fn(), lagSmoothing: jest.fn(), time: 0, }; const mockLenis: MockLenis[] = []; class MockLenis { isScrolling: false | "smooth" = false; on = jest.fn(); raf = jest.fn(); scrollTo = jest.fn(); destroy = jest.fn(); constructor(public options: unknown) { mockLenis.push(this); } } jest.mock("gsap", () => ({ gsap: { registerPlugin: jest.fn(), ticker: mockTicker, context: (fn: () => void) => { fn(); return { revert: jest.fn() }; }, timeline: () => { const record = { calls: [] as Call[] }; mockTimelines.push(record); const chain = { fromTo: (...args: Call) => { record.calls.push(args); return chain; }, }; return chain; }, }, })); jest.mock("gsap/ScrollTrigger", () => ({ ScrollTrigger: { update: jest.fn(), refresh: jest.fn() }, })); 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 OriginalResizeObserver = global.ResizeObserver; 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(() => { mockTimelines.length = 0; mockLenis.length = 0; mockTicker.add.mockClear(); mockTicker.remove.mockClear(); mockTicker.lagSmoothing.mockClear(); mockTicker.time = 0; jest.mocked(ScrollTrigger.update).mockClear(); jest.mocked(ScrollTrigger.refresh).mockClear(); 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; global.ResizeObserver = OriginalResizeObserver; 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: Lenis e o relógio do GSAP", () => { it("path: mouse -> Lenis gets the smoothing options and drives ScrollTrigger; the GSAP lag smoothing is turned off", async () => { await mount(); expect(mockLenis).toHaveLength(1); expect(mockLenis[0].options).toEqual({ lerp: 0.075, wheelMultiplier: 0.85, }); expect(mockLenis[0].on).toHaveBeenCalledWith( "scroll", ScrollTrigger.update, ); expect(mockTicker.lagSmoothing).toHaveBeenCalledWith(0); expect(mockTicker.add).toHaveBeenCalledTimes(1); }); it("path: touch -> no Lenis, no ticker, no lag-smoothing change and the passages are still created", async () => { mockMedia(["(pointer: coarse)"]); await mount(); expect(mockLenis).toHaveLength(0); expect(mockTicker.add).not.toHaveBeenCalled(); expect(mockTicker.lagSmoothing).not.toHaveBeenCalled(); expect(mockTimelines.length).toBeGreaterThan(0); }); it("path: touch -> window scroll events do not touch the clock", async () => { mockMedia(["(pointer: coarse)"]); await mount(); act(() => { window.dispatchEvent(new Event("scroll")); window.dispatchEvent(new Event("wheel")); }); expect(mockTicker.add).not.toHaveBeenCalled(); }); it("path: the clock went to sleep, then a native scroll event -> wakes it", async () => { await mount(); const tick = mockTicker.add.mock.calls[0][0] as (time: number) => void; tick(IDLE_SECONDS + 1); // parado há mais que o tempo de espera expect(mockTicker.remove).toHaveBeenCalledTimes(1); mockTicker.time = 5; act(() => { window.dispatchEvent(new Event("scroll")); }); expect(mockTicker.add).toHaveBeenCalledTimes(2); }); it("path: the clock went to sleep, then an anchor click -> wakes it before scrolling", async () => { await mount(); const tick = mockTicker.add.mock.calls[0][0] as (time: number) => void; tick(IDLE_SECONDS + 1); click(link("projetos")); expect(mockTicker.add).toHaveBeenCalledTimes(2); expect(mockLenis[0].scrollTo).toHaveBeenCalledTimes(1); }); it("path: the clock is already asleep and the page unmounts -> it is not removed twice", async () => { const view = await mount(); const tick = mockTicker.add.mock.calls[0][0] as (time: number) => void; tick(IDLE_SECONDS + 1); expect(mockTicker.remove).toHaveBeenCalledTimes(1); view.unmount(); expect(mockTicker.remove).toHaveBeenCalledTimes(1); expect(mockLenis[0].destroy).toHaveBeenCalledTimes(1); }); it("path: Lenis still scrolling -> the tick keeps the clock awake and feeds it in milliseconds", async () => { await mount(); const tick = mockTicker.add.mock.calls[0][0] as (time: number) => void; mockLenis[0].isScrolling = "smooth"; tick(IDLE_SECONDS * 10); tick(IDLE_SECONDS * 10 + IDLE_SECONDS / 2); expect(mockLenis[0].raf).toHaveBeenLastCalledWith( (IDLE_SECONDS * 10 + IDLE_SECONDS / 2) * 1000, ); expect(mockTicker.remove).not.toHaveBeenCalled(); }); }); describe("useCinematicScroll: montagem e desmontagem", () => { it("path: unmounted before the dynamic imports finish -> nothing is created", async () => { const view = render(); view.unmount(); await flushImports(); expect(mockTimelines).toHaveLength(0); expect(mockLenis).toHaveLength(0); expect(mockTicker.add).not.toHaveBeenCalled(); }); it("path: ref not attached to an element -> the hook does nothing", async () => { await mount({ attach: false }); expect(mockTimelines).toHaveLength(0); expect(mockLenis).toHaveLength(0); }); it("path: reduced motion -> nothing, and anchor clicks are left to the browser", async () => { mockMedia(["(prefers-reduced-motion: reduce)"]); await mount(); const { prevented } = click(link("projetos")); expect(prevented).toBe(false); expect(mockLenis).toHaveLength(0); }); 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"]), ); }); }); describe("useCinematicScroll: remedida depois de a página mudar de altura", () => { type Resize = { callback: () => void; disconnect: jest.Mock }; let resizers: Resize[] = []; beforeEach(() => { resizers = []; global.ResizeObserver = class { private readonly record: Resize; constructor(callback: () => void) { this.record = { callback, disconnect: jest.fn() }; resizers.push(this.record); } observe() {} unobserve() {} disconnect() { this.record.disconnect(); } } as unknown as typeof ResizeObserver; }); it("path: several size changes in a row -> one refresh, 150 ms after the last", async () => { jest.useFakeTimers(); await mount(); act(() => { resizers[0].callback(); jest.advanceTimersByTime(100); resizers[0].callback(); jest.advanceTimersByTime(149); }); expect(ScrollTrigger.refresh).not.toHaveBeenCalled(); act(() => { jest.advanceTimersByTime(1); }); expect(ScrollTrigger.refresh).toHaveBeenCalledTimes(1); }); it("path: unmount with a refresh pending -> the refresh never happens and the observer is disconnected", async () => { jest.useFakeTimers(); const view = await mount(); act(() => { resizers[0].callback(); }); view.unmount(); act(() => { jest.advanceTimersByTime(1000); }); expect(ScrollTrigger.refresh).not.toHaveBeenCalled(); expect(resizers[0].disconnect).toHaveBeenCalledTimes(1); }); });