Files
portifolio-evandrodevbr/app/components/cinematic/useCinematicScroll.test.tsx
Evandro Fonseca Junior 204ebd5fa5 fix(navegacao): foco em âncora de outra página, vitrine acessível e 3D mais leve
Foco cai no destino ao chegar de outra página; âncoras ignoram ?tipo=; movimento reduzido salta em todos os cliques; cronômetro da janela ampliada; degradê no topo de Projetos e legendas do mergulho com intervalos testados; vitrine acima da barra de cookies e com 2 paradas por projeto; reel-list.css no global; 3D montado em tarefas curtas, limitado a ~30 qps e estático com WebGL por software; pôsteres da vitrine de 330 para 158 KB; SC Plus regravado; scripts sem funções acima de complexidade 10.
2026-10-14 19:14:48 -03:00

453 lines
14 KiB
TypeScript

import { act, render } from "@testing-library/react";
import { useRef } from "react";
import {
anchorDuration,
anchorTop,
IDLE_SECONDS,
JUMP_SCREENS,
MAX_ANCHOR_SECONDS,
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<string, number> = {
home: 0,
projects: 1800,
short: 3000,
about: 3300,
contact: 25000,
};
function Home() {
const ref = useRef<HTMLElement>(null);
useCinematicScroll(ref);
return (
<main ref={ref}>
<section id="home" data-top={TOPS.home}>
<a href="/#contact">Conversar</a>
<a href="/#projects">Projetos</a>
</section>
<section id="projects" data-top={TOPS.projects}>
<h2>Projetos</h2>
</section>
<section id="short" data-top={TOPS.short}>
<h2>Curta</h2>
</section>
<section id="about" data-top={TOPS.about}>
<h2>Sobre</h2>
</section>
<section id="contact" data-top={TOPS.contact}>
<h2>Contato</h2>
</section>
</main>
);
}
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(<Home />);
// 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("anchorTop", () => {
const scenes = () => {
document.body.innerHTML = `
<section id="a" data-top="1800"><h2 id="a-title" data-top="1900">t</h2></section>
<section id="b" data-top="2400"></section>
<p id="loose" data-top="9000">x</p>`;
return [...document.querySelectorAll<HTMLElement>("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<HTMLElement>(
"main > section",
)) {
expect(element.getAttribute("style")).toBeNull();
}
const contact = document.getElementById("contact") as HTMLElement;
expect(contact).toBeVisible();
expect(contact).toHaveTextContent("Contato");
});
it("with reduced motion there is no smooth scroll, and EVERY anchor click jumps instantly (no animation)", async () => {
mockMedia(["(prefers-reduced-motion: reduce)"]);
await mount();
expect(mockLenis).toHaveLength(0);
const link = document.querySelector('a[href="/#projects"]') as HTMLElement;
for (let i = 0; i < 3; i++) {
(window.scrollTo as jest.Mock).mockClear();
const event = new MouseEvent("click", {
bubbles: true,
cancelable: true,
});
act(() => {
link.dispatchEvent(event);
});
// o clique é tratado em todas as vezes, não só na primeira: salto instantâneo até a cena
expect(event.defaultPrevented).toBe(true);
expect(window.scrollTo).toHaveBeenCalledTimes(1);
expect(window.scrollTo).toHaveBeenCalledWith({
top: TOPS.projects,
behavior: "instant",
});
}
expect(mockLenis).toHaveLength(0);
expect(document.getElementById("projects")).toHaveFocus();
});
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("eases the anchor travel in and out: slow at both ends, exactly half way at the middle, from 0 to 1", async () => {
await mount();
const lenis = mockLenis[0];
act(() => {
(document.querySelector('a[href="/#projects"]') as HTMLElement).click();
});
const { easing } = lenis.scrollTo.mock.calls[0][1] as {
easing: (t: number) => number;
};
expect(easing(0)).toBe(0);
expect(easing(0.25)).toBeCloseTo(0.0625, 9);
expect(easing(0.5)).toBeCloseTo(0.5, 9);
expect(easing(0.75)).toBeCloseTo(0.9375, 9);
expect(easing(1)).toBe(1);
});
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(<Home />);
view.unmount();
await act(async () => {
for (let i = 0; i < 10; i++) await Promise.resolve();
});
expect(mockLenis).toHaveLength(0);
expect(frames).not.toHaveBeenCalled();
});
});
});