Files
portifolio-evandrodevbr/app/components/cinematic/useCinematicScroll.paths.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

569 lines
17 KiB
TypeScript

import { act, render } from "@testing-library/react";
import { useRef } from "react";
import { useCinematicScroll } from "./useCinematicScroll";
/**
* Testes de caminhos-base de useCinematicScroll (complemento de useCinematicScroll.test.tsx), com o hook montado
* numa página de verdade:
* - cliques em â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, com Lenis e sem ele (toque);
* - ref vazia, movimento reduzido e a limpeza dos ouvintes ao desmontar.
* O Lenis é trocado por um dublê que registra o que recebe; o GSAP nem existe mais (se alguém o importar, o
* dublê abaixo quebra 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,
"projects-title": 1900,
edge: 2400,
contact: 25000,
"footer-note": 26000,
};
function Home({ attach = true }: { attach?: boolean }) {
const ref = useRef<HTMLElement>(null);
useCinematicScroll(ref);
return (
<>
<main ref={attach ? ref : undefined}>
<section id="home" data-top={TOPS.home}>
<a href="/#contact">contato</a>
<a href="/#projects">projetos</a>
<a href="/#projects-title">titulo dos projetos</a>
<a href="/#edge">limite</a>
<a href="/#footer-note">nota fora da cena</a>
<a href="/#nao-existe">fantasma</a>
<a href="/#%E0%A4%A">malformado</a>
<a href="/outra#contact">outra pagina</a>
<a href="https://example.com/#contact">outro site</a>
<a href="/">sem ancora</a>
<a href="/#">ancora vazia</a>
<a href="/#contact" target="_blank" rel="noopener">
nova aba
</a>
<a href="/#contact" target="_self">
mesma aba
</a>
<svg aria-hidden="true">
<a href="/#contact">
<text>svg</text>
</a>
</svg>
<p id="plain">texto</p>
</section>
<section id="projects" data-top={TOPS.projects}>
<h2 id="projects-title" data-top={TOPS["projects-title"]}>
Projetos
</h2>
</section>
<section id="edge" data-top={TOPS.edge}>
<h2>Limite</h2>
</section>
{/* biome-ignore lint/a11y/noNoninteractiveTabindex: alvo de âncora que já traz tabindex, para testar que ele é mantido */}
<section id="contact" data-top={TOPS.contact} tabIndex={0}>
<h2>Contato</h2>
</section>
</main>
<p id="footer-note" data-top={TOPS["footer-note"]}>
Nota
</p>
</>
);
}
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 flushImports() {
await act(async () => {
for (let i = 0; i < 10; i++) await Promise.resolve();
});
}
async function mount(props: { attach?: boolean } = {}) {
const view = render(<Home {...props} />);
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(() => {
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("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: montagem e desmontagem", () => {
it("path: ref not attached to an element -> the hook does nothing", async () => {
await mount({ attach: false });
expect(mockLenis).toHaveLength(0);
const { prevented } = click(link("projetos"));
expect(prevented).toBe(false);
});
it("path: reduced motion -> no Lenis, and each anchor click is an instant jump, never an animation", async () => {
mockMedia(["(prefers-reduced-motion: reduce)"]);
await mount();
const first = click(link("projetos"));
const second = click(link("projetos"));
expect(first.prevented).toBe(true);
expect(second.prevented).toBe(true);
expect(mockLenis).toHaveLength(0);
expect(window.scrollTo).toHaveBeenCalledTimes(2);
for (const [options] of (window.scrollTo as jest.Mock).mock.calls)
expect(options).toMatchObject({ behavior: "instant" });
});
it("path: touch -> anchors are still handled (the click listener does not wait for any package)", async () => {
mockMedia(["(pointer: coarse)"]);
const view = render(<Home />);
// sem esperar importação nenhuma: o clique já é tratado
const { prevented } = click(link("projetos"));
expect(prevented).toBe(true);
expect(window.scrollTo).toHaveBeenCalledTimes(1);
expect(mockLenis).toHaveLength(0);
view.unmount();
});
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"]),
);
});
it("path: after unmount an anchor click is no longer intercepted", async () => {
const view = await mount();
const target = link("projetos");
view.unmount();
document.body.append(target);
const { prevented } = click(target);
expect(prevented).toBe(false);
target.remove();
});
});