Files
portifolio-evandrodevbr/app/components/cinematic/HeroField.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

452 lines
16 KiB
TypeScript

import { act, render, screen } from "@testing-library/react";
import { rememberSlowScene, rememberSoftwareWebGL } from "@/lib/device";
import HeroField, { DIVE_QUERY } from "./HeroField";
type SceneProps = {
visible: boolean;
progressRef: { current: number };
placementRef?: { current: unknown };
stageRef?: { current: unknown };
tier?: string;
labelsRef?: unknown;
onReady: () => void;
onError: () => void;
onEstado?: (estado: number | null) => void;
};
const mockScenes: SceneProps[] = [];
const mockPrepare = jest.fn();
jest.mock("./BlocosScene", () => ({
__esModule: true,
default: function MockScene(props: SceneProps) {
mockScenes.push(props);
return require("react").createElement("canvas", {
"data-testid": "scene",
});
},
prepareBlocos: () => {
mockPrepare();
return Promise.resolve();
},
}));
const original = {
matchMedia: window.matchMedia,
webgl2: (window as unknown as Record<string, unknown>).WebGL2RenderingContext,
getContext: HTMLCanvasElement.prototype.getContext,
};
/** Consultas de mídia que valem; o mergulho (DIVE_QUERY) vale por padrão: tela alta, sem movimento reduzido. */
function mockMedia(active: string[] = [DIVE_QUERY]) {
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
matches: active.includes(query),
media: query,
addEventListener() {},
removeEventListener() {},
}));
}
function mockNavigator(values: Record<string, unknown>) {
for (const [key, value] of Object.entries(values))
Object.defineProperty(navigator, key, { configurable: true, value });
}
/** O que o globals.css dá ao hero: a altura do mergulho (telas de hero além dele) vem de `--hero-tail`. */
const HERO_TAIL = { "--hero-tail": 1.7 } as React.CSSProperties;
/** O palco e a peça (o pôster) como o Hero os monta: a peça fica na coluna da direita, 760 px à direita do palco. */
function Hero() {
return (
<section className="hero" style={HERO_TAIL}>
<div
className="hero-stage"
style={{ "--site-header-height": "60px" } as React.CSSProperties}
>
<HeroField />
<div className="hero-piece" />
</div>
</section>
);
}
const STAGE = { left: 0, top: -40, width: 1440, height: 900 };
const PIECE = { left: 760, top: 110, width: 600, height: 620 };
/** A área útil do mergulho: 6% a 94% da largura, do cabeçalho (60 px) + 24 px até 70% da altura. */
const STAGE_AREA = { x: 86.4, y: 84, width: 1267.2, height: 546 };
/** A caixa da peça medida a partir do palco. */
const PLACEMENT = { x: 760, y: 150, width: 600, height: 620 };
function mockLayout() {
jest
.spyOn(Element.prototype, "getBoundingClientRect")
.mockImplementation(function (this: Element) {
const box = this.classList.contains("hero-stage")
? STAGE
: this.classList.contains("hero-piece")
? PIECE
: { left: 0, top: 0, width: 0, height: 0 };
return { ...box, x: box.left, y: box.top } as DOMRect;
});
}
const section = () => document.querySelector(".hero") as HTMLElement;
/** Rola o hero no meio do mergulho e diz se a rolagem chegou a escrever o progresso no CSS. */
function scrollWritesProgress() {
section().getBoundingClientRect = () =>
({ top: -700, height: 2760, bottom: 2300 }) as DOMRect;
act(() => {
window.dispatchEvent(new Event("scroll"));
jest.advanceTimersByTime(40);
});
return section().style.getPropertyValue("--hero-p") !== "";
}
/** Avança o relógio e deixa as promessas (importação do pacote, passos ociosos) resolverem no caminho. */
const advance = (ms: number) =>
act(async () => {
await jest.advanceTimersByTimeAsync(ms);
});
/** A página fica ociosa (sem requestIdleCallback: 1,2 s) e o Three.js entra, passo a passo. */
async function idle() {
await advance(1200); // a espera
await advance(60); // pacote carregado, geometria no próximo intervalo ocioso
await advance(60); // Canvas montado
}
beforeEach(() => {
jest.useFakeTimers();
mockLayout();
sessionStorage.clear();
mockScenes.length = 0;
mockPrepare.mockClear();
mockMedia();
mockNavigator({
hardwareConcurrency: 8,
deviceMemory: undefined,
connection: undefined,
});
(window as unknown as Record<string, unknown>).WebGL2RenderingContext =
function WebGL2RenderingContext() {};
HTMLCanvasElement.prototype.getContext = (() => ({
getExtension: () => null,
})) as unknown as typeof HTMLCanvasElement.prototype.getContext;
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
sessionStorage.clear();
window.matchMedia = original.matchMedia;
(window as unknown as Record<string, unknown>).WebGL2RenderingContext =
original.webgl2;
HTMLCanvasElement.prototype.getContext = original.getContext;
});
describe("HeroField (the 3D blocks over the hero poster)", () => {
it("mounts the WebGL scene only after the page is idle, on a capable desktop", async () => {
render(<Hero />);
expect(screen.queryByTestId("scene")).toBeNull();
await idle();
expect(screen.getByTestId("scene")).toBeInTheDocument();
// o IntersectionObserver do jest.setup avisa que o hero está à vista: o desenho está liberado
expect(mockScenes.at(-1)?.visible).toBe(true);
});
it("opens no WebGL context of its own, on hydration or when idle: the only one is the scene's", async () => {
const probe = jest.fn(() => ({ getExtension: () => null }));
HTMLCanvasElement.prototype.getContext =
probe as unknown as typeof HTMLCanvasElement.prototype.getContext;
render(<Hero />);
expect(probe).not.toHaveBeenCalled();
await idle();
expect(probe).not.toHaveBeenCalled();
expect(screen.getByTestId("scene")).toBeInTheDocument();
});
it("brings the geometry in before the Canvas, in a task of its own", async () => {
render(<Hero />);
await advance(1200);
await advance(10); // o pacote carregou, mas o intervalo ocioso da geometria ainda não veio
expect(mockPrepare).not.toHaveBeenCalled();
expect(screen.queryByTestId("scene")).toBeNull();
await advance(60);
expect(mockPrepare).toHaveBeenCalledTimes(1);
expect(screen.queryByTestId("scene")).toBeNull();
await advance(60);
expect(screen.getByTestId("scene")).toBeInTheDocument();
expect(mockPrepare).toHaveBeenCalledTimes(1);
});
it("does not wait for the idle time once the visitor interacts", async () => {
render(<Hero />);
await advance(100);
expect(screen.queryByTestId("scene")).toBeNull();
act(() => {
window.dispatchEvent(new Event("pointermove"));
});
await advance(200);
expect(screen.getByTestId("scene")).toBeInTheDocument();
});
it("is decorative: hidden from assistive technology and with no labels over the 3D", async () => {
const { container } = render(<Hero />);
await idle();
const field = container.querySelector(".hero-field");
expect(field).toHaveAttribute("aria-hidden", "true");
expect(container.querySelector(".hero-tag")).toBeNull();
for (const label of ["A tela", "As integrações", "Os dados"])
expect(screen.queryByText(label, { exact: false })).toBeNull();
expect(mockScenes.at(-1)?.labelsRef).toBeUndefined();
});
it.each<[string, () => void]>([
[
"a weak phone (touch with 5 cores)",
() => {
mockMedia([DIVE_QUERY, "(pointer: coarse)"]);
mockNavigator({ hardwareConcurrency: 5 });
},
],
["reduced motion", () => mockMedia(["(prefers-reduced-motion: reduce)"])],
["the data saver", () => mockNavigator({ connection: { saveData: true } })],
["four cores", () => mockNavigator({ hardwareConcurrency: 4 })],
["4 GB of memory", () => mockNavigator({ deviceMemory: 4 })],
[
"no WebGL2",
() => {
(window as unknown as Record<string, unknown>).WebGL2RenderingContext =
undefined;
},
],
// os vereditos vêm do contexto da cena (BlocosScene), que os anota na sessão; a página seguinte já nasce estática
[
"a software WebGL found earlier in the session",
() => rememberSoftwareWebGL(),
],
["a slow scene found earlier in the session", () => rememberSlowScene()],
])(
"keeps only the poster (no WebGL, no dive, no scroll work) with %s",
async (_label, arrange) => {
arrange();
const { container } = render(<Hero />);
await idle();
expect(screen.queryByTestId("scene")).toBeNull();
expect(mockScenes).toHaveLength(0);
expect(container.querySelector(".hero-field")).not.toHaveAttribute(
"data-ready",
);
// o hero é o palco de 100svh: sem marca de mergulho, sem variável de rolagem
expect(section()).not.toHaveAttribute("data-dive");
expect(scrollWritesProgress()).toBe(false);
},
);
it("marks the hero for the dive (the CSS then gives it its scroll block) and follows the hero scroll with --hero-p", async () => {
const { container } = render(<Hero />);
const hero = container.querySelector(".hero") as HTMLElement;
expect(hero).toHaveAttribute("data-dive");
await idle();
// 2760 px de hero (altura = 1,7 tela de cauda + o mergulho): 800 de palco, 560 de Projetos subindo e 1400 de mergulho
let top = 0;
hero.getBoundingClientRect = () =>
({ top, height: 2760, bottom: top + 2760 }) as DOMRect;
Object.defineProperty(window, "innerHeight", {
configurable: true,
value: 800,
});
const scroll = (to: number) => {
top = -to;
act(() => {
window.dispatchEvent(new Event("scroll"));
jest.advanceTimersByTime(40);
});
};
scroll(0);
expect(mockScenes.at(-1)?.progressRef.current).toBe(0);
scroll(700); // meio do mergulho (1400 px de trilho)
expect(Number(hero.style.getPropertyValue("--hero-p"))).toBeCloseTo(0.5);
scroll(1400);
expect(Number(hero.style.getPropertyValue("--hero-p"))).toBe(1);
expect(mockScenes.at(-1)?.visible).toBe(false);
});
it("does not start the dive on a short screen (the CSS does not apply there), but the scene still runs", async () => {
mockMedia([]);
render(<Hero />);
await idle();
expect(section()).not.toHaveAttribute("data-dive");
expect(scrollWritesProgress()).toBe(false);
expect(screen.getByTestId("scene")).toBeInTheDocument();
});
it("reveals the scene (data-ready) once it has drawn its first frames, and goes back to the plain hero if it fails", async () => {
const { container } = render(<Hero />);
await idle();
expect(container.querySelector(".hero-field")).not.toHaveAttribute(
"data-ready",
);
act(() => mockScenes.at(-1)?.onReady());
expect(container.querySelector(".hero-field")).toHaveAttribute(
"data-ready",
);
act(() => mockScenes.at(-1)?.onError());
expect(container.querySelector(".hero-field")).not.toHaveAttribute(
"data-ready",
);
expect(screen.queryByTestId("scene")).toBeNull();
// de volta ao palco de 100svh: sem marca de mergulho nem variáveis de rolagem
expect(section()).not.toHaveAttribute("data-dive");
expect(section().style.getPropertyValue("--hero-p")).toBe("");
});
it("full: covers the stage and hands the scene the measured poster box (where marca rests) and the useful dive area", async () => {
const { container } = render(<Hero />);
await idle();
const scene = mockScenes.at(-1);
expect(scene?.tier).toBe("full");
expect(scene?.placementRef?.current).toEqual(PLACEMENT);
const area = scene?.stageRef?.current as typeof STAGE_AREA;
for (const key of ["x", "y", "width", "height"] as const)
expect(area[key]).toBeCloseTo(STAGE_AREA[key], 6);
const field = container.querySelector(".hero-field") as HTMLElement;
expect(field).toHaveAttribute("data-tier", "full");
expect(field.getAttribute("style")).toBeNull();
});
it("lite (a capable phone): no dive, the canvas box is the poster box, and no placement goes to the scene", async () => {
mockMedia([DIVE_QUERY, "(pointer: coarse)"]);
const { container } = render(<Hero />);
expect(section()).not.toHaveAttribute("data-dive");
await idle();
const scene = mockScenes.at(-1);
expect(scene?.tier).toBe("lite");
expect(scene?.placementRef).toBeUndefined();
expect(scene?.stageRef).toBeUndefined();
const field = container.querySelector(".hero-field") as HTMLElement;
expect(field).toHaveAttribute("data-tier", "lite");
expect(field.style).toMatchObject({
left: "760px",
top: "150px",
width: "600px",
height: "620px",
});
expect(section()).not.toHaveAttribute("data-dive");
expect(scrollWritesProgress()).toBe(false);
});
it("lite: falls back to the poster when the scene gives up", async () => {
mockMedia([DIVE_QUERY, "(pointer: coarse)"]);
const { container } = render(<Hero />);
await idle();
act(() => mockScenes.at(-1)?.onReady());
act(() => mockScenes.at(-1)?.onError());
const field = container.querySelector(".hero-field") as HTMLElement;
expect(screen.queryByTestId("scene")).toBeNull();
expect(field).not.toHaveAttribute("data-ready");
expect(field).not.toHaveAttribute("data-tier");
// de volta a cobrir o palco (inset: 0 do CSS), sem a caixa em linha
expect([field.style.left, field.style.width]).toEqual(["", ""]);
});
/** A legenda da peça, se existir. */
const caption = (container: HTMLElement) =>
container.querySelector<HTMLElement>(".hero-legenda");
const tell = (estado: number | null) =>
act(() => mockScenes.at(-1)?.onEstado?.(estado));
it("captions what the piece is building, under the box of the states and aligned to its left, and nothing on marca", async () => {
const { container } = render(<Hero />);
await idle();
act(() => mockScenes.at(-1)?.onReady());
tell(0);
expect(caption(container)).toBeNull();
for (const [estado, texto] of [
[1, "Sites e sistemas"],
[2, "Integrações"],
[3, "Painéis e dados"],
] as const) {
tell(estado);
const legenda = caption(container) as HTMLElement;
expect(legenda).toHaveTextContent(texto);
expect(legenda).toHaveAttribute("data-shown");
}
const legenda = caption(container) as HTMLElement;
// 12 px abaixo da caixa em que a cena põe os estados no topo, alinhada a eles: a caixa da peça sem os 22%
// da esquerda (760 + 132 px), na proporção 17 x 12 (468 x 330,4 px), centrada na altura da peça (150 + 620 / 2)
// (os estados têm 14,5 das 17 unidades dela, centrados: a legenda começa onde eles começam)
expect(Number.parseFloat(legenda.style.left)).toBeCloseTo(
892 + (468 * (1 - 14.5 / 17)) / 2,
3,
);
expect(Number.parseFloat(legenda.style.top)).toBeCloseTo(
460 + (468 * 12) / 17 / 2 + 12,
3,
);
// dentro do campo decorativo
expect(legenda.closest('[aria-hidden="true"]')).not.toBeNull();
// cada texto novo é digitado (.ds-type, um passo por caractere)
const typed = legenda.querySelector<HTMLElement>(".ds-type");
expect(typed?.style.getPropertyValue("--n")).toBe("15");
// de volta à marca, ou fora da caixa (o mergulho): some, com o último texto ainda no lugar enquanto apaga
tell(0);
expect(caption(container)).not.toHaveAttribute("data-shown");
tell(2);
tell(null);
expect(caption(container)).not.toHaveAttribute("data-shown");
});
it("shows no caption before the scene is ready, nor after it gives up", async () => {
const { container } = render(<Hero />);
await idle();
tell(1);
expect(caption(container)).not.toHaveAttribute("data-shown");
act(() => mockScenes.at(-1)?.onReady());
expect(caption(container)).toHaveAttribute("data-shown");
act(() => mockScenes.at(-1)?.onError());
expect(caption(container)).toBeNull();
});
it("lite: puts the caption right under the canvas box, from 1024 px up only (below, the piece shares the title's corner)", async () => {
mockMedia([DIVE_QUERY, "(pointer: coarse)"]);
const { container } = render(<Hero />);
await idle();
act(() => mockScenes.at(-1)?.onReady());
tell(1);
expect(caption(container)?.style).toMatchObject({
left: "0px",
top: "632px",
});
expect(caption(container)).toHaveClass("max-lg:hidden");
});
});