import { readFileSync } from "node:fs"; import { join } from "node:path"; import { act, render } from "@testing-library/react"; import type { InstancedBufferGeometry, Mesh, ShaderMaterial, Texture, } from "three"; import { stateAt } from "@/lib/blocos"; import { isKnownSlowScene, isKnownSoftwareWebGL } from "@/lib/device"; import BlocosScene from "./BlocosScene"; /* * A cena de blocos: baixa os ativos, cria o renderizador do Three.js, compila o shader em tarefas separadas e desenha * sob demanda. O WebGL do jsdom não existe: o renderizador é um dublê, e os ativos (o bloco do glTF, os matcaps e o * JSON dos estados) vêm de dublês também; o JSON é o arquivo real. */ type RendererOptions = { alpha?: boolean; antialias?: boolean; powerPreference?: string; }; const mockRenderers: MockRenderer[] = []; /** 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; class MockRenderer { domElement = document.createElement("canvas"); pixelRatio = 1; options: RendererOptions; render = jest.fn(); compileAsync = jest.fn(() => Promise.resolve()); 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) { this.options = options; mockRenderers.push(this); } } jest.mock("three", () => { const three = jest.requireActual("three"); return { ...three, WebGLRenderer: jest.fn( (options: RendererOptions) => new MockRenderer(options), ), // o jsdom não carrega imagens: o matcap é uma textura vazia TextureLoader: jest.fn(() => ({ loadAsync: () => Promise.resolve(new three.Texture()), })), }; }); jest.mock("three/examples/jsm/loaders/GLTFLoader.js", () => { const { BoxGeometry, Group, Mesh } = jest.requireActual("three"); return { GLTFLoader: jest.fn(() => ({ loadAsync: () => { const scene = new Group(); scene.add(new Mesh(new BoxGeometry(1, 1, 1))); return Promise.resolve({ scene }); }, })), }; }); const blocosJson = JSON.parse( readFileSync(join(process.cwd(), "public/3d/blocos.json"), "utf8"), ); let mockFetchOk = true; const originalFetch = global.fetch; const props = () => ({ progressRef: { current: 0 }, visible: true, onReady: jest.fn(), onError: jest.fn(), }); function setDevicePixelRatio(value: number) { Object.defineProperty(window, "devicePixelRatio", { configurable: true, value, }); } beforeEach(() => { jest.useFakeTimers(); mockRenderers.length = 0; mockRendererName = null; mockFetchOk = true; global.fetch = jest.fn(() => Promise.resolve({ ok: mockFetchOk, status: mockFetchOk ? 200 : 404, json: () => Promise.resolve(blocosJson), }), ) as unknown as typeof fetch; setDevicePixelRatio(1); sessionStorage.clear(); }); afterEach(() => { jest.useRealTimers(); jest.restoreAllMocks(); global.fetch = originalFetch; sessionStorage.clear(); }); /** Deixa as tarefas da montagem correrem (ativos, renderizador, compilação do shader, primeiro quadro pedido). */ const buildScene = (ms = 10) => act(async () => { await jest.advanceTimersByTimeAsync(ms); }); async function mount(ui: React.ReactElement) { const result = render(ui); await buildScene(); return result; } const advance = (ms: number) => act(() => { jest.advanceTimersByTime(ms); }); /** A malha que o último quadro desenhou (a cena tem uma só). */ function drawnMesh() { const calls = mockRenderers[0].render.mock.calls as unknown as [ { children: Mesh[] }, ][]; return calls.at(-1)?.[0].children[0] as Mesh< InstancedBufferGeometry, ShaderMaterial >; } const uniform = (name: string) => drawnMesh().material.uniforms[name].value as number; // precisa ser o primeiro: os ativos ficam em cache depois do primeiro download que dá certo describe("BlocosScene when the assets do not download", () => { it("tells the page once, creates no context, and lets the next mount try again", async () => { mockFetchOk = false; const scene = props(); const { container, unmount } = await mount(); expect(scene.onError).toHaveBeenCalledTimes(1); expect(mockRenderers).toHaveLength(0); expect(container.querySelector("canvas")).toBeNull(); unmount(); mockFetchOk = true; const again = props(); await mount(); expect(again.onError).not.toHaveBeenCalled(); expect(mockRenderers).toHaveLength(1); }); }); describe("BlocosScene", () => { it("creates a transparent, antialiased renderer 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).toEqual({ alpha: true, antialias: true, powerPreference: "high-performance", }); const canvas = container.querySelector("canvas"); expect(canvas).toBe(mockRenderers[0].domElement); expect(canvas).toHaveAttribute("aria-hidden", "true"); }); it.each([ ["full", 3, 1.75], ["full", 1.5, 1.5], ["lite", 3, 1.5], ["lite", 0.5, 1], ] as const)( "caps the pixel ratio on %s (device %p -> %p)", async (tier, device, expected) => { setDevicePixelRatio(device); await mount(); expect(mockRenderers[0].pixelRatio).toBe(expected); }, ); it("draws every block in one instanced call that starts on marca", async () => { await mount(); advance(20); const mesh = drawnMesh(); expect(mesh.geometry.type).toBe("InstancedBufferGeometry"); expect(mesh.geometry.instanceCount).toBe(238); expect(uniform("uFrom")).toBe(0); expect(uniform("uTo")).toBe(0); }); it("tells the page it is ready on the second frame drawn", async () => { const scene = props(); await mount(); advance(17); expect(mockRenderers[0].render).toHaveBeenCalledTimes(1); expect(scene.onReady).not.toHaveBeenCalled(); advance(17); expect(mockRenderers[0].render).toHaveBeenCalledTimes(2); expect(scene.onReady).toHaveBeenCalledTimes(1); advance(3000); expect(scene.onReady).toHaveBeenCalledTimes(1); }); it("draws nothing while not visible", async () => { const frames = jest.spyOn(window, "requestAnimationFrame"); const scene = props(); const { rerender } = await mount( , ); advance(3000); expect(frames).not.toHaveBeenCalled(); expect(jest.getTimerCount()).toBe(0); rerender(); advance(100); expect(mockRenderers[0].render).toHaveBeenCalled(); }); it("draws at screen rate through the entry and the first 60 frames, then only on the 30 fps pulse", async () => { const frames = jest.spyOn(window, "requestAnimationFrame"); await mount(); advance(1500); // entrada de 1,4 s, no ritmo da tela const entry = frames.mock.calls.length; expect(entry).toBeGreaterThan(80); expect(uniform("uIntro")).toBe(1); advance(1000); const pulse = frames.mock.calls.length - entry; expect(pulse).toBeGreaterThan(20); expect(pulse).toBeLessThanOrEqual(34); }); it("follows the dive progress on full", async () => { const scene = props(); await mount(); advance(1500); scene.progressRef.current = 0.63; advance(4000); expect(uniform("uFrom")).toBe(2); expect(uniform("uTo")).toBe(2); expect(uniform("uT")).toBe(0); }); it("changes state by itself on lite, 4 s on each, even with nobody touching the page", async () => { const scene = props(); scene.progressRef.current = 0.93; // ignorado no lite await mount(); advance(1400 + 3500); // entrada e 3,5 s parado na marca expect([uniform("uFrom"), uniform("uTo")]).toEqual([0, 0]); advance(1100); // no meio da troca de 1,2 s expect([uniform("uFrom"), uniform("uTo")]).toEqual([0, 1]); expect(uniform("uT")).toBeGreaterThan(0); advance(1000); expect([uniform("uFrom"), uniform("uTo"), uniform("uT")]).toEqual([ 1, 1, 0, ]); }); /** O hospedeiro com 800 x 600 px no canto da janela (o jsdom não faz layout). */ const hostOf800x600 = () => { jest.spyOn(Element.prototype, "clientWidth", "get").mockReturnValue(800); jest.spyOn(Element.prototype, "clientHeight", "get").mockReturnValue(600); jest .spyOn(Element.prototype, "getBoundingClientRect") .mockReturnValue(new DOMRect(0, 0, 800, 600)); }; const fire = (event: Event) => act(() => { window.dispatchEvent(event); }); it("brings the resting state to life after the entry and fades it out while the piece changes state", async () => { const scene = props(); await mount(); advance(1000); expect(uniform("uVida")).toBe(0); // a entrada ainda assenta advance(1500); expect(uniform("uVida")).toBeGreaterThan(0.9); expect(uniform("uEstado")).toBe(0); const tempo = uniform("uTempo"); advance(500); expect(uniform("uTempo")).toBeCloseTo(tempo + 0.5, 1); // no meio da troca da marca para a tela fire(new Event("scroll")); scene.progressRef.current = 0.23; advance(1000); expect(uniform("uT")).toBeGreaterThan(0); expect(uniform("uVida")).toBeLessThan(0.05); fire(new Event("scroll")); scene.progressRef.current = 0.35; advance(3000); expect(uniform("uEstado")).toBe(1); expect(uniform("uVida")).toBeGreaterThan(0.9); }); it("freezes the resting life with the pulse once nobody moves", async () => { const scene = props(); scene.progressRef.current = 0.35; // fora do topo: sem o ciclo em repouso await mount(); advance(6000); // a batida para 5 s depois do último gesto const drawn = mockRenderers[0].render.mock.calls.length; const tempo = uniform("uTempo"); advance(3000); expect(mockRenderers[0].render.mock.calls.length).toBe(drawn); expect(uniform("uTempo")).toBe(tempo); fire(new Event("scroll")); advance(500); expect(uniform("uTempo")).toBeGreaterThan(tempo); }); /** O estado que o último quadro desenhou: de, para e t. */ const shown = () => [uniform("uFrom"), uniform("uTo"), uniform("uT")]; it("parked at the top on full, cycles by itself through the four states, about 3 s on each, drawing only the changes", async () => { const scene = { ...props(), onEstado: jest.fn() }; await mount(); advance(1400 + 2500); // entrada e 2,5 s parado na marca expect(shown()).toEqual([0, 0, 0]); advance(1100); // na troca de 1,2 s para a tela expect([uniform("uFrom"), uniform("uTo")]).toEqual([0, 1]); expect(uniform("uT")).toBeGreaterThan(0); // durante a troca, nenhuma legenda: os blocos da marca ainda ocupam o lugar dela expect(scene.onEstado).toHaveBeenLastCalledWith(null); advance(1500); expect(shown()).toEqual([1, 1, 0]); expect(scene.onEstado).toHaveBeenLastCalledWith(1); // assentada na tela // a batida parou (5 s sem gesto): parada na tela, nenhum quadro até a próxima troca const drawn = mockRenderers[0].render.mock.calls.length; advance(1000); expect(mockRenderers[0].render.mock.calls.length).toBe(drawn); advance(4200 * 2 + 1000); // integrações, dados expect(shown()).toEqual([3, 3, 0]); advance(4200); expect(shown()).toEqual([0, 0, 0]); // cada legenda entra com a peça assentada e sai quando a troca seguinte começa expect(scene.onEstado.mock.calls.map(([estado]) => estado)).toEqual([ 0, null, 1, null, 2, null, 3, null, 0, ]); }); it("hands the piece back to the scroll at once and without a jump, and starts the cycle again only after 3 s at the top", async () => { const scene = { ...props(), onEstado: jest.fn() }; await mount(); advance(1400 + 3000 + 1200 + 1000); expect(shown()).toEqual([1, 1, 0]); // parada na tela, pelo ciclo fire(new Event("scroll")); scene.progressRef.current = 0.02; advance(50); // a próxima batida // a rolagem pede a marca: a peça sai da tela para ela, do ponto em que estava expect([uniform("uFrom"), uniform("uTo")]).toEqual([1, 0]); expect(uniform("uT")).toBeGreaterThan(0); expect(uniform("uT")).toBeLessThan(0.1); expect(scene.onEstado).toHaveBeenLastCalledWith(null); // em troca: sem legenda advance(1000); expect(shown()).toEqual([0, 0, 0]); // de volta ao topo: 3 s parada na marca antes da troca seguinte fire(new Event("scroll")); scene.progressRef.current = 0; advance(3000); expect(shown()).toEqual([0, 0, 0]); advance(1500); expect([uniform("uFrom"), uniform("uTo")]).toEqual([0, 1]); }); it("follows the dive once the scroll leaves the top, and tells the legend the piece has left its box", async () => { const scene = { ...props(), onEstado: jest.fn() }; await mount(); advance(1400 + 3000 + 1200 + 1000); expect(scene.onEstado).toHaveBeenLastCalledWith(1); fire(new Event("scroll")); scene.progressRef.current = 0.63; advance(4000); expect(shown()).toEqual([2, 2, 0]); expect(scene.onEstado).toHaveBeenLastCalledWith(null); }); it("takes the piece from the cycle into the middle of a dive transition and keeps it there", async () => { const scene = props(); await mount(); advance(1400 + 4200 + 4200 + 1000); expect(shown()).toEqual([2, 2, 0]); // parada nas integrações, pelo ciclo // a rolagem para no meio da troca da marca para a tela fire(new Event("scroll")); scene.progressRef.current = 0.26; advance(3000); expect([uniform("uFrom"), uniform("uTo")]).toEqual([0, 1]); expect(uniform("uT")).toBeCloseTo(stateAt(0.26).t, 6); }); it("lite: tells the legend every state it builds", async () => { const scene = { ...props(), onEstado: jest.fn() }; await mount(); advance(1400 + 4000 + 1200 + 500); expect(scene.onEstado.mock.calls.map(([estado]) => estado)).toEqual([ 0, null, 1, ]); }); it("pushes the blocks under the mouse forward on full and lets them go when the mouse leaves", async () => { hostOf800x600(); await mount(); advance(2500); expect(uniform("uCursorPeso")).toBe(0); // o centro do hospedeiro cai no centro da peça fire(new PointerEvent("pointermove", { clientX: 400, clientY: 300 })); advance(1000); expect(uniform("uCursorPeso")).toBeGreaterThan(0.9); const cursor = drawnMesh().material.uniforms.uCursor.value as { x: number; y: number; }; expect(Math.hypot(cursor.x, cursor.y)).toBeLessThan(0.5); // perto do canto de cima à esquerda, longe da peça, o ponto vai junto fire(new PointerEvent("pointermove", { clientX: 100, clientY: 100 })); advance(1000); expect(cursor.x).toBeLessThan(-3); expect(cursor.y).toBeGreaterThan(2); fire(new PointerEvent("pointerout", { relatedTarget: null })); advance(1500); expect(uniform("uCursorPeso")).toBeLessThan(0.01); }); it("gives lite the resting life on the pulse, without the cursor", async () => { hostOf800x600(); await mount(); advance(2500); expect(uniform("uVida")).toBeGreaterThan(0.9); const tempo = uniform("uTempo"); fire(new PointerEvent("pointermove", { clientX: 400, clientY: 300 })); advance(500); expect(uniform("uTempo")).toBeCloseTo(tempo + 0.5, 1); expect(uniform("uCursorPeso")).toBe(0); }); it("releases the geometry, the material, the textures, the context, the canvas, the timers and the listeners on unmount", async () => { const removed = jest.spyOn(window, "removeEventListener"); const scene = props(); const { container, unmount } = await mount(); advance(20); const mesh = drawnMesh(); const disposed = [ jest.spyOn(mesh.geometry, "dispose"), jest.spyOn(mesh.material, "dispose"), jest.spyOn(mesh.material.uniforms.uPorcelana.value as Texture, "dispose"), jest.spyOn(mesh.material.uniforms.uLima.value as Texture, "dispose"), ]; const canvasRemoved = jest.spyOn( mockRenderers[0].domElement, "removeEventListener", ); unmount(); for (const dispose of disposed) expect(dispose).toHaveBeenCalledTimes(1); expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1); expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1); expect(container.querySelector("canvas")).toBeNull(); expect(jest.getTimerCount()).toBe(0); expect(removed.mock.calls.map(([type]) => type)).toEqual( expect.arrayContaining(["resize", "pointermove", "pointerout"]), ); expect(canvasRemoved).toHaveBeenCalledWith( "webglcontextlost", expect.any(Function), ); const drawn = mockRenderers[0].render.mock.calls.length; advance(1000); expect(mockRenderers[0].render.mock.calls.length).toBe(drawn); }); it("tells the page once when the WebGL context is lost", async () => { const scene = props(); await mount(); act(() => { mockRenderers[0].domElement.dispatchEvent( new Event("webglcontextlost", { cancelable: true }), ); mockRenderers[0].domElement.dispatchEvent( new Event("webglcontextlost", { cancelable: true }), ); }); expect(scene.onError).toHaveBeenCalledTimes(1); }); it("gives up once and remembers it when the entry frames average more than 28 ms", async () => { // quadros de 40 ms: um aparelho que não segura a cena jest .spyOn(window, "requestAnimationFrame") .mockImplementation( (callback) => setTimeout( () => callback(performance.now()), 40, ) as unknown as number, ); jest .spyOn(window, "cancelAnimationFrame") .mockImplementation((id) => clearTimeout(id)); const scene = props(); await mount(); advance(3000); expect(scene.onError).toHaveBeenCalledTimes(1); expect(isKnownSlowScene()).toBe(true); const drawn = mockRenderers[0].render.mock.calls.length; expect(drawn).toBeLessThanOrEqual(62); advance(2000); expect(mockRenderers[0].render.mock.calls.length).toBe(drawn); }); it("keeps a device that holds 60 fps, without remembering anything", async () => { const scene = props(); await mount(); advance(3000); expect(scene.onError).not.toHaveBeenCalled(); expect(isKnownSlowScene()).toBe(false); }); }); describe("BlocosScene: software WebGL", () => { it("draws on the CPU: no canvas, the page is told once, the context is freed and the session remembers it", async () => { mockRendererName = "llvmpipe (LLVM 15.0.7, 256 bits)"; const scene = props(); const { container } = await mount(); 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)"; const scene = props(); const { container } = await mount(); expect(container.querySelector("canvas")).toBe(mockRenderers[0].domElement); expect(scene.onError).not.toHaveBeenCalled(); expect(isKnownSoftwareWebGL()).toBe(false); }); });