import { act, render } from "@testing-library/react"; import { isKnownSoftwareWebGL } from "@/lib/device"; 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. A cena nasce em tarefas separadas (renderizador, * compilação do shader, primeiro quadro): os testes deixam essas tarefas correrem com `mount`. 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; /** O nome do renderizador que o contexto de teste conta (WEBGL_debug_renderer_info); `null` = a extensão não existe. */ let mockRendererName: string | null = null; /** Quando o teste quer segurar a compilação do shader: a promessa que `compileAsync` devolve. */ let mockCompile: () => Promise = () => Promise.resolve(); class MockRenderer { domElement = document.createElement("canvas"); pixelRatio = 1; options: RendererOptions; render = jest.fn(); compileAsync = jest.fn(() => mockCompile()); dispose = jest.fn(); forceContextLoss = jest.fn(); setSize = jest.fn(); setPixelRatio = (value: number) => { this.pixelRatio = value; }; getPixelRatio = () => this.pixelRatio; getContext = () => ({ getExtension: (name: string) => name === "WEBGL_debug_renderer_info" && mockRendererName !== null ? { UNMASKED_RENDERER_WEBGL: 37446 } : null, getParameter: (parameter: number) => parameter === 37446 ? mockRendererName : null, }); 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; mockRendererName = null; mockCompile = () => Promise.resolve(); setDevicePixelRatio(1); }); afterEach(() => { jest.useRealTimers(); }); /** Deixa as tarefas da montagem correrem (renderizador, compilação do shader, primeiro quadro pedido). */ const buildScene = (ms = 10) => act(async () => { await jest.advanceTimersByTimeAsync(ms); }); /** Só a tarefa que já estava na fila (as que ela mesma agenda esperam a próxima chamada): um passo da montagem. */ const nextTask = () => act(async () => { await jest.runOnlyPendingTimersAsync(); }); async function mount(ui: React.ReactElement) { const result = render(ui); await buildScene(); return result; } describe("HeroFieldScene (ambient 3D)", () => { it("creates a transparent renderer without antialiasing, on the high-performance GPU, and puts its canvas in the page", async () => { const { container } = await mount(); 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)", async (device, expected) => { setDevicePixelRatio(device); await mount(); 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", async () => { const frames = jest.spyOn(window, "requestAnimationFrame"); await mount(); 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", async () => { const scene = props(); await mount(); 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", async () => { const frames = jest.spyOn(window, "requestAnimationFrame"); const scene = props(); const { rerender } = await mount( , ); 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", async () => { const scene = props(); const { container, unmount } = await mount( , ); 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", async () => { const first = props(); const { rerender } = await mount(); 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", async () => { mockFailToCreate = true; const scene = props(); const { container } = await mount(); 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", async (device, expected) => { setDevicePixelRatio(device); await mount(); expect(mockRenderers[0].pixelRatio).toBe(expected); }, ); it("tells the page when the WebGL context is lost after mounting, through the newest callback", async () => { const scene = props(); await mount(); act(() => { mockRenderers[0].domElement.dispatchEvent( new Event("webglcontextlost", { cancelable: true }), ); }); expect(scene.onError).toHaveBeenCalledTimes(1); }); describe("built in separate tasks (no single long task)", () => { it("takes the steps one task at a time: the renderer, the point cloud and the shader compile, then the first frame", async () => { let finishCompile = () => {}; mockCompile = () => new Promise((resolve) => { finishCompile = () => resolve(undefined); }); const frames = jest.spyOn(window, "requestAnimationFrame"); const scene = props(); render(); // nada acontece dentro do commit do React: nem o contexto WebGL expect(mockRenderers).toHaveLength(0); await nextTask(); // tarefa 1: só o renderizador (o contexto WebGL); a nuvem de pontos e o shader esperam a vez expect(mockRenderers).toHaveLength(1); expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled(); expect(frames).not.toHaveBeenCalled(); await nextTask(); // tarefa 2: a nuvem de pontos e o pedido de compilação; o shader compila fora da thread principal e nada foi desenhado expect(mockRenderers[0].compileAsync).toHaveBeenCalledTimes(1); expect(frames).not.toHaveBeenCalled(); expect(mockRenderers[0].setSize).not.toHaveBeenCalled(); finishCompile(); await act(async () => { await Promise.resolve(); }); // a compilação acabou, mas o tamanho e o primeiro quadro esperam a própria tarefa expect(mockRenderers[0].setSize).not.toHaveBeenCalled(); expect(frames).not.toHaveBeenCalled(); await nextTask(); // tarefa 3: tamanho e o pedido do primeiro quadro, que sai noutra tarefa (requestAnimationFrame) expect(mockRenderers[0].setSize).toHaveBeenCalled(); expect(frames).toHaveBeenCalledTimes(1); expect(mockRenderers[0].render).not.toHaveBeenCalled(); await act(async () => { await jest.advanceTimersByTimeAsync(20); }); expect(mockRenderers[0].render).toHaveBeenCalled(); }); it("compiles the very scene it is going to draw", async () => { await mount(); const [scene, camera] = mockRenderers[0].compileAsync.mock .calls[0] as unknown as [{ isScene: boolean }, { isCamera: boolean }]; expect(scene.isScene).toBe(true); expect(camera.isCamera).toBe(true); await act(async () => { await jest.advanceTimersByTimeAsync(20); }); expect(mockRenderers[0].render.mock.calls[0][0]).toBe(scene); }); it("creates nothing when it is unmounted before the first task (StrictMode, a quick scroll away)", async () => { const scene = props(); const { unmount } = render(); unmount(); await buildScene(); expect(mockRenderers).toHaveLength(0); expect(scene.onError).not.toHaveBeenCalled(); expect(jest.getTimerCount()).toBe(0); }); it("frees the context if it is unmounted between the renderer and the point cloud", async () => { const { container, unmount } = render(); await nextTask(); expect(mockRenderers).toHaveLength(1); unmount(); await buildScene(); expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled(); expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1); expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1); expect(mockRenderers[0].domElement.isConnected).toBe(false); expect(container.querySelector("canvas")).toBeNull(); }); it("releases the context if it is unmounted while the shader compiles", async () => { let finishCompile = () => {}; mockCompile = () => new Promise((resolve) => { finishCompile = () => resolve(undefined); }); const frames = jest.spyOn(window, "requestAnimationFrame"); const { container, unmount } = await mount( , ); expect(mockRenderers).toHaveLength(1); unmount(); finishCompile(); await buildScene(); expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1); expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1); expect(mockRenderers[0].domElement.isConnected).toBe(false); expect(container.querySelector("canvas")).toBeNull(); expect(frames).not.toHaveBeenCalled(); }); it("tells the page, and frees the context, when the shader does not compile", async () => { mockCompile = () => Promise.reject(new Error("shader inválido")); const scene = props(); const { container } = await mount(); expect(scene.onError).toHaveBeenCalledTimes(1); expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1); expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1); expect(container.querySelector("canvas")).toBeNull(); }); it("applies the visibility the page asked for while it was still being built", async () => { const frames = jest.spyOn(window, "requestAnimationFrame"); const scene = props(); const { rerender } = render( , ); rerender(); await buildScene(); await act(async () => { await jest.advanceTimersByTimeAsync(980); }); // a batida de 30 quadros por segundo ligou assim que a cena nasceu expect(frames.mock.calls.length).toBeGreaterThan(20); }); }); }); describe("HeroFieldScene: WebGL por software (o teste é o contexto da própria cena)", () => { beforeEach(() => sessionStorage.clear()); afterEach(() => sessionStorage.clear()); it.each([ "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)", ])( "draws on the CPU (%s): no canvas, the page is told once, the context is freed and the session remembers it", async (name) => { mockRendererName = name; const scene = props(); const { container } = await mount(); // um só contexto: o do teste é o que a cena abriria para desenhar, sem um segundo expect(mockRenderers).toHaveLength(1); expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1); expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1); expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled(); expect(container.querySelector("canvas")).toBeNull(); expect(scene.onError).toHaveBeenCalledTimes(1); expect(scene.onReady).not.toHaveBeenCalled(); expect(isKnownSoftwareWebGL()).toBe(true); }, ); it("draws on the GPU: the canvas stays and the session is not marked", async () => { mockRendererName = "ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 Direct3D11 vs_5_0 ps_5_0, D3D11)"; const scene = props(); const { container } = await mount(); expect(container.querySelector("canvas")).toBe(mockRenderers[0].domElement); expect(scene.onError).not.toHaveBeenCalled(); expect(isKnownSoftwareWebGL()).toBe(false); }); it("does not report an unknown renderer (no debug extension) as software", async () => { mockRendererName = null; const scene = props(); await mount(); expect(scene.onError).not.toHaveBeenCalled(); expect(isKnownSoftwareWebGL()).toBe(false); }); });