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.
565 lines
16 KiB
TypeScript
565 lines
16 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 -> nothing, and anchor clicks are left to the browser", async () => {
|
|
mockMedia(["(prefers-reduced-motion: reduce)"]);
|
|
await mount();
|
|
|
|
const { prevented } = click(link("projetos"));
|
|
|
|
expect(prevented).toBe(false);
|
|
expect(mockLenis).toHaveLength(0);
|
|
expect(window.scrollTo).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|