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.
453 lines
14 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|