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; 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) { 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 (
{children}
); } 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); await finishLoading(); const hero = section(); expect(() => view.unmount()).not.toThrow(); expect(hero).not.toHaveAttribute("data-dive"); }); });