import { act, render } from "@testing-library/react"; import { useRef } from "react"; import { anchorDuration, ENTER_OFFSET, enterScene, exitScene, IDLE_SECONDS, JUMP_SCREENS, MAX_ANCHOR_SECONDS, PASSAGE_END, useCinematicScroll, } from "./useCinematicScroll"; /* ------------------------------------------------------------------------------------------------ * Passagem de verdade (GSAP real, sem ScrollTrigger): o que cada cena recebe no começo e no fim. * ------------------------------------------------------------------------------------------------ */ // o resto do arquivo troca o GSAP por um fake: aqui vale o de verdade const { gsap: realGsap } = jest.requireActual("gsap"); function scene() { const element = document.createElement("section"); document.body.append(element); return element; } function timeline() { return realGsap.timeline({ paused: true, defaults: { ease: "none", duration: 1 }, }); } const translateY = (element: HTMLElement) => Number( /translate(?:3d)?\(\s*[-\d.]+(?:px)?,\s*([-\d.]+)px/.exec( element.style.transform, )?.[1] ?? Number.NaN, ); describe("the single passage between scenes", () => { afterEach(() => { document.body.innerHTML = ""; }); it("brings the next scene in with a fade and a short vertical offset, nothing else", () => { expect(ENTER_OFFSET).toBeGreaterThanOrEqual(24); expect(ENTER_OFFSET).toBeLessThanOrEqual(40); const next = scene(); const tl = timeline(); enterScene(tl, next); tl.progress(0.0001); expect(Number(next.style.opacity)).toBeLessThan(0.01); expect(translateY(next)).toBeCloseTo(ENTER_OFFSET, 0); tl.progress(0.5); expect(Number(next.style.opacity)).toBeGreaterThan(0.2); expect(Number(next.style.opacity)).toBeLessThan(1); expect(translateY(next)).toBeGreaterThan(0); expect(translateY(next)).toBeLessThan(ENTER_OFFSET); // sem recorte, escala, giro nem visibility em nenhum ponto for (const progress of [0.0001, 0.3, 0.7]) { tl.progress(progress); expect(next.style.clipPath).toBe(""); expect(next.style.visibility).toBe(""); expect(next.style.transform).not.toMatch(/scale|rotate/); } }); it("leaves no inline style behind once the scene has arrived, and brings it back on the way up", () => { const next = scene(); const tl = timeline(); enterScene(tl, next); tl.progress(1); expect(next.style.opacity).toBe(""); expect(next.style.transform).toBe(""); tl.progress(0.4); expect(Number(next.style.opacity)).toBeLessThan(1); expect(next.style.transform).not.toBe(""); }); it("fades the previous scene all the way to opacity 0 at the end of every passage", () => { const previous = scene(); const tl = timeline(); exitScene(tl, previous); tl.progress(0.5); expect(Number(previous.style.opacity)).toBeGreaterThan(0); expect(Number(previous.style.opacity)).toBeLessThan(1); expect(previous.style.visibility).toBe(""); expect(previous.style.clipPath).toBe(""); tl.progress(1); expect(previous.style.opacity).toBe("0"); // rolando de volta, a cena reaparece inteira tl.progress(0.0001); expect(Number(previous.style.opacity)).toBeGreaterThan(0.99); }); it("a scene in the middle of the page arrives, stays, and leaves at 0 without fighting itself", () => { const middle = scene(); const arrive = timeline(); const leave = timeline(); enterScene(arrive, middle); exitScene(leave, middle); arrive.progress(1); expect(middle.style.opacity).toBe(""); leave.progress(1); expect(middle.style.opacity).toBe("0"); leave.progress(0); arrive.progress(0.2); expect(Number(middle.style.opacity)).toBeLessThan(0.5); }); }); 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); } }); }); /* ------------------------------------------------------------------------------------------------ * O hook: GSAP, ScrollTrigger e Lenis trocados por fakes que registram o que recebem. * ------------------------------------------------------------------------------------------------ */ type Call = unknown[]; const mockTimelines: Array<{ vars: ScrollTriggerLike; calls: Call[] }> = []; const mockTicker = { add: jest.fn(), remove: jest.fn(), lagSmoothing: jest.fn(), time: 0, }; const mockLenis: MockLenis[] = []; interface ScrollTriggerLike { scrollTrigger: { trigger: HTMLElement; start: () => number; end: () => number; scrub: boolean; onEnter?: () => void; }; } 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: (vars: ScrollTriggerLike) => { const record = { vars, 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, short: 3000, about: 3300, contact: 25000, }; function Home() { const ref = useRef(null); useCinematicScroll(ref); return (
Conversar Projetos

Projetos

