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.
283 lines
9.8 KiB
TypeScript
283 lines
9.8 KiB
TypeScript
import { act, render, screen } from "@testing-library/react";
|
|
import HeroField, { DIVE_QUERY } from "./HeroField";
|
|
|
|
type SceneProps = {
|
|
visible: boolean;
|
|
progressRef: { current: number };
|
|
labelsRef?: unknown;
|
|
onReady: () => void;
|
|
onError: () => void;
|
|
};
|
|
const mockScenes: SceneProps[] = [];
|
|
const mockPrepare = jest.fn();
|
|
|
|
jest.mock("./HeroFieldScene", () => ({
|
|
__esModule: true,
|
|
default: function MockScene(props: SceneProps) {
|
|
mockScenes.push(props);
|
|
return require("react").createElement("canvas", {
|
|
"data-testid": "scene",
|
|
});
|
|
},
|
|
prepareField: () => mockPrepare(),
|
|
}));
|
|
|
|
const original = {
|
|
matchMedia: window.matchMedia,
|
|
webgl2: (window as unknown as Record<string, unknown>).WebGL2RenderingContext,
|
|
getContext: HTMLCanvasElement.prototype.getContext,
|
|
};
|
|
|
|
/** Consultas de mídia que valem; o mergulho (DIVE_QUERY) vale por padrão: tela alta, sem movimento reduzido. */
|
|
function mockMedia(active: string[] = [DIVE_QUERY]) {
|
|
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
|
matches: active.includes(query),
|
|
media: query,
|
|
addEventListener() {},
|
|
removeEventListener() {},
|
|
}));
|
|
}
|
|
|
|
/** Um WebGL2 que abre, e que diz na extensão de depuração em que renderizador desenha. */
|
|
function mockWebGLRenderer(renderer: string) {
|
|
HTMLCanvasElement.prototype.getContext = (() => ({
|
|
getExtension: (name: string) =>
|
|
name === "WEBGL_debug_renderer_info"
|
|
? { UNMASKED_RENDERER_WEBGL: 37446 }
|
|
: null,
|
|
getParameter: (parameter: number) =>
|
|
parameter === 37446 ? renderer : null,
|
|
})) as unknown as typeof HTMLCanvasElement.prototype.getContext;
|
|
}
|
|
|
|
function mockNavigator(values: Record<string, unknown>) {
|
|
for (const [key, value] of Object.entries(values))
|
|
Object.defineProperty(navigator, key, { configurable: true, value });
|
|
}
|
|
|
|
/** O que o globals.css dá ao hero: a altura do mergulho (telas de hero além dele) vem de `--hero-tail`. */
|
|
const HERO_TAIL = { "--hero-tail": 1.7 } as React.CSSProperties;
|
|
|
|
function Hero() {
|
|
return (
|
|
<section className="hero" style={HERO_TAIL}>
|
|
<HeroField />
|
|
</section>
|
|
);
|
|
}
|
|
|
|
const section = () => document.querySelector(".hero") as HTMLElement;
|
|
|
|
/** Rola o hero no meio do mergulho e diz se a rolagem chegou a escrever o progresso no CSS. */
|
|
function scrollWritesProgress() {
|
|
section().getBoundingClientRect = () =>
|
|
({ top: -700, height: 2760, bottom: 2300 }) as DOMRect;
|
|
act(() => {
|
|
window.dispatchEvent(new Event("scroll"));
|
|
jest.advanceTimersByTime(40);
|
|
});
|
|
return section().style.getPropertyValue("--hero-p") !== "";
|
|
}
|
|
|
|
/** Avança o relógio e deixa as promessas (importação do pacote, passos ociosos) resolverem no caminho. */
|
|
const advance = (ms: number) =>
|
|
act(async () => {
|
|
await jest.advanceTimersByTimeAsync(ms);
|
|
});
|
|
|
|
/** A página fica ociosa (sem requestIdleCallback: 1,2 s) e o Three.js entra, passo a passo. */
|
|
async function idle() {
|
|
await advance(1200); // a espera
|
|
await advance(60); // pacote carregado, geometria no próximo intervalo ocioso
|
|
await advance(60); // Canvas montado
|
|
}
|
|
|
|
beforeEach(() => {
|
|
jest.useFakeTimers();
|
|
mockScenes.length = 0;
|
|
mockPrepare.mockClear();
|
|
mockMedia();
|
|
mockNavigator({
|
|
hardwareConcurrency: 8,
|
|
deviceMemory: undefined,
|
|
connection: undefined,
|
|
});
|
|
(window as unknown as Record<string, unknown>).WebGL2RenderingContext =
|
|
function WebGL2RenderingContext() {};
|
|
HTMLCanvasElement.prototype.getContext = (() => ({
|
|
getExtension: () => null,
|
|
})) as unknown as typeof HTMLCanvasElement.prototype.getContext;
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.useRealTimers();
|
|
window.matchMedia = original.matchMedia;
|
|
(window as unknown as Record<string, unknown>).WebGL2RenderingContext =
|
|
original.webgl2;
|
|
HTMLCanvasElement.prototype.getContext = original.getContext;
|
|
});
|
|
|
|
describe("HeroField (ambient background)", () => {
|
|
it("mounts the WebGL scene only after the page is idle, on a capable desktop", async () => {
|
|
render(<Hero />);
|
|
expect(screen.queryByTestId("scene")).toBeNull();
|
|
|
|
await idle();
|
|
|
|
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
|
// o IntersectionObserver do jest.setup avisa que o hero está à vista: o desenho está liberado
|
|
expect(mockScenes.at(-1)?.visible).toBe(true);
|
|
});
|
|
|
|
it("brings the geometry in before the Canvas, in a task of its own", async () => {
|
|
render(<Hero />);
|
|
|
|
await advance(1200);
|
|
await advance(10); // o pacote carregou, mas o intervalo ocioso da geometria ainda não veio
|
|
expect(mockPrepare).not.toHaveBeenCalled();
|
|
expect(screen.queryByTestId("scene")).toBeNull();
|
|
|
|
await advance(60);
|
|
expect(mockPrepare).toHaveBeenCalledTimes(1);
|
|
expect(screen.queryByTestId("scene")).toBeNull();
|
|
|
|
await advance(60);
|
|
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
|
expect(mockPrepare).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not wait for the idle time once the visitor interacts", async () => {
|
|
render(<Hero />);
|
|
await advance(100);
|
|
expect(screen.queryByTestId("scene")).toBeNull();
|
|
|
|
act(() => {
|
|
window.dispatchEvent(new Event("pointermove"));
|
|
});
|
|
await advance(200);
|
|
|
|
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
|
});
|
|
|
|
it("is decorative: hidden from assistive technology and with no labels over the 3D", async () => {
|
|
const { container } = render(<Hero />);
|
|
await idle();
|
|
const field = container.querySelector(".hero-field");
|
|
expect(field).toHaveAttribute("aria-hidden", "true");
|
|
expect(container.querySelector(".hero-tag")).toBeNull();
|
|
for (const label of ["A tela", "As integrações", "Os dados"])
|
|
expect(screen.queryByText(label, { exact: false })).toBeNull();
|
|
expect(mockScenes.at(-1)?.labelsRef).toBeUndefined();
|
|
});
|
|
|
|
it.each<[string, () => void]>([
|
|
["a touch pointer", () => mockMedia([DIVE_QUERY, "(pointer: coarse)"])],
|
|
["reduced motion", () => mockMedia(["(prefers-reduced-motion: reduce)"])],
|
|
["the data saver", () => mockNavigator({ connection: { saveData: true } })],
|
|
["four cores", () => mockNavigator({ hardwareConcurrency: 4 })],
|
|
["4 GB of memory", () => mockNavigator({ deviceMemory: 4 })],
|
|
[
|
|
"no WebGL2",
|
|
() => {
|
|
(window as unknown as Record<string, unknown>).WebGL2RenderingContext =
|
|
undefined;
|
|
},
|
|
],
|
|
...[
|
|
"ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
|
|
"llvmpipe (LLVM 15.0.7, 256 bits)",
|
|
"ANGLE (Microsoft, Microsoft Basic Render Driver (0x0000008C) Direct3D11 vs_5_0 ps_5_0, D3D11)",
|
|
].map(
|
|
(renderer) =>
|
|
[
|
|
`software WebGL (${renderer.slice(0, 24)}...)`,
|
|
() => mockWebGLRenderer(renderer),
|
|
] as [string, () => void],
|
|
),
|
|
])(
|
|
"keeps the light static background (no WebGL, no dive, no scroll work) with %s",
|
|
async (_label, arrange) => {
|
|
arrange();
|
|
const { container } = render(<Hero />);
|
|
|
|
await idle();
|
|
|
|
expect(screen.queryByTestId("scene")).toBeNull();
|
|
expect(mockScenes).toHaveLength(0);
|
|
expect(container.querySelector(".hero-field")).not.toHaveAttribute(
|
|
"data-ready",
|
|
);
|
|
// o hero é o palco de 100svh: sem marca de mergulho, sem variável de rolagem
|
|
expect(section()).not.toHaveAttribute("data-dive");
|
|
expect(scrollWritesProgress()).toBe(false);
|
|
},
|
|
);
|
|
|
|
it("marks the hero for the dive (the CSS then gives it its scroll block) and follows the hero scroll with --hero-p and --hero-open", async () => {
|
|
const { container } = render(<Hero />);
|
|
const hero = container.querySelector(".hero") as HTMLElement;
|
|
expect(hero).toHaveAttribute("data-dive");
|
|
await idle();
|
|
// 2760 px de hero (altura = 1,7 tela de cauda + o mergulho): 800 de palco, 560 de Projetos subindo e 1400 de mergulho
|
|
let top = 0;
|
|
hero.getBoundingClientRect = () =>
|
|
({ top, height: 2760, bottom: top + 2760 }) as DOMRect;
|
|
Object.defineProperty(window, "innerHeight", {
|
|
configurable: true,
|
|
value: 800,
|
|
});
|
|
const scroll = (to: number) => {
|
|
top = -to;
|
|
act(() => {
|
|
window.dispatchEvent(new Event("scroll"));
|
|
jest.advanceTimersByTime(40);
|
|
});
|
|
};
|
|
|
|
scroll(0);
|
|
expect(mockScenes.at(-1)?.progressRef.current).toBe(0);
|
|
|
|
scroll(700); // meio do mergulho (1400 px de trilho)
|
|
expect(Number(hero.style.getPropertyValue("--hero-p"))).toBeCloseTo(0.5);
|
|
// a máscara já se abriu por completo: o fundo aparece inteiro
|
|
expect(Number(hero.style.getPropertyValue("--hero-open"))).toBe(1);
|
|
|
|
scroll(1400);
|
|
expect(Number(hero.style.getPropertyValue("--hero-p"))).toBe(1);
|
|
expect(mockScenes.at(-1)?.visible).toBe(false);
|
|
});
|
|
|
|
it("does not start the dive on a short screen (the CSS does not apply there), but the ambient scene still runs", async () => {
|
|
mockMedia([]);
|
|
|
|
render(<Hero />);
|
|
await idle();
|
|
|
|
expect(section()).not.toHaveAttribute("data-dive");
|
|
expect(scrollWritesProgress()).toBe(false);
|
|
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
|
});
|
|
|
|
it("reveals the scene (data-ready) once it has drawn its first frames, and goes back to the plain hero if it fails", async () => {
|
|
const { container } = render(<Hero />);
|
|
await idle();
|
|
expect(container.querySelector(".hero-field")).not.toHaveAttribute(
|
|
"data-ready",
|
|
);
|
|
|
|
act(() => mockScenes.at(-1)?.onReady());
|
|
expect(container.querySelector(".hero-field")).toHaveAttribute(
|
|
"data-ready",
|
|
);
|
|
|
|
act(() => mockScenes.at(-1)?.onError());
|
|
expect(container.querySelector(".hero-field")).not.toHaveAttribute(
|
|
"data-ready",
|
|
);
|
|
expect(screen.queryByTestId("scene")).toBeNull();
|
|
// de volta ao palco de 100svh: sem marca de mergulho nem variáveis de rolagem
|
|
expect(section()).not.toHaveAttribute("data-dive");
|
|
expect(section().style.getPropertyValue("--hero-p")).toBe("");
|
|
});
|
|
});
|