import { act, render } from "@testing-library/react"; import { useRef } from "react"; import { anchorDuration, anchorTop, IDLE_SECONDS, isPlainPrimaryClick, JUMP_SCREENS, MAX_ANCHOR_SECONDS, samePageHashTarget, useCinematicScroll, } from "./useCinematicScroll"; /* * A rolagem da home sem biblioteca de animação por quadro: as passagens entre cenas são CSS; este hook só * liga o Lenis (mouse) e leva os links internos até o destino. Aqui: o hook montado (Lenis trocado por um * dublê que registra o que recebe) e as funções puras que ele usa. */ // o GSAP e o ScrollTrigger saíram da página: se alguém voltar a importá-los, estes dublês quebram 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, short: 3000, about: 3300, contact: 25000, }; function Home() { const ref = useRef(null); useCinematicScroll(ref); return (
Conversar Projetos

Projetos

Curta

Sobre

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 mount() { const view = render(); // a importação dinâmica do Lenis resolve em microtarefas await act(async () => { for (let i = 0; i < 10; i++) await Promise.resolve(); }); return view; } /** Quadros de animação pedidos pelo hook desde que o espião foi criado. */ let frames: jest.SpyInstance; const advance = (ms: number) => act(() => { jest.advanceTimersByTime(ms); }); 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(() => { jest.useFakeTimers(); frames = jest.spyOn(window, "requestAnimationFrame"); 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("anchor travel", () => { it("jumps straight to far anchors and animates near ones for 0.8 s at most", () => { expect(MAX_ANCHOR_SECONDS).toBe(0.8); const viewport = 800; expect(anchorDuration(25000, viewport)).toBeNull(); expect(anchorDuration(-25000, viewport)).toBeNull(); expect( anchorDuration(viewport * (JUMP_SCREENS + 0.5), viewport), ).toBeNull(); for (const distance of [0, 200, 800, 1600, viewport * JUMP_SCREENS]) { const seconds = anchorDuration(distance, viewport); expect(seconds).not.toBeNull(); expect(seconds as number).toBeGreaterThan(0); expect(seconds as number).toBeLessThanOrEqual(MAX_ANCHOR_SECONDS); } }); it("takes longer for farther anchors, up to the cap, and the same time up or down", () => { const near = anchorDuration(200, 800) as number; const far = anchorDuration(1600, 800) as number; expect(far).toBeGreaterThan(near); expect(anchorDuration(-1600, 800)).toBe(far); expect(anchorDuration(0, 0)).not.toBeNaN(); }); }); describe("isPlainPrimaryClick", () => { const click = (init: MouseEventInit = {}) => new MouseEvent("click", { cancelable: true, button: 0, ...init }); it("accepts a plain click of the main button", () => { expect(isPlainPrimaryClick(click())).toBe(true); }); it.each(["metaKey", "ctrlKey", "shiftKey", "altKey"] as const)( "refuses a click with %s held (new tab, selection)", (modifier) => { expect(isPlainPrimaryClick(click({ [modifier]: true }))).toBe(false); }, ); it.each([1, 2])("refuses mouse button %i", (button) => { expect(isPlainPrimaryClick(click({ button }))).toBe(false); }); it("refuses a click somebody else already cancelled", () => { const event = click(); event.preventDefault(); expect(isPlainPrimaryClick(event)).toBe(false); }); }); describe("samePageHashTarget", () => { afterEach(() => { document.body.innerHTML = ""; }); function clickOn(markup: string, selector: string) { document.body.innerHTML = markup; const target = document.querySelector(selector) as Element; const event = new MouseEvent("click", { bubbles: true }); Object.defineProperty(event, "target", { value: target }); return event; } it("finds the element a same-page #link points to", () => { const event = clickOn( `ir
`, "a", ); const hit = samePageHashTarget(event); expect(hit?.hash).toBe("#alvo"); expect(hit?.target.id).toBe("alvo"); }); it("works from inside the link (the text, an icon)", () => { const event = clickOn( `ir
`, "#inside", ); expect(samePageHashTarget(event)?.target.id).toBe("alvo"); }); it.each([ ["another site", `x`], ["another page of this site", `x`], ["no anchor", `x`], ["an empty anchor", `x`], ["a new tab", `x`], ["an id that does not exist", `x`], ["a malformed percent-encoding", `x`], ["no link at all", `

x

`], ])("returns null for %s", (_label, markup) => { const event = clickOn( `${markup}
`, markup.startsWith(" { const event = clickOn( `x
`, "a", ); expect(samePageHashTarget(event)?.target.id).toBe("alvo"); }); it("ignores a click whose target is not an element", () => { const event = new MouseEvent("click", { bubbles: true }); Object.defineProperty(event, "target", { value: document }); expect(samePageHashTarget(event)).toBeNull(); }); }); describe("anchorTop", () => { const scenes = () => { document.body.innerHTML = `

t

x

`; return [...document.querySelectorAll("section")]; }; const byId = (id: string) => document.getElementById(id) as HTMLElement; afterEach(() => { document.body.innerHTML = ""; jest.restoreAllMocks(); }); function headerOffset(px: string) { jest .spyOn(window, "getComputedStyle") .mockReturnValue({ scrollPaddingTop: px } as CSSStyleDeclaration); } it("is the scene top when the target is the whole scene (the header offset is ignored)", () => { const all = scenes(); headerOffset("80px"); expect(anchorTop(byId("a"), all)).toBe(1800); }); it("is the target minus the fixed header when it is inside a scene", () => { const all = scenes(); headerOffset("80px"); expect(anchorTop(byId("a-title"), all)).toBe(1900 - 80); }); it("never goes above the top of the scene", () => { const all = scenes(); headerOffset("300px"); expect(anchorTop(byId("a-title"), all)).toBe(1800); }); it("measures from the top of the page, minus the header, for a target outside every scene", () => { const all = scenes(); headerOffset("100px"); expect(anchorTop(byId("loose"), all)).toBe(9000 - 100); }); it("treats a missing header offset as zero", () => { const all = scenes(); headerOffset(""); expect(anchorTop(byId("a-title"), all)).toBe(1900); }); }); describe("useCinematicScroll", () => { it("never hides, pins or restyles a scene: the passages are CSS, nothing inline", async () => { await mount(); for (const element of document.querySelectorAll( "main > section", )) { expect(element.getAttribute("style")).toBeNull(); } const contact = document.getElementById("contact") as HTMLElement; expect(contact).toBeVisible(); expect(contact).toHaveTextContent("Contato"); }); it("does no scroll work for users who prefer reduced motion, and leaves anchor clicks to the browser", async () => { mockMedia(["(prefers-reduced-motion: reduce)"]); await mount(); const link = document.querySelector('a[href="/#projects"]') as HTMLElement; const event = new MouseEvent("click", { bubbles: true, cancelable: true }); act(() => { link.dispatchEvent(event); }); expect(event.defaultPrevented).toBe(false); expect(mockLenis).toHaveLength(0); expect(window.scrollTo).not.toHaveBeenCalled(); }); describe("smooth scroll", () => { it("is not created on a touch screen: the package is not even loaded, and the native scroll does the work", async () => { mockMedia(["(pointer: coarse)"]); await mount(); expect(mockLenis).toHaveLength(0); expect(frames).not.toHaveBeenCalled(); act(() => { window.dispatchEvent(new Event("wheel")); window.dispatchEvent(new Event("scroll")); }); advance(500); expect(frames).not.toHaveBeenCalled(); }); it("is created with a mouse, with the smoothing options", async () => { await mount(); expect(mockLenis).toHaveLength(1); expect(mockLenis[0].options).toEqual({ lerp: 0.075, wheelMultiplier: 0.85, }); }); it("feeds Lenis in milliseconds while scrolling, and lets the animation clock go once idle", async () => { await mount(); const lenis = mockLenis[0]; // rolando: segue ligado, a cada quadro lenis.isScrolling = "smooth"; advance(500); const whileScrolling = lenis.raf.mock.calls.length; expect(whileScrolling).toBeGreaterThan(20); expect(lenis.raf.mock.calls.at(-1)?.[0]).toBeGreaterThan(400); // parado: depois de IDLE_SECONDS o relógio é solto de vez (nenhum quadro de animação pedido) lenis.isScrolling = false; advance(IDLE_SECONDS * 1000 + 200); const stopped = lenis.raf.mock.calls.length; const requested = frames.mock.calls.length; advance(3000); expect(lenis.raf.mock.calls.length).toBe(stopped); expect(frames.mock.calls.length).toBe(requested); }); it("wakes up again with the wheel or any scroll event, and a second event does not start a second clock", async () => { await mount(); const lenis = mockLenis[0]; advance(IDLE_SECONDS * 1000 + 500); lenis.raf.mockClear(); act(() => { window.dispatchEvent(new Event("wheel")); window.dispatchEvent(new Event("scroll")); }); advance(100); // um laço só: cerca de um quadro a cada 16 ms expect(lenis.raf.mock.calls.length).toBeGreaterThan(0); expect(lenis.raf.mock.calls.length).toBeLessThanOrEqual(8); }); it("jumps straight to a far anchor (the header button to #contact) and animates a near one for 0.8 s at most", async () => { await mount(); const lenis = mockLenis[0]; act(() => { (document.querySelector('a[href="/#contact"]') as HTMLElement).click(); }); expect(lenis.scrollTo).toHaveBeenCalledTimes(1); const [farTop, farOptions] = lenis.scrollTo.mock.calls[0]; expect(farTop).toBe(TOPS.contact); expect(farOptions).toMatchObject({ immediate: true, duration: 0 }); expect(window.location.hash).toBe("#contact"); expect(document.getElementById("contact")).toHaveFocus(); act(() => { (document.querySelector('a[href="/#projects"]') as HTMLElement).click(); }); const [nearTop, nearOptions] = lenis.scrollTo.mock.calls[1]; expect(nearTop).toBe(TOPS.projects); expect(nearOptions.immediate).toBe(false); expect(nearOptions.duration).toBeGreaterThan(0); expect(nearOptions.duration).toBeLessThanOrEqual(MAX_ANCHOR_SECONDS); }); it("wakes the clock before an anchor scroll, so the animation actually runs after a long rest", async () => { await mount(); const lenis = mockLenis[0]; advance(IDLE_SECONDS * 1000 + 500); lenis.raf.mockClear(); act(() => { (document.querySelector('a[href="/#projects"]') as HTMLElement).click(); }); advance(100); expect(lenis.scrollTo).toHaveBeenCalledTimes(1); expect(lenis.raf).toHaveBeenCalled(); }); it("without Lenis (touch) a far anchor is an instant jump and a near one is a native smooth scroll", async () => { mockMedia(["(pointer: coarse)"]); await mount(); act(() => { (document.querySelector('a[href="/#contact"]') as HTMLElement).click(); }); expect(window.scrollTo).toHaveBeenCalledWith({ top: TOPS.contact, behavior: "instant", }); act(() => { (document.querySelector('a[href="/#projects"]') as HTMLElement).click(); }); expect(window.scrollTo).toHaveBeenLastCalledWith({ top: TOPS.projects, behavior: "smooth", }); }); it("releases Lenis, the clock and the listeners when the page unmounts", async () => { const view = await mount(); const lenis = mockLenis[0]; view.unmount(); expect(lenis.destroy).toHaveBeenCalledTimes(1); lenis.raf.mockClear(); const requested = frames.mock.calls.length; act(() => { window.dispatchEvent(new Event("wheel")); window.dispatchEvent(new Event("scroll")); }); advance(200); expect(lenis.raf).not.toHaveBeenCalled(); expect(frames.mock.calls.length).toBe(requested); }); it("creates nothing when the page unmounted before the package finished loading", async () => { const view = render(); view.unmount(); await act(async () => { for (let i = 0; i < 10; i++) await Promise.resolve(); }); expect(mockLenis).toHaveLength(0); expect(frames).not.toHaveBeenCalled(); }); }); });