Files
portifolio-evandrodevbr/app/components/cinematic/useCinematicScroll.test.tsx
T
Evandro Fonseca Junior c9a0b80656 perf(movimento): uma transição, 3D leve e vídeo certo por aparelho
Passagens com fade e 32 px, cena anterior termina em opacidade 0, sem visibility nem clip-path. 3D do hero como fundo ambiente, sob demanda e desligado em toque, aparelho fraco, saveData e movimento reduzido. Lenis fora do toque e salto direto em âncoras distantes. Vitrine só de clientes, até 3 capítulos, fixa só no desktop e carrossel no celular; vídeo só do projeto em cena com preload metadata. Rótulos mono a partir de 12 px e limão só no contato.
2026-10-14 03:32:23 -03:00

518 lines
16 KiB
TypeScript

import { act, render } from "@testing-library/react";
import { useRef } from "react";
import {
anchorDuration,
ENTER_OFFSET,
enterScene,
exitScene,
IDLE_SECONDS,
JUMP_SCREENS,
MAX_ANCHOR_SECONDS,
PASSAGE_END,
useCinematicScroll,
} from "./useCinematicScroll";
/* ------------------------------------------------------------------------------------------------
* Passagem de verdade (GSAP real, sem ScrollTrigger): o que cada cena recebe no começo e no fim.
* ------------------------------------------------------------------------------------------------ */
// o resto do arquivo troca o GSAP por um fake: aqui vale o de verdade
const { gsap: realGsap } = jest.requireActual<typeof import("gsap")>("gsap");
function scene() {
const element = document.createElement("section");
document.body.append(element);
return element;
}
function timeline() {
return realGsap.timeline({
paused: true,
defaults: { ease: "none", duration: 1 },
});
}
const translateY = (element: HTMLElement) =>
Number(
/translate(?:3d)?\(\s*[-\d.]+(?:px)?,\s*([-\d.]+)px/.exec(
element.style.transform,
)?.[1] ?? Number.NaN,
);
describe("the single passage between scenes", () => {
afterEach(() => {
document.body.innerHTML = "";
});
it("brings the next scene in with a fade and a short vertical offset, nothing else", () => {
expect(ENTER_OFFSET).toBeGreaterThanOrEqual(24);
expect(ENTER_OFFSET).toBeLessThanOrEqual(40);
const next = scene();
const tl = timeline();
enterScene(tl, next);
tl.progress(0.0001);
expect(Number(next.style.opacity)).toBeLessThan(0.01);
expect(translateY(next)).toBeCloseTo(ENTER_OFFSET, 0);
tl.progress(0.5);
expect(Number(next.style.opacity)).toBeGreaterThan(0.2);
expect(Number(next.style.opacity)).toBeLessThan(1);
expect(translateY(next)).toBeGreaterThan(0);
expect(translateY(next)).toBeLessThan(ENTER_OFFSET);
// sem recorte, escala, giro nem visibility em nenhum ponto
for (const progress of [0.0001, 0.3, 0.7]) {
tl.progress(progress);
expect(next.style.clipPath).toBe("");
expect(next.style.visibility).toBe("");
expect(next.style.transform).not.toMatch(/scale|rotate/);
}
});
it("leaves no inline style behind once the scene has arrived, and brings it back on the way up", () => {
const next = scene();
const tl = timeline();
enterScene(tl, next);
tl.progress(1);
expect(next.style.opacity).toBe("");
expect(next.style.transform).toBe("");
tl.progress(0.4);
expect(Number(next.style.opacity)).toBeLessThan(1);
expect(next.style.transform).not.toBe("");
});
it("fades the previous scene all the way to opacity 0 at the end of every passage", () => {
const previous = scene();
const tl = timeline();
exitScene(tl, previous);
tl.progress(0.5);
expect(Number(previous.style.opacity)).toBeGreaterThan(0);
expect(Number(previous.style.opacity)).toBeLessThan(1);
expect(previous.style.visibility).toBe("");
expect(previous.style.clipPath).toBe("");
tl.progress(1);
expect(previous.style.opacity).toBe("0");
// rolando de volta, a cena reaparece inteira
tl.progress(0.0001);
expect(Number(previous.style.opacity)).toBeGreaterThan(0.99);
});
it("a scene in the middle of the page arrives, stays, and leaves at 0 without fighting itself", () => {
const middle = scene();
const arrive = timeline();
const leave = timeline();
enterScene(arrive, middle);
exitScene(leave, middle);
arrive.progress(1);
expect(middle.style.opacity).toBe("");
leave.progress(1);
expect(middle.style.opacity).toBe("0");
leave.progress(0);
arrive.progress(0.2);
expect(Number(middle.style.opacity)).toBeLessThan(0.5);
});
});
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);
}
});
});
/* ------------------------------------------------------------------------------------------------
* O hook: GSAP, ScrollTrigger e Lenis trocados por fakes que registram o que recebem.
* ------------------------------------------------------------------------------------------------ */
type Call = unknown[];
const mockTimelines: Array<{ vars: ScrollTriggerLike; calls: Call[] }> = [];
const mockTicker = {
add: jest.fn(),
remove: jest.fn(),
lagSmoothing: jest.fn(),
time: 0,
};
const mockLenis: MockLenis[] = [];
interface ScrollTriggerLike {
scrollTrigger: {
trigger: HTMLElement;
start: () => number;
end: () => number;
scrub: boolean;
onEnter?: () => void;
};
}
class MockLenis {
isScrolling: false | "smooth" = false;
on = jest.fn();
raf = jest.fn();
scrollTo = jest.fn();
destroy = jest.fn();
constructor(public options: unknown) {
mockLenis.push(this);
}
}
jest.mock("gsap", () => ({
gsap: {
registerPlugin: jest.fn(),
ticker: mockTicker,
context: (fn: () => void) => {
fn();
return { revert: jest.fn() };
},
timeline: (vars: ScrollTriggerLike) => {
const record = { vars, calls: [] as Call[] };
mockTimelines.push(record);
const chain = {
fromTo: (...args: Call) => {
record.calls.push(args);
return chain;
},
};
return chain;
},
},
}));
jest.mock("gsap/ScrollTrigger", () => ({
ScrollTrigger: { update: jest.fn(), refresh: jest.fn() },
}));
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>
{/* uma cena mais curta que a tela: a saída dela não pode começar antes de a entrada terminar */}
<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 />);
// as importações dinâmicas do hook (gsap, ScrollTrigger, Lenis) resolvem em microtarefas
await act(async () => {
for (let i = 0; i < 10; i++) await Promise.resolve();
});
return view;
}
const scenes = () =>
Array.from(document.querySelectorAll<HTMLElement>("main > section"));
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(() => {
mockTimelines.length = 0;
mockLenis.length = 0;
mockTicker.add.mockClear();
mockTicker.remove.mockClear();
mockTicker.lagSmoothing.mockClear();
mockTicker.time = 0;
mockMedia([]);
window.history.replaceState(null, "", "/");
jest.spyOn(window, "scrollTo").mockImplementation(() => {});
Object.defineProperty(window, "innerHeight", {
configurable: true,
value: 800,
});
});
afterEach(() => {
window.matchMedia = originalMatchMedia;
jest.restoreAllMocks();
});
describe("useCinematicScroll", () => {
it("never hides a scene: no visibility, no sticky pin, nothing inline before the scroll runs", async () => {
await mount();
for (const element of scenes()) {
expect(element.style.visibility).toBe("");
expect(element.style.position).toBe("");
expect(element.style.top).toBe("");
expect(element.style.opacity).toBe("");
expect(element.style.clipPath).toBe("");
}
// o texto do contato continua achável (Ctrl+F) e visível para leitores de tela
const contact = document.getElementById("contact") as HTMLElement;
expect(contact).toBeVisible();
expect(contact).toHaveTextContent("Contato");
expect(contact.closest("[aria-hidden]")).toBeNull();
});
it("uses one passage only: opacity and a vertical offset, with the previous scene ending at 0", async () => {
await mount();
const allowed = new Set([
"opacity",
"y",
"ease",
"clearProps",
"immediateRender",
]);
const entering: HTMLElement[] = [];
const leaving: HTMLElement[] = [];
for (const { vars, calls } of mockTimelines) {
expect(vars.scrollTrigger.scrub).toBe(true);
expect(calls).toHaveLength(1);
const [target, from, to] = calls[0] as [
HTMLElement,
Record<string, unknown>,
Record<string, unknown>,
];
for (const key of [...Object.keys(from), ...Object.keys(to)])
expect(allowed.has(key)).toBe(true);
expect(JSON.stringify([from, to])).not.toMatch(
/autoAlpha|visibility|clipPath|scale|rotation|xPercent/,
);
if (to.opacity === 1) {
entering.push(target);
expect(from).toMatchObject({ opacity: 0, y: ENTER_OFFSET });
expect(vars.scrollTrigger.trigger).toBe(target);
} else {
leaving.push(target);
expect(from).toMatchObject({ opacity: 1 });
expect(to.opacity).toBe(0);
}
}
const all = scenes();
// todas entram, menos a primeira; todas saem, menos a última
expect(entering).toEqual(all.slice(1));
expect(leaving).toEqual(all.slice(0, -1));
});
it("starts each exit only after the same scene finished entering, even for a scene shorter than the screen", async () => {
await mount();
const all = scenes();
const ranges = new Map<
HTMLElement,
{ enter?: [number, number]; exit?: [number, number] }
>();
for (const { vars, calls } of mockTimelines) {
const target = calls[0][0] as HTMLElement;
const to = calls[0][2] as { opacity: number };
const { start, end } = vars.scrollTrigger;
const entry = ranges.get(target) ?? {};
if (to.opacity === 1) entry.enter = [start(), end()];
else entry.exit = [start(), end()];
ranges.set(target, entry);
}
const vh = window.innerHeight;
for (const element of all.slice(1)) {
const top = TOPS[element.id];
expect(ranges.get(element)?.enter).toEqual([
top - vh,
top - vh * PASSAGE_END,
]);
}
for (const element of all.slice(0, -1)) {
const { enter, exit } = ranges.get(element) ?? {};
expect(exit).toBeDefined();
const [start, end] = exit as [number, number];
expect(start).toBeLessThan(end);
// termina quando o topo da cena seguinte chegou ao fim da passagem dela
const next = all[all.indexOf(element) + 1];
expect(end).toBe(TOPS[next.id] - vh * PASSAGE_END);
if (enter) expect(start).toBeGreaterThanOrEqual(enter[1]);
}
// a cena curta (300 px): a saída começa onde a entrada acabou, não 500 px antes
const short = ranges.get(document.getElementById("short") as HTMLElement);
expect(short?.exit?.[0]).toBe(short?.enter?.[1]);
});
it("does no scroll work for users who prefer reduced motion", async () => {
mockMedia(["(prefers-reduced-motion: reduce)"]);
await mount();
expect(mockTimelines).toHaveLength(0);
expect(mockLenis).toHaveLength(0);
expect(mockTicker.add).not.toHaveBeenCalled();
});
describe("smooth scroll", () => {
it("is not created on a touch screen, where the passages still run on the native scroll", async () => {
mockMedia(["(pointer: coarse)"]);
await mount();
expect(mockLenis).toHaveLength(0);
expect(mockTicker.add).not.toHaveBeenCalled();
expect(mockTicker.lagSmoothing).not.toHaveBeenCalled();
expect(mockTimelines.length).toBeGreaterThan(0);
});
it("is created with a mouse, and the GSAP clock sleeps once the scroll is idle and wakes on the wheel", async () => {
await mount();
expect(mockLenis).toHaveLength(1);
const lenis = mockLenis[0];
expect(mockTicker.add).toHaveBeenCalledTimes(1);
const tick = mockTicker.add.mock.calls[0][0] as (time: number) => void;
// rolando: segue ligado e alimenta o Lenis em milissegundos
lenis.isScrolling = "smooth";
tick(0.5);
expect(lenis.raf).toHaveBeenLastCalledWith(500);
expect(mockTicker.remove).not.toHaveBeenCalled();
// parado há mais que o tempo de espera: o relógio é solto
lenis.isScrolling = false;
tick(0.5 + IDLE_SECONDS / 2);
expect(mockTicker.remove).not.toHaveBeenCalled();
tick(0.5 + IDLE_SECONDS + 0.1);
expect(mockTicker.remove).toHaveBeenCalledTimes(1);
expect(mockTicker.remove).toHaveBeenCalledWith(tick);
// a roda do mouse o acorda de novo
mockTicker.time = 10;
act(() => {
window.dispatchEvent(new Event("wheel"));
});
expect(mockTicker.add).toHaveBeenCalledTimes(2);
// e uma roda seguinte, com o relógio já ligado, não o liga duas vezes
act(() => {
window.dispatchEvent(new Event("wheel"));
});
expect(mockTicker.add).toHaveBeenCalledTimes(2);
});
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 });
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("without Lenis (touch) a far anchor is an instant jump", 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 the clock, the listeners and Lenis when the page unmounts", async () => {
const view = await mount();
const lenis = mockLenis[0];
view.unmount();
expect(lenis.destroy).toHaveBeenCalledTimes(1);
expect(mockTicker.remove).toHaveBeenCalled();
mockTicker.add.mockClear();
act(() => {
window.dispatchEvent(new Event("wheel"));
});
expect(mockTicker.add).not.toHaveBeenCalled();
});
});
});