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.
406 lines
11 KiB
TypeScript
406 lines
11 KiB
TypeScript
import { act, render, screen } from "@testing-library/react";
|
|
import HeroField from "./HeroField";
|
|
|
|
/**
|
|
* Testes de caminhos-base de HeroField (complemento de HeroField.test.tsx): a decisão de montar o WebGL
|
|
* (requestIdleCallback x temporizador, desmontagem antes da hora, falhas ao pedir o contexto WebGL2),
|
|
* o fundo sem a seção `.hero`, as variáveis de rolagem e a visibilidade (observador, aba oculta, fim do mergulho).
|
|
*/
|
|
|
|
type SceneProps = {
|
|
visible: boolean;
|
|
progressRef: { current: number };
|
|
onReady: () => void;
|
|
onError: () => void;
|
|
};
|
|
const mockScenes: SceneProps[] = [];
|
|
|
|
jest.mock("next/dynamic", () => ({
|
|
__esModule: true,
|
|
default: () =>
|
|
function MockScene(props: SceneProps) {
|
|
mockScenes.push(props);
|
|
return require("react").createElement("canvas", {
|
|
"data-testid": "scene",
|
|
});
|
|
},
|
|
}));
|
|
|
|
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[]) {
|
|
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>
|
|
);
|
|
}
|
|
|
|
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,
|
|
);
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
jest.useFakeTimers();
|
|
mockScenes.length = 0;
|
|
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 mounts after 1.2 s of timer, not before", () => {
|
|
render(<Hero />);
|
|
|
|
act(() => {
|
|
jest.advanceTimersByTime(1199);
|
|
});
|
|
expect(screen.queryByTestId("scene")).toBeNull();
|
|
|
|
act(() => {
|
|
jest.advanceTimersByTime(1);
|
|
});
|
|
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
|
});
|
|
|
|
it("path: requestIdleCallback available -> mounts when the browser is idle (2.5 s timeout), not on the timer", () => {
|
|
let idle: (() => void) | undefined;
|
|
const request = jest.fn((callback: () => void) => {
|
|
idle = callback;
|
|
return 42;
|
|
});
|
|
win.requestIdleCallback = request;
|
|
win.cancelIdleCallback = jest.fn();
|
|
|
|
render(<Hero />);
|
|
act(() => {
|
|
jest.advanceTimersByTime(5000);
|
|
});
|
|
expect(screen.queryByTestId("scene")).toBeNull();
|
|
expect(request).toHaveBeenCalledWith(expect.any(Function), {
|
|
timeout: 2500,
|
|
});
|
|
|
|
act(() => idle?.());
|
|
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
|
});
|
|
|
|
it("path: unmounted before the browser got idle -> the idle request is cancelled and no scene mounts", () => {
|
|
const cancel = jest.fn();
|
|
win.requestIdleCallback = jest.fn(() => 7);
|
|
win.cancelIdleCallback = cancel;
|
|
|
|
const view = render(<Hero />);
|
|
view.unmount();
|
|
|
|
expect(cancel).toHaveBeenCalledWith(7);
|
|
expect(mockScenes).toHaveLength(0);
|
|
});
|
|
|
|
it("path: unmounted before the fallback timer fires -> nothing mounts afterwards", () => {
|
|
const view = render(<Hero />);
|
|
view.unmount();
|
|
|
|
act(() => {
|
|
jest.advanceTimersByTime(5000);
|
|
});
|
|
|
|
expect(mockScenes).toHaveLength(0);
|
|
});
|
|
|
|
it("path: WebGL2 constructor exists but creating the context throws -> static background", () => {
|
|
setContext(() => {
|
|
throw new Error("sem GPU");
|
|
});
|
|
|
|
render(<Hero />);
|
|
act(() => {
|
|
jest.advanceTimersByTime(3000);
|
|
});
|
|
|
|
expect(screen.queryByTestId("scene")).toBeNull();
|
|
});
|
|
|
|
it("path: WebGL2 constructor exists but the context is null -> static background", () => {
|
|
setContext(() => null);
|
|
|
|
render(<Hero />);
|
|
act(() => {
|
|
jest.advanceTimersByTime(3000);
|
|
});
|
|
|
|
expect(screen.queryByTestId("scene")).toBeNull();
|
|
});
|
|
|
|
it("path: the probe context is released right away (WEBGL_lose_context) before the real scene mounts", () => {
|
|
const loseContext = jest.fn();
|
|
setContext(() => ({
|
|
getExtension: (name: string) =>
|
|
name === "WEBGL_lose_context" ? { loseContext } : null,
|
|
}));
|
|
|
|
render(<Hero />);
|
|
act(() => {
|
|
jest.advanceTimersByTime(3000);
|
|
});
|
|
|
|
expect(loseContext).toHaveBeenCalledTimes(1);
|
|
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
|
});
|
|
|
|
it("path: the lose-context extension is missing -> the probe still succeeds", () => {
|
|
setContext(() => ({ getExtension: () => null }));
|
|
|
|
render(<Hero />);
|
|
act(() => {
|
|
jest.advanceTimersByTime(3000);
|
|
});
|
|
|
|
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
|
});
|
|
|
|
it("path: low power -> the WebGL probe and the timer are never started", () => {
|
|
mockMedia(["(pointer: coarse)"]);
|
|
const probe = jest.fn(() => ({ getExtension: () => null }));
|
|
setContext(probe);
|
|
|
|
render(<Hero />);
|
|
act(() => {
|
|
jest.advanceTimersByTime(3000);
|
|
});
|
|
|
|
expect(probe).not.toHaveBeenCalled();
|
|
expect(mockScenes).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("HeroField: rolagem e visibilidade", () => {
|
|
function section() {
|
|
return document.querySelector(".hero") as HTMLElement;
|
|
}
|
|
|
|
function place(top: number, height = 2000) {
|
|
section().getBoundingClientRect = () =>
|
|
({ top, height, bottom: top + height }) as DOMRect;
|
|
}
|
|
|
|
function scroll(top: number) {
|
|
place(top);
|
|
act(() => {
|
|
window.dispatchEvent(new Event("scroll"));
|
|
jest.advanceTimersByTime(40);
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
Object.defineProperty(window, "innerHeight", {
|
|
configurable: true,
|
|
value: 800,
|
|
});
|
|
});
|
|
|
|
it("path: no .hero ancestor -> no scroll variables and no observer, but the scene still mounts (not visible)", () => {
|
|
render(<HeroField />);
|
|
act(() => {
|
|
jest.advanceTimersByTime(3000);
|
|
});
|
|
|
|
expect(observers).toHaveLength(0);
|
|
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
|
expect(mockScenes.at(-1)?.visible).toBe(false);
|
|
});
|
|
|
|
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(-600);
|
|
|
|
act(() => {
|
|
window.dispatchEvent(new Event("resize"));
|
|
jest.advanceTimersByTime(40);
|
|
});
|
|
|
|
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: end of the dive then back up -> drawing stops, then resumes", () => {
|
|
render(<Hero />);
|
|
act(() => {
|
|
jest.advanceTimersByTime(3000);
|
|
});
|
|
reportIntersecting(true);
|
|
|
|
scroll(-1200);
|
|
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", () => {
|
|
render(<Hero />);
|
|
act(() => {
|
|
jest.advanceTimersByTime(3000);
|
|
});
|
|
|
|
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", () => {
|
|
render(<Hero />);
|
|
act(() => {
|
|
jest.advanceTimersByTime(3000);
|
|
});
|
|
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", () => {
|
|
render(<Hero />);
|
|
act(() => {
|
|
jest.advanceTimersByTime(3000);
|
|
});
|
|
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", () => {
|
|
const removeListener = jest.spyOn(window, "removeEventListener");
|
|
const removeDocListener = jest.spyOn(document, "removeEventListener");
|
|
const cancelFrame = jest.spyOn(window, "cancelAnimationFrame");
|
|
const view = render(<Hero />);
|
|
|
|
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();
|
|
});
|
|
});
|