{/* uma cena mais curta que a tela: a saída dela não pode começar antes de a entrada terminar */}

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(); // as importações dinâmicas do hook (gsap, ScrollTrigger, Lenis) resolvem em microtarefas await act(async () => { for (let i = 0; i < 10; i++) await Promise.resolve(); }); return view; } const scenes = () => Array.from(document.querySelectorAll("main > section")); 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; mockMedia([]); window.history.replaceState(null, "", "/"); jest.spyOn(window, "scrollTo").mockImplementation(() => {}); Object.defineProperty(window, "innerHeight", { configurable: true, value: 800, }); }); afterEach(() => { window.matchMedia = originalMatchMedia; jest.restoreAllMocks(); }); describe("useCinematicScroll", () => { it("never hides a scene: no visibility, no sticky pin, nothing inline before the scroll runs", async () => { await mount(); for (const element of scenes()) { expect(element.style.visibility).toBe(""); expect(element.style.position).toBe(""); expect(element.style.top).toBe(""); expect(element.style.opacity).toBe(""); expect(element.style.clipPath).toBe(""); } // o texto do contato continua achável (Ctrl+F) e visível para leitores de tela const contact = document.getElementById("contact") as HTMLElement; expect(contact).toBeVisible(); expect(contact).toHaveTextContent("Contato"); expect(contact.closest("[aria-hidden]")).toBeNull(); }); it("uses one passage only: opacity and a vertical offset, with the previous scene ending at 0", async () => { await mount(); const allowed = new Set([ "opacity", "y", "ease", "clearProps", "immediateRender", ]); const entering: HTMLElement[] = []; const leaving: HTMLElement[] = []; for (const { vars, calls } of mockTimelines) { expect(vars.scrollTrigger.scrub).toBe(true); expect(calls).toHaveLength(1); const [target, from, to] = calls[0] as [ HTMLElement, Record, Record, ]; for (const key of [...Object.keys(from), ...Object.keys(to)]) expect(allowed.has(key)).toBe(true); expect(JSON.stringify([from, to])).not.toMatch( /autoAlpha|visibility|clipPath|scale|rotation|xPercent/, ); if (to.opacity === 1) { entering.push(target); expect(from).toMatchObject({ opacity: 0, y: ENTER_OFFSET }); expect(vars.scrollTrigger.trigger).toBe(target); } else { leaving.push(target); expect(from).toMatchObject({ opacity: 1 }); expect(to.opacity).toBe(0); } } const all = scenes(); // todas entram, menos a primeira; todas saem, menos a última expect(entering).toEqual(all.slice(1)); expect(leaving).toEqual(all.slice(0, -1)); }); it("starts each exit only after the same scene finished entering, even for a scene shorter than the screen", async () => { await mount(); const all = scenes(); const ranges = new Map< HTMLElement, { enter?: [number, number]; exit?: [number, number] } >(); for (const { vars, calls } of mockTimelines) { const target = calls[0][0] as HTMLElement; const to = calls[0][2] as { opacity: number }; const { start, end } = vars.scrollTrigger; const entry = ranges.get(target) ?? {}; if (to.opacity === 1) entry.enter = [start(), end()]; else entry.exit = [start(), end()]; ranges.set(target, entry); } const vh = window.innerHeight; for (const element of all.slice(1)) { const top = TOPS[element.id]; expect(ranges.get(element)?.enter).toEqual([ top - vh, top - vh * PASSAGE_END, ]); } for (const element of all.slice(0, -1)) { const { enter, exit } = ranges.get(element) ?? {}; expect(exit).toBeDefined(); const [start, end] = exit as [number, number]; expect(start).toBeLessThan(end); // termina quando o topo da cena seguinte chegou ao fim da passagem dela const next = all[all.indexOf(element) + 1]; expect(end).toBe(TOPS[next.id] - vh * PASSAGE_END); if (enter) expect(start).toBeGreaterThanOrEqual(enter[1]); } // a cena curta (300 px): a saída começa onde a entrada acabou, não 500 px antes const short = ranges.get(document.getElementById("short") as HTMLElement); expect(short?.exit?.[0]).toBe(short?.enter?.[1]); }); it("does no scroll work for users who prefer reduced motion", async () => { mockMedia(["(prefers-reduced-motion: reduce)"]); await mount(); expect(mockTimelines).toHaveLength(0); expect(mockLenis).toHaveLength(0); expect(mockTicker.add).not.toHaveBeenCalled(); }); describe("smooth scroll", () => { it("is not created on a touch screen, where the passages still run on the native scroll", 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("is created with a mouse, and the GSAP clock sleeps once the scroll is idle and wakes on the wheel", async () => { await mount(); expect(mockLenis).toHaveLength(1); const lenis = mockLenis[0]; expect(mockTicker.add).toHaveBeenCalledTimes(1); const tick = mockTicker.add.mock.calls[0][0] as (time: number) => void; // rolando: segue ligado e alimenta o Lenis em milissegundos lenis.isScrolling = "smooth"; tick(0.5); expect(lenis.raf).toHaveBeenLastCalledWith(500); expect(mockTicker.remove).not.toHaveBeenCalled(); // parado há mais que o tempo de espera: o relógio é solto lenis.isScrolling = false; tick(0.5 + IDLE_SECONDS / 2); expect(mockTicker.remove).not.toHaveBeenCalled(); tick(0.5 + IDLE_SECONDS + 0.1); expect(mockTicker.remove).toHaveBeenCalledTimes(1); expect(mockTicker.remove).toHaveBeenCalledWith(tick); // a roda do mouse o acorda de novo mockTicker.time = 10; act(() => { window.dispatchEvent(new Event("wheel")); }); expect(mockTicker.add).toHaveBeenCalledTimes(2); // e uma roda seguinte, com o relógio já ligado, não o liga duas vezes act(() => { window.dispatchEvent(new Event("wheel")); }); expect(mockTicker.add).toHaveBeenCalledTimes(2); }); 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 }); 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("without Lenis (touch) a far anchor is an instant jump", 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 the clock, the listeners and Lenis when the page unmounts", async () => { const view = await mount(); const lenis = mockLenis[0]; view.unmount(); expect(lenis.destroy).toHaveBeenCalledTimes(1); expect(mockTicker.remove).toHaveBeenCalled(); mockTicker.add.mockClear(); act(() => { window.dispatchEvent(new Event("wheel")); }); expect(mockTicker.add).not.toHaveBeenCalled(); }); }); });