472 lines
16 KiB
TypeScript
472 lines
16 KiB
TypeScript
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<unknown> = () => 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(<HeroFieldScene {...props()} />);
|
|
|
|
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(<HeroFieldScene {...props()} />);
|
|
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(<HeroFieldScene {...props()} />);
|
|
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(<HeroFieldScene {...scene} />);
|
|
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(
|
|
<HeroFieldScene {...scene} visible={false} />,
|
|
);
|
|
act(() => {
|
|
jest.advanceTimersByTime(6000);
|
|
});
|
|
const hidden = frames.mock.calls.length;
|
|
expect(jest.getTimerCount()).toBe(0);
|
|
|
|
rerender(<HeroFieldScene {...scene} visible />);
|
|
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(<HeroFieldScene {...scene} visible={false} />);
|
|
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(
|
|
<HeroFieldScene {...scene} visible />,
|
|
);
|
|
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(<HeroFieldScene {...first} />);
|
|
const second = props();
|
|
rerender(
|
|
<HeroFieldScene
|
|
progressRef={first.progressRef}
|
|
visible={false}
|
|
onReady={second.onReady}
|
|
onError={second.onError}
|
|
/>,
|
|
);
|
|
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(<HeroFieldScene {...scene} />);
|
|
|
|
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(<HeroFieldScene {...props()} />);
|
|
|
|
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(<HeroFieldScene {...scene} />);
|
|
|
|
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(<HeroFieldScene {...scene} />);
|
|
// 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(<HeroFieldScene {...props()} />);
|
|
|
|
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(<HeroFieldScene {...scene} />);
|
|
|
|
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(<HeroFieldScene {...props()} />);
|
|
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(
|
|
<HeroFieldScene {...props()} />,
|
|
);
|
|
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(<HeroFieldScene {...scene} />);
|
|
|
|
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(
|
|
<HeroFieldScene {...scene} visible={false} />,
|
|
);
|
|
rerender(<HeroFieldScene {...scene} visible />);
|
|
|
|
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(<HeroFieldScene {...scene} />);
|
|
|
|
// 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(<HeroFieldScene {...scene} />);
|
|
|
|
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(<HeroFieldScene {...scene} />);
|
|
|
|
expect(scene.onError).not.toHaveBeenCalled();
|
|
expect(isKnownSoftwareWebGL()).toBe(false);
|
|
});
|
|
});
|