Files
portifolio-evandrodevbr/app/components/cinematic/HeroField.paths.test.tsx
T
Evandro Fonseca Junior 6ac87824f3 perf(movimento): sem tempo morto, vídeos regravados e 3D sem React Three Fiber
Legenda do mergulho até Projetos cobrir a cena; transição de página sem blur e com fundo escuro; foco no conteúdo ao trocar de página; listas com marcador e dois pesos de título; vídeos da SEV e da SC Plus regravados sem quadros brancos; scrub a 12-15 fps (-30%); vitrine com uma parada de Tab e setas, contador no celular e CSS sob demanda; âncoras num módulo só (fragment-nav); FramePulse com setInterval; CSS morto removido.
2026-10-14 17:01:37 -03:00

600 lines
18 KiB
TypeScript

import { act, render, screen } from "@testing-library/react";
import HeroField, { DIVE_QUERY } from "./HeroField";
/**
* Testes de caminhos-base de HeroField (complemento de HeroField.test.tsx): como a espera pelo WebGL acaba
* (primeira interação, requestIdleCallback com folga ou sem folga, temporizador), os cancelamentos, as falhas
* (contexto WebGL2, pacote que não carrega), o fundo sem a seção `.hero`, as variáveis de rolagem e a
* visibilidade (observador, aba oculta, fim do mergulho) e a limpeza ao desmontar.
*/
type SceneProps = {
visible: boolean;
progressRef: { current: number };
onReady: () => void;
onError: () => void;
};
const mockScenes: SceneProps[] = [];
const mockPrepare = jest.fn();
let mockImportFails = false;
function MockScene(props: SceneProps) {
mockScenes.push(props);
return require("react").createElement("canvas", { "data-testid": "scene" });
}
jest.mock("./HeroFieldScene", () => ({
__esModule: true,
// ler o módulo falha quando o teste simula o pacote que não carrega (uma promessa rejeitada)
get default() {
if (mockImportFails) throw new Error("chunk indisponível");
return MockScene;
},
get prepareField() {
if (mockImportFails) throw new Error("chunk indisponível");
return () => mockPrepare();
},
}));
const win = window as unknown as Record<string, unknown>;
const original = {
matchMedia: window.matchMedia,
webgl2: win.WebGL2RenderingContext,
getContext: HTMLCanvasElement.prototype.getContext,
observer: global.IntersectionObserver,
idle: win.requestIdleCallback,
cancelIdle: win.cancelIdleCallback,
};
function mockMedia(active: string[] = [DIVE_QUERY]) {
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
matches: active.includes(query),
media: query,
addEventListener() {},
removeEventListener() {},
}));
}
function mockNavigator(values: Record<string, unknown>) {
for (const [key, value] of Object.entries(values))
Object.defineProperty(navigator, key, { configurable: true, value });
}
function setContext(factory: () => unknown) {
HTMLCanvasElement.prototype.getContext = factory as never;
}
type Observer = {
callback: IntersectionObserverCallback;
disconnect: jest.Mock;
};
let observers: Observer[] = [];
function Hero({ children }: { children?: React.ReactNode }) {
return (
<section className="hero">
<HeroField />
{children}
</section>
);
}
const section = () => document.querySelector(".hero") as HTMLElement;
function setHidden(value: boolean) {
Object.defineProperty(document, "hidden", { configurable: true, value });
}
function reportIntersecting(value: boolean) {
act(() => {
for (const observer of observers)
observer.callback(
[{ isIntersecting: value } as IntersectionObserverEntry],
{} as IntersectionObserver,
);
});
}
/**
* requestIdleCallback de mentira: o pedido da espera pela página ociosa (timeout 4000) fica guardado para o teste
* disparar com o prazo que quiser; os dos passos de carregamento (timeout 500) rodam logo.
*/
function mockIdleCallback() {
const waiting = {
callback: undefined as ((d: IdleDeadline) => void) | undefined,
};
let requests = 0;
const request = jest.fn(
(
callback: (d: IdleDeadline) => void,
options?: { timeout?: number },
): number => {
requests += 1;
if (options?.timeout === 500)
setTimeout(
() => callback({ didTimeout: false, timeRemaining: () => 50 }),
0,
);
else waiting.callback = callback;
return requests;
},
);
win.requestIdleCallback = request;
win.cancelIdleCallback = jest.fn();
return { waiting, request };
}
const advance = (ms: number) =>
act(async () => {
await jest.advanceTimersByTimeAsync(ms);
});
/** Depois de a espera acabar, os passos seguintes (pacote, geometria, Canvas). */
async function finishLoading() {
await advance(60);
await advance(60);
}
beforeEach(() => {
jest.useFakeTimers();
mockScenes.length = 0;
mockPrepare.mockClear();
mockImportFails = false;
observers = [];
mockMedia();
mockNavigator({
hardwareConcurrency: 8,
deviceMemory: undefined,
connection: undefined,
});
win.WebGL2RenderingContext = function WebGL2RenderingContext() {};
setContext(() => ({ getExtension: () => null }));
win.requestIdleCallback = undefined;
win.cancelIdleCallback = undefined;
global.IntersectionObserver = class {
private readonly record: Observer;
constructor(callback: IntersectionObserverCallback) {
this.record = { callback, disconnect: jest.fn() };
observers.push(this.record);
}
observe() {}
unobserve() {}
disconnect() {
this.record.disconnect();
}
takeRecords() {
return [];
}
} as unknown as typeof IntersectionObserver;
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
window.matchMedia = original.matchMedia;
win.WebGL2RenderingContext = original.webgl2;
win.requestIdleCallback = original.idle;
win.cancelIdleCallback = original.cancelIdle;
HTMLCanvasElement.prototype.getContext = original.getContext;
global.IntersectionObserver = original.observer;
Reflect.deleteProperty(document, "hidden");
});
describe("HeroField: quando o WebGL monta", () => {
it("path: no requestIdleCallback -> the scene starts loading after 1.2 s of timer, not before", async () => {
render(<Hero />);
await advance(1199);
expect(mockPrepare).not.toHaveBeenCalled();
expect(screen.queryByTestId("scene")).toBeNull();
await advance(1);
await finishLoading();
expect(screen.getByTestId("scene")).toBeInTheDocument();
});
it.each([
"pointerdown",
"pointermove",
"keydown",
"wheel",
"touchstart",
"scroll",
])(
"path: first %s -> the scene starts loading without waiting for the timer",
async (type) => {
render(<Hero />);
act(() => {
window.dispatchEvent(new Event(type));
});
await finishLoading();
expect(screen.getByTestId("scene")).toBeInTheDocument();
},
);
it("path: several gestures in a row -> the scene is loaded once", async () => {
render(<Hero />);
act(() => {
window.dispatchEvent(new Event("pointermove"));
window.dispatchEvent(new Event("pointermove"));
window.dispatchEvent(new Event("keydown"));
});
await finishLoading();
await advance(3000);
expect(mockPrepare).toHaveBeenCalledTimes(1);
expect(mockScenes.length).toBeGreaterThan(0);
});
it("path: requestIdleCallback with plenty of free time -> the scene starts loading right then, not on the timer", async () => {
const { waiting, request } = mockIdleCallback();
render(<Hero />);
await advance(1300);
expect(screen.queryByTestId("scene")).toBeNull();
expect(request).toHaveBeenCalledWith(expect.any(Function), {
timeout: 4000,
});
act(() =>
waiting.callback?.({ didTimeout: false, timeRemaining: () => 45 }),
);
await finishLoading();
expect(screen.getByTestId("scene")).toBeInTheDocument();
});
it("path: an idle period with no free time -> waits for the next one; waiting too long -> goes ahead anyway", async () => {
const { waiting, request } = mockIdleCallback();
render(<Hero />);
const first = request.mock.calls.length;
act(() =>
waiting.callback?.({ didTimeout: false, timeRemaining: () => 3 }),
);
await finishLoading();
expect(screen.queryByTestId("scene")).toBeNull();
expect(request.mock.calls.length).toBeGreaterThan(first); // pediu o próximo intervalo
act(() => waiting.callback?.({ didTimeout: true, timeRemaining: () => 0 }));
await finishLoading();
expect(screen.getByTestId("scene")).toBeInTheDocument();
});
it("path: unmounted while waiting for the browser to get idle -> the request is cancelled and no scene mounts", async () => {
const cancel = jest.fn();
win.requestIdleCallback = jest.fn(() => 7);
win.cancelIdleCallback = cancel;
const view = render(<Hero />);
view.unmount();
await advance(5000);
expect(cancel).toHaveBeenCalledWith(7);
expect(mockScenes).toHaveLength(0);
});
it("path: unmounted before the fallback timer fires -> nothing loads afterwards, and the gestures no longer matter", async () => {
const view = render(<Hero />);
view.unmount();
act(() => {
window.dispatchEvent(new Event("pointermove"));
});
await advance(5000);
expect(mockPrepare).not.toHaveBeenCalled();
expect(mockScenes).toHaveLength(0);
});
it("path: unmounted in the middle of loading (after the package, before the geometry) -> nothing mounts", async () => {
const view = render(<Hero />);
await advance(1200);
view.unmount();
await finishLoading();
expect(mockPrepare).not.toHaveBeenCalled();
expect(mockScenes).toHaveLength(0);
});
});
describe("HeroField: falhas voltam ao hero simples", () => {
it("path: WebGL2 constructor exists but creating the context throws -> static background, no dive", async () => {
setContext(() => {
throw new Error("sem GPU");
});
render(<Hero />);
await advance(1200);
await finishLoading();
expect(screen.queryByTestId("scene")).toBeNull();
expect(section()).not.toHaveAttribute("data-dive");
});
it("path: WebGL2 constructor exists but the context is null -> static background, no dive", async () => {
setContext(() => null);
render(<Hero />);
await advance(1200);
await finishLoading();
expect(screen.queryByTestId("scene")).toBeNull();
expect(section()).not.toHaveAttribute("data-dive");
});
it("path: the three.js package fails to load (network) -> static background, no dive, no scene", async () => {
mockImportFails = true;
render(<Hero />);
await advance(1200);
await finishLoading();
expect(screen.queryByTestId("scene")).toBeNull();
expect(mockScenes).toHaveLength(0);
expect(section()).not.toHaveAttribute("data-dive");
});
it("path: the probe context is released right away (WEBGL2 lose_context) before the real scene mounts", async () => {
const loseContext = jest.fn();
setContext(() => ({
getExtension: (name: string) =>
name === "WEBGL_lose_context" ? { loseContext } : null,
}));
render(<Hero />);
await advance(1200);
await finishLoading();
expect(loseContext).toHaveBeenCalledTimes(1);
expect(screen.getByTestId("scene")).toBeInTheDocument();
});
it("path: the lose-context extension is missing -> the probe still succeeds", async () => {
setContext(() => ({ getExtension: () => null }));
render(<Hero />);
await advance(1200);
await finishLoading();
expect(screen.getByTestId("scene")).toBeInTheDocument();
});
it("path: low power -> the WebGL probe, the timer and the gesture listeners are never started", async () => {
mockMedia(["(pointer: coarse)"]);
const probe = jest.fn(() => ({ getExtension: () => null }));
setContext(probe);
const addListener = jest.spyOn(window, "addEventListener");
render(<Hero />);
act(() => {
window.dispatchEvent(new Event("pointermove"));
});
await advance(3000);
await finishLoading();
expect(probe).not.toHaveBeenCalled();
expect(mockScenes).toHaveLength(0);
expect(mockPrepare).not.toHaveBeenCalled();
const types = addListener.mock.calls.map(([type]) => type);
expect(types).not.toContain("pointermove");
expect(types).not.toContain("scroll");
expect(observers).toHaveLength(0);
});
});
describe("HeroField: rolagem e visibilidade", () => {
function place(top: number, height = 2760) {
section().getBoundingClientRect = () =>
({ top, height, bottom: top + height }) as DOMRect;
}
function scroll(top: number) {
place(top);
act(() => {
window.dispatchEvent(new Event("scroll"));
jest.advanceTimersByTime(40);
});
}
async function loaded() {
await advance(1200);
await finishLoading();
}
beforeEach(() => {
Object.defineProperty(window, "innerHeight", {
configurable: true,
value: 800,
});
});
it("path: no .hero ancestor -> no scroll variables and no observer, and nothing else starts either", async () => {
render(<HeroField />);
await advance(3000);
await finishLoading();
expect(observers).toHaveLength(0);
expect(mockScenes).toHaveLength(0);
});
it("path: scrolling to the same progress twice -> the CSS variables are written once", () => {
render(<Hero />);
const setProperty = jest.spyOn(section().style, "setProperty");
scroll(-600);
const afterFirst = setProperty.mock.calls.length;
expect(afterFirst).toBeGreaterThan(0);
scroll(-600);
expect(setProperty.mock.calls).toHaveLength(afterFirst);
});
it("path: resize recomputes the progress like a scroll", () => {
render(<Hero />);
place(-700);
act(() => {
window.dispatchEvent(new Event("resize"));
jest.advanceTimersByTime(40);
});
// trilho de 1400 px (2760 de hero - 1,7 tela de 800)
expect(Number(section().style.getPropertyValue("--hero-p"))).toBeCloseTo(
0.5,
);
});
it("path: scroll above the hero (positive top) -> progress clamps at 0", () => {
render(<Hero />);
scroll(300);
expect(Number(section().style.getPropertyValue("--hero-p") || 0)).toBe(0);
});
it("path: scroll far past the dive (Projects covering the stage) -> progress clamps at 1", () => {
render(<Hero />);
scroll(-2200);
expect(Number(section().style.getPropertyValue("--hero-p"))).toBe(1);
});
it("path: a hero shorter than the minimum rail -> the rail is 80 % of the window, never zero", () => {
render(<Hero />);
place(-400, 800);
act(() => {
window.dispatchEvent(new Event("resize"));
jest.advanceTimersByTime(40);
});
expect(Number(section().style.getPropertyValue("--hero-p"))).toBeCloseTo(
400 / 640,
);
});
it("path: end of the dive then back up -> drawing stops, then resumes", async () => {
render(<Hero />);
await loaded();
reportIntersecting(true);
scroll(-1400);
expect(mockScenes.at(-1)?.visible).toBe(false);
scroll(-300);
expect(mockScenes.at(-1)?.visible).toBe(true);
});
it("path: the hero leaves the screen -> not visible; returns -> visible", async () => {
render(<Hero />);
await loaded();
reportIntersecting(true);
expect(mockScenes.at(-1)?.visible).toBe(true);
reportIntersecting(false);
expect(mockScenes.at(-1)?.visible).toBe(false);
reportIntersecting(true);
expect(mockScenes.at(-1)?.visible).toBe(true);
});
it("path: the tab is hidden while the hero is on screen -> not visible; shown again -> visible", async () => {
render(<Hero />);
await loaded();
reportIntersecting(true);
setHidden(true);
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(mockScenes.at(-1)?.visible).toBe(false);
setHidden(false);
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(mockScenes.at(-1)?.visible).toBe(true);
});
it("path: the tab shows but the hero is off screen -> still not visible", async () => {
render(<Hero />);
await loaded();
reportIntersecting(false);
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(mockScenes.at(-1)?.visible).toBe(false);
});
it("path: unmount -> listeners removed, observer disconnected, pending frame cancelled, hero back to plain", () => {
const removeListener = jest.spyOn(window, "removeEventListener");
const removeDocListener = jest.spyOn(document, "removeEventListener");
const cancelFrame = jest.spyOn(window, "cancelAnimationFrame");
const view = render(<Hero />);
const hero = section();
scroll(-700);
expect(hero).toHaveAttribute("data-dive");
view.unmount();
const removed = removeListener.mock.calls.map(([type]) => type);
expect(removed).toEqual(expect.arrayContaining(["scroll", "resize"]));
expect(removeDocListener.mock.calls.map(([type]) => type)).toContain(
"visibilitychange",
);
expect(observers[0].disconnect).toHaveBeenCalledTimes(1);
expect(cancelFrame).toHaveBeenCalled();
expect(hero).not.toHaveAttribute("data-dive");
expect(hero.style.getPropertyValue("--hero-p")).toBe("");
});
});
describe("HeroField: navegador sem matchMedia", () => {
it("path: no matchMedia at all -> the dive is not started, but the ambient scene still mounts", async () => {
window.matchMedia = undefined as unknown as typeof window.matchMedia;
render(<Hero />);
await advance(1200);
await finishLoading();
expect(section()).not.toHaveAttribute("data-dive");
expect(section().style.getPropertyValue("--hero-p")).toBe("");
expect(screen.getByTestId("scene")).toBeInTheDocument();
});
});
describe("HeroField: desmontagem em pontos que faltavam", () => {
it("path: unmounted after the geometry was prepared, before the Canvas -> the scene is never mounted", async () => {
const view = render(<Hero />);
mockPrepare.mockImplementationOnce(() => {
act(() => view.unmount());
});
await advance(1200);
await finishLoading();
expect(mockPrepare).toHaveBeenCalledTimes(1);
expect(mockScenes).toHaveLength(0);
});
it("path: no dive on this screen (the dive query does not match) -> unmounting is quiet and the hero stays plain", async () => {
mockMedia([]);
const view = render(<Hero />);
await finishLoading();
const hero = section();
expect(() => view.unmount()).not.toThrow();
expect(hero).not.toHaveAttribute("data-dive");
});
});