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; 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) { 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}
); } 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(); 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(); 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(); view.unmount(); expect(cancel).toHaveBeenCalledWith(7); expect(mockScenes).toHaveLength(0); }); it("path: unmounted before the fallback timer fires -> nothing mounts afterwards", () => { const view = render(); 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(); act(() => { jest.advanceTimersByTime(3000); }); expect(screen.queryByTestId("scene")).toBeNull(); }); it("path: WebGL2 constructor exists but the context is null -> static background", () => { setContext(() => null); render(); 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(); 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(); 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(); 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(); 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(); 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(-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(); 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(); 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(); 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(); 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(); 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(); 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(); }); });