Files
portifolio-evandrodevbr/app/components/cinematic/useCinematicScroll.test.tsx
T
Evandro Fonseca Junior 318a764190 perf(movimento): sem tela vazia, passagens em CSS e vitrine leve
Projetos sobem sobre o palco do hero e o celular tem hero de uma tela sem mergulho; passagens com animation-timeline em CSS, sem GSAP nem motion; troca da vitrine sem texto duplicado; moldura de celular só com pôster e scrub reencodado em 960 px (9,4 para 5,2 MB); 3D monta na primeira interação e para quando parado; transição entre páginas do mesmo segmento; foco em anel duplo; texto de 15 px no celular; código morto removido.
2026-10-14 14:15:50 -03:00

520 lines
15 KiB
TypeScript

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<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("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(
`<a href="/#alvo">ir</a><section id="alvo"></section>`,
"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(
`<a href="/#alvo"><span id="inside">ir</span></a><section id="alvo"></section>`,
"#inside",
);
expect(samePageHashTarget(event)?.target.id).toBe("alvo");
});
it.each([
["another site", `<a href="https://example.com/#alvo">x</a>`],
["another page of this site", `<a href="/outra#alvo">x</a>`],
["no anchor", `<a href="/">x</a>`],
["an empty anchor", `<a href="/#">x</a>`],
["a new tab", `<a href="/#alvo" target="_blank">x</a>`],
["an id that does not exist", `<a href="/#nada">x</a>`],
["a malformed percent-encoding", `<a href="/#%E0%A4%A">x</a>`],
["no link at all", `<p>x</p>`],
])("returns null for %s", (_label, markup) => {
const event = clickOn(
`${markup}<section id="alvo"></section>`,
markup.startsWith("<a") ? "a" : "p",
);
expect(samePageHashTarget(event)).toBeNull();
});
it("handles target=_self like a plain link", () => {
const event = clickOn(
`<a href="/#alvo" target="_self">x</a><section id="alvo"></section>`,
"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 = `
<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("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(<Home />);
view.unmount();
await act(async () => {
for (let i = 0; i < 10; i++) await Promise.resolve();
});
expect(mockLenis).toHaveLength(0);
expect(frames).not.toHaveBeenCalled();
});
});
});