Hero e movimento - cena 3D em tempo real com os blocos da marca (kit feito no Blender, 3d/blocos.py): marca, tela, integrações e dados; segue a rolagem, tem vida em repouso, reage ao cursor e, parada no topo, passa sozinha pelos estados com legenda - três níveis de aparelho (full, lite, static); sem WebGL fica o pôster - título novo: "Sites e sistemas sob medida para o jeito que a sua empresa trabalha." - faixa de clientes com o ramo, linha do primeiro passo, hero alinhado às seções - rótulos digitados, entrada dos blocos, cortina de blocos entre páginas e capa compartilhada entre a lista de cases e o case Ofertas - atendimento no WhatsApp com IA e nota fiscal para a reforma tributária (páginas de serviço, cartões da home, formulário e aviso com data de validade) - página /ia atualizada, com fontes - peças 3D em loop nos cartões e topos de página (3d/pecas.py) Design - hover por papel (bloco nos botões, barra nos links), rótulos sem caixa alta - um destino por rótulo: "Falar sobre meu projeto" leva ao formulário, "Chamar no WhatsApp" abre o WhatsApp - logo montado em blocos, 404 redesenhado, grades sem célula vazia - ícones do PWA e manifesto novos Specs em specs/, contexto de design em DESIGN.md e PRODUCT.md.
615 lines
20 KiB
TypeScript
615 lines
20 KiB
TypeScript
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(<BlocosScene {...scene} />);
|
|
|
|
expect(scene.onError).toHaveBeenCalledTimes(1);
|
|
expect(mockRenderers).toHaveLength(0);
|
|
expect(container.querySelector("canvas")).toBeNull();
|
|
unmount();
|
|
|
|
mockFetchOk = true;
|
|
const again = props();
|
|
await mount(<BlocosScene {...again} />);
|
|
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(<BlocosScene {...props()} />);
|
|
|
|
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(<BlocosScene {...props()} tier={tier} />);
|
|
expect(mockRenderers[0].pixelRatio).toBe(expected);
|
|
},
|
|
);
|
|
|
|
it("draws every block in one instanced call that starts on marca", async () => {
|
|
await mount(<BlocosScene {...props()} />);
|
|
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(<BlocosScene {...scene} />);
|
|
|
|
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(
|
|
<BlocosScene {...scene} visible={false} />,
|
|
);
|
|
advance(3000);
|
|
expect(frames).not.toHaveBeenCalled();
|
|
expect(jest.getTimerCount()).toBe(0);
|
|
|
|
rerender(<BlocosScene {...scene} visible />);
|
|
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(<BlocosScene {...props()} />);
|
|
|
|
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(<BlocosScene {...scene} />);
|
|
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(<BlocosScene {...scene} tier="lite" />);
|
|
|
|
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(<BlocosScene {...scene} />);
|
|
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(<BlocosScene {...scene} />);
|
|
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(<BlocosScene {...scene} />);
|
|
|
|
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(<BlocosScene {...scene} />);
|
|
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(<BlocosScene {...scene} />);
|
|
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(<BlocosScene {...scene} />);
|
|
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(<BlocosScene {...scene} tier="lite" />);
|
|
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(<BlocosScene {...props()} />);
|
|
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(<BlocosScene {...props()} tier="lite" />);
|
|
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(<BlocosScene {...scene} />);
|
|
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(<BlocosScene {...scene} />);
|
|
|
|
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(<BlocosScene {...scene} />);
|
|
|
|
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(<BlocosScene {...scene} />);
|
|
|
|
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(<BlocosScene {...scene} />);
|
|
|
|
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(<BlocosScene {...scene} />);
|
|
|
|
expect(container.querySelector("canvas")).toBe(mockRenderers[0].domElement);
|
|
expect(scene.onError).not.toHaveBeenCalled();
|
|
expect(isKnownSoftwareWebGL()).toBe(false);
|
|
});
|
|
});
|