import { act, render, screen } from "@testing-library/react"; import { rememberSlowScene, rememberSoftwareWebGL } from "@/lib/device"; import HeroField, { DIVE_QUERY } from "./HeroField"; type SceneProps = { visible: boolean; progressRef: { current: number }; placementRef?: { current: unknown }; stageRef?: { current: unknown }; tier?: string; labelsRef?: unknown; onReady: () => void; onError: () => void; onEstado?: (estado: number | null) => void; }; const mockScenes: SceneProps[] = []; const mockPrepare = jest.fn(); jest.mock("./BlocosScene", () => ({ __esModule: true, default: function MockScene(props: SceneProps) { mockScenes.push(props); return require("react").createElement("canvas", { "data-testid": "scene", }); }, prepareBlocos: () => { mockPrepare(); return Promise.resolve(); }, })); const original = { matchMedia: window.matchMedia, webgl2: (window as unknown as Record).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() {}, })); } function mockNavigator(values: Record) { 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; /** O palco e a peça (o pôster) como o Hero os monta: a peça fica na coluna da direita, 760 px à direita do palco. */ function Hero() { return (
); } const STAGE = { left: 0, top: -40, width: 1440, height: 900 }; const PIECE = { left: 760, top: 110, width: 600, height: 620 }; /** A área útil do mergulho: 6% a 94% da largura, do cabeçalho (60 px) + 24 px até 70% da altura. */ const STAGE_AREA = { x: 86.4, y: 84, width: 1267.2, height: 546 }; /** A caixa da peça medida a partir do palco. */ const PLACEMENT = { x: 760, y: 150, width: 600, height: 620 }; function mockLayout() { jest .spyOn(Element.prototype, "getBoundingClientRect") .mockImplementation(function (this: Element) { const box = this.classList.contains("hero-stage") ? STAGE : this.classList.contains("hero-piece") ? PIECE : { left: 0, top: 0, width: 0, height: 0 }; return { ...box, x: box.left, y: box.top } as DOMRect; }); } 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(); mockLayout(); sessionStorage.clear(); mockScenes.length = 0; mockPrepare.mockClear(); mockMedia(); mockNavigator({ hardwareConcurrency: 8, deviceMemory: undefined, connection: undefined, }); (window as unknown as Record).WebGL2RenderingContext = function WebGL2RenderingContext() {}; HTMLCanvasElement.prototype.getContext = (() => ({ getExtension: () => null, })) as unknown as typeof HTMLCanvasElement.prototype.getContext; }); afterEach(() => { jest.useRealTimers(); jest.restoreAllMocks(); sessionStorage.clear(); window.matchMedia = original.matchMedia; (window as unknown as Record).WebGL2RenderingContext = original.webgl2; HTMLCanvasElement.prototype.getContext = original.getContext; }); describe("HeroField (the 3D blocks over the hero poster)", () => { it("mounts the WebGL scene only after the page is idle, on a capable desktop", async () => { render(); 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("opens no WebGL context of its own, on hydration or when idle: the only one is the scene's", async () => { const probe = jest.fn(() => ({ getExtension: () => null })); HTMLCanvasElement.prototype.getContext = probe as unknown as typeof HTMLCanvasElement.prototype.getContext; render(); expect(probe).not.toHaveBeenCalled(); await idle(); expect(probe).not.toHaveBeenCalled(); expect(screen.getByTestId("scene")).toBeInTheDocument(); }); it("brings the geometry in before the Canvas, in a task of its own", async () => { render(); 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(); 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(); 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 weak phone (touch with 5 cores)", () => { mockMedia([DIVE_QUERY, "(pointer: coarse)"]); mockNavigator({ hardwareConcurrency: 5 }); }, ], ["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).WebGL2RenderingContext = undefined; }, ], // os vereditos vêm do contexto da cena (BlocosScene), que os anota na sessão; a página seguinte já nasce estática [ "a software WebGL found earlier in the session", () => rememberSoftwareWebGL(), ], ["a slow scene found earlier in the session", () => rememberSlowScene()], ])( "keeps only the poster (no WebGL, no dive, no scroll work) with %s", async (_label, arrange) => { arrange(); const { container } = render(); 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", async () => { const { container } = render(); 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); 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 scene still runs", async () => { mockMedia([]); render(); 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(); 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(""); }); it("full: covers the stage and hands the scene the measured poster box (where marca rests) and the useful dive area", async () => { const { container } = render(); await idle(); const scene = mockScenes.at(-1); expect(scene?.tier).toBe("full"); expect(scene?.placementRef?.current).toEqual(PLACEMENT); const area = scene?.stageRef?.current as typeof STAGE_AREA; for (const key of ["x", "y", "width", "height"] as const) expect(area[key]).toBeCloseTo(STAGE_AREA[key], 6); const field = container.querySelector(".hero-field") as HTMLElement; expect(field).toHaveAttribute("data-tier", "full"); expect(field.getAttribute("style")).toBeNull(); }); it("lite (a capable phone): no dive, the canvas box is the poster box, and no placement goes to the scene", async () => { mockMedia([DIVE_QUERY, "(pointer: coarse)"]); const { container } = render(); expect(section()).not.toHaveAttribute("data-dive"); await idle(); const scene = mockScenes.at(-1); expect(scene?.tier).toBe("lite"); expect(scene?.placementRef).toBeUndefined(); expect(scene?.stageRef).toBeUndefined(); const field = container.querySelector(".hero-field") as HTMLElement; expect(field).toHaveAttribute("data-tier", "lite"); expect(field.style).toMatchObject({ left: "760px", top: "150px", width: "600px", height: "620px", }); expect(section()).not.toHaveAttribute("data-dive"); expect(scrollWritesProgress()).toBe(false); }); it("lite: falls back to the poster when the scene gives up", async () => { mockMedia([DIVE_QUERY, "(pointer: coarse)"]); const { container } = render(); await idle(); act(() => mockScenes.at(-1)?.onReady()); act(() => mockScenes.at(-1)?.onError()); const field = container.querySelector(".hero-field") as HTMLElement; expect(screen.queryByTestId("scene")).toBeNull(); expect(field).not.toHaveAttribute("data-ready"); expect(field).not.toHaveAttribute("data-tier"); // de volta a cobrir o palco (inset: 0 do CSS), sem a caixa em linha expect([field.style.left, field.style.width]).toEqual(["", ""]); }); /** A legenda da peça, se existir. */ const caption = (container: HTMLElement) => container.querySelector(".hero-legenda"); const tell = (estado: number | null) => act(() => mockScenes.at(-1)?.onEstado?.(estado)); it("captions what the piece is building, under the box of the states and aligned to its left, and nothing on marca", async () => { const { container } = render(); await idle(); act(() => mockScenes.at(-1)?.onReady()); tell(0); expect(caption(container)).toBeNull(); for (const [estado, texto] of [ [1, "Sites e sistemas"], [2, "Integrações"], [3, "Painéis e dados"], ] as const) { tell(estado); const legenda = caption(container) as HTMLElement; expect(legenda).toHaveTextContent(texto); expect(legenda).toHaveAttribute("data-shown"); } const legenda = caption(container) as HTMLElement; // 12 px abaixo da caixa em que a cena põe os estados no topo, alinhada a eles: a caixa da peça sem os 22% // da esquerda (760 + 132 px), na proporção 17 x 12 (468 x 330,4 px), centrada na altura da peça (150 + 620 / 2) // (os estados têm 14,5 das 17 unidades dela, centrados: a legenda começa onde eles começam) expect(Number.parseFloat(legenda.style.left)).toBeCloseTo( 892 + (468 * (1 - 14.5 / 17)) / 2, 3, ); expect(Number.parseFloat(legenda.style.top)).toBeCloseTo( 460 + (468 * 12) / 17 / 2 + 12, 3, ); // dentro do campo decorativo expect(legenda.closest('[aria-hidden="true"]')).not.toBeNull(); // cada texto novo é digitado (.ds-type, um passo por caractere) const typed = legenda.querySelector(".ds-type"); expect(typed?.style.getPropertyValue("--n")).toBe("15"); // de volta à marca, ou fora da caixa (o mergulho): some, com o último texto ainda no lugar enquanto apaga tell(0); expect(caption(container)).not.toHaveAttribute("data-shown"); tell(2); tell(null); expect(caption(container)).not.toHaveAttribute("data-shown"); }); it("shows no caption before the scene is ready, nor after it gives up", async () => { const { container } = render(); await idle(); tell(1); expect(caption(container)).not.toHaveAttribute("data-shown"); act(() => mockScenes.at(-1)?.onReady()); expect(caption(container)).toHaveAttribute("data-shown"); act(() => mockScenes.at(-1)?.onError()); expect(caption(container)).toBeNull(); }); it("lite: puts the caption right under the canvas box, from 1024 px up only (below, the piece shares the title's corner)", async () => { mockMedia([DIVE_QUERY, "(pointer: coarse)"]); const { container } = render(); await idle(); act(() => mockScenes.at(-1)?.onReady()); tell(1); expect(caption(container)?.style).toMatchObject({ left: "0px", top: "632px", }); expect(caption(container)).toHaveClass("max-lg:hidden"); }); });