Files
portifolio-evandrodevbr/app/components/cinematic/BlocosScene.test.tsx
T
evandro c7d09d63d4
CI / audit (push) Successful in 10m38s
CI / verify (push) Successful in 13m10s
CI / deploy (push) Successful in 1m50s
CI / e2e (push) Successful in 18m48s
feat(site): blocos 3D da marca, novas ofertas e hero mais abrangente
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.
2026-11-10 03:48:25 -03:00

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);
});
});