import { act, render } from "@testing-library/react"; import HeroFieldScene from "./HeroFieldScene"; /* * O componente que monta a cena: cria o renderizador do Three.js (sem React Three Fiber), põe o canvas na página, * liga a batida só com o hero à vista e solta tudo ao sair. O WebGL do jsdom não existe: o renderizador é um dublê. */ type RendererOptions = { alpha?: boolean; antialias?: boolean; powerPreference?: string; }; const mockRenderers: MockRenderer[] = []; let mockFailToCreate = false; class MockRenderer { domElement = document.createElement("canvas"); pixelRatio = 1; options: RendererOptions; render = jest.fn(); dispose = jest.fn(); forceContextLoss = jest.fn(); setSize = jest.fn(); setPixelRatio = (value: number) => { this.pixelRatio = value; }; getPixelRatio = () => this.pixelRatio; constructor(options: RendererOptions) { if (mockFailToCreate) throw new Error("sem WebGL"); this.options = options; mockRenderers.push(this); } } // a classe é criada só quando a cena pede o renderizador (o mock é içado antes da declaração dela) jest.mock("three", () => ({ ...jest.requireActual("three"), WebGLRenderer: jest.fn( (options: RendererOptions) => new MockRenderer(options), ), })); const props = () => ({ progressRef: { current: 0 }, visible: false, onReady: jest.fn(), onError: jest.fn(), }); function setDevicePixelRatio(value: number) { Object.defineProperty(window, "devicePixelRatio", { configurable: true, value, }); } beforeEach(() => { jest.useFakeTimers(); mockRenderers.length = 0; mockFailToCreate = false; setDevicePixelRatio(1); }); afterEach(() => { jest.useRealTimers(); }); describe("HeroFieldScene (ambient 3D)", () => { it("creates a transparent renderer without antialiasing, on the high-performance GPU, and puts its canvas in the page", () => { const { container } = render(); expect(mockRenderers).toHaveLength(1); expect(mockRenderers[0].options).toMatchObject({ alpha: true, antialias: false, powerPreference: "high-performance", }); const canvas = container.querySelector("canvas"); expect(canvas).toBe(mockRenderers[0].domElement); expect(canvas).toHaveAttribute("aria-hidden", "true"); expect(container.firstElementChild).toHaveAttribute("aria-hidden", "true"); }); it.each([ [0.5, 1], [1, 1], [1.25, 1.25], [2, 1.5], [3, 1.5], ])("caps the pixel ratio at 1.5 (device %s -> %s)", (device, expected) => { setDevicePixelRatio(device); render(); expect(mockRenderers[0].pixelRatio).toBe(expected); }); it("draws on demand: a frame is asked for, never a fixed 60 fps loop, and nothing keeps running once settled", () => { const frames = jest.spyOn(window, "requestAnimationFrame"); render(); expect(frames).toHaveBeenCalledTimes(1); // a entrada ainda corre: cada quadro pede o seguinte, até assentar (2,6 s de entrada) act(() => { jest.advanceTimersByTime(5000); }); const settled = frames.mock.calls.length; expect(settled).toBeGreaterThan(2); expect(settled).toBeLessThan(5000 / 16); // hero fora de cena e tudo assentado: nenhum quadro a mais act(() => { jest.advanceTimersByTime(5000); }); expect(frames.mock.calls.length).toBe(settled); expect(mockRenderers[0].render.mock.calls.length).toBeGreaterThan(2); }); it("tells the page it is ready after the second frame", () => { const scene = props(); render(); expect(scene.onReady).not.toHaveBeenCalled(); act(() => { jest.advanceTimersByTime(100); }); expect(scene.onReady).toHaveBeenCalledTimes(1); }); it("starts the 30 fps pulse only while the hero is visible, and stops it when it leaves", () => { const frames = jest.spyOn(window, "requestAnimationFrame"); const scene = props(); const { rerender } = render(); act(() => { jest.advanceTimersByTime(6000); }); const hidden = frames.mock.calls.length; expect(jest.getTimerCount()).toBe(0); rerender(); act(() => { jest.advanceTimersByTime(20); // o quadro que a batida pediu sai }); expect(jest.getTimerCount()).toBe(1); // um setInterval, nenhum segundo laço act(() => { jest.advanceTimersByTime(980); }); const shown = frames.mock.calls.length - hidden; expect(shown).toBeGreaterThan(20); expect(shown).toBeLessThanOrEqual(34); rerender(); const stopped = frames.mock.calls.length; act(() => { jest.advanceTimersByTime(2000); }); expect(frames.mock.calls.length).toBe(stopped); expect(jest.getTimerCount()).toBe(0); }); it("releases the context, the canvas and the timers when it unmounts", () => { const scene = props(); const { container, unmount } = render( , ); expect(container.querySelector("canvas")).not.toBeNull(); unmount(); expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1); expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1); expect(mockRenderers[0].domElement.isConnected).toBe(false); expect(jest.getTimerCount()).toBe(0); const drawn = mockRenderers[0].render.mock.calls.length; act(() => { jest.advanceTimersByTime(1000); }); expect(mockRenderers[0].render.mock.calls.length).toBe(drawn); }); it("does not rebuild the scene when the parent passes new callbacks, and calls the newest ones", () => { const first = props(); const { rerender } = render(); const second = props(); rerender( , ); expect(mockRenderers).toHaveLength(1); act(() => { jest.advanceTimersByTime(100); }); expect(first.onReady).not.toHaveBeenCalled(); expect(second.onReady).toHaveBeenCalledTimes(1); }); it("tells the page and renders nothing but the empty host when WebGL cannot be created", () => { mockFailToCreate = true; const scene = props(); const { container } = render(); expect(scene.onError).toHaveBeenCalledTimes(1); expect(container.querySelector("canvas")).toBeNull(); }); it.each([ [0, 1], // aparelho que não informa a proporção: 1 [0.5, 1], // nunca abaixo de 1 [1.25, 1.25], [3, 1.5], // nunca acima de 1,5: cada ponto custa fragmentos e o fundo é ambiente ])( "draws at a pixel ratio of %p -> the renderer gets %p", (device, expected) => { setDevicePixelRatio(device); render(); expect(mockRenderers[0].pixelRatio).toBe(expected); }, ); it("tells the page when the WebGL context is lost after mounting, through the newest callback", () => { const scene = props(); render(); act(() => { mockRenderers[0].domElement.dispatchEvent( new Event("webglcontextlost", { cancelable: true }), ); }); expect(scene.onError).toHaveBeenCalledTimes(1); }); });