Passagens com fade e 32 px, cena anterior termina em opacidade 0, sem visibility nem clip-path. 3D do hero como fundo ambiente, sob demanda e desligado em toque, aparelho fraco, saveData e movimento reduzido. Lenis fora do toque e salto direto em âncoras distantes. Vitrine só de clientes, até 3 capítulos, fixa só no desktop e carrossel no celular; vídeo só do projeto em cena com preload metadata. Rótulos mono a partir de 12 px e limão só no contato.
581 lines
18 KiB
TypeScript
581 lines
18 KiB
TypeScript
import { act, render } from "@testing-library/react";
|
|
import * as THREE from "three";
|
|
import HeroFieldScene from "./HeroFieldScene";
|
|
|
|
/**
|
|
* Testes de caminhos-base do quadro da cena 3D (o callback do useFrame de Field, CC alta) e de SceneBoundary:
|
|
* - fim antecipado sem grupo, onReady no 2º quadro, amortecimento da rolagem (limite de dt, progresso ausente);
|
|
* - retrato x paisagem (posição do grupo e distância da câmera) e o foco da lente;
|
|
* - mouse ativo x parado x toque, raio que acerta ou não o plano, e a quantidade de pontos (denso x leve);
|
|
* - quando pedir o próximo quadro (entrada, rolagem assentando, cursor) e quando não pedir;
|
|
* - perda do contexto WebGL, limpeza e a falha de um filho (SceneBoundary).
|
|
* O @react-three/fiber é trocado por um dublê que renderiza os filhos e entrega o callback do useFrame; o `three`
|
|
* é o de verdade, com ShaderMaterial e BufferGeometry registrando as instâncias criadas.
|
|
*/
|
|
|
|
type FrameState = {
|
|
clock: { elapsedTime: number };
|
|
invalidate: () => void;
|
|
};
|
|
const mockState: {
|
|
frame: ((state: FrameState, delta: number) => void) | null;
|
|
three: unknown;
|
|
throwOnUseThree: boolean;
|
|
} = { frame: null, three: null, throwOnUseThree: false };
|
|
const mockMaterials: THREE.ShaderMaterial[] = [];
|
|
const mockGeometries: THREE.BufferGeometry[] = [];
|
|
|
|
jest.mock("three", () => {
|
|
const actual = jest.requireActual("three");
|
|
class CapturingMaterial extends actual.ShaderMaterial {
|
|
constructor(parameters: unknown) {
|
|
super(parameters);
|
|
mockMaterials.push(this as unknown as THREE.ShaderMaterial);
|
|
}
|
|
}
|
|
class CapturingGeometry extends actual.BufferGeometry {
|
|
constructor() {
|
|
super();
|
|
mockGeometries.push(this as unknown as THREE.BufferGeometry);
|
|
}
|
|
}
|
|
return {
|
|
...actual,
|
|
ShaderMaterial: CapturingMaterial,
|
|
BufferGeometry: CapturingGeometry,
|
|
};
|
|
});
|
|
jest.mock("@react-three/fiber", () => ({
|
|
Canvas: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
|
useFrame: (callback: (state: FrameState, delta: number) => void) => {
|
|
mockState.frame = callback;
|
|
},
|
|
useThree: () => {
|
|
if (mockState.throwOnUseThree) throw new Error("sem WebGL");
|
|
return mockState.three;
|
|
},
|
|
}));
|
|
jest.mock("./FramePulse", () => ({ __esModule: true, default: () => null }));
|
|
|
|
type Setup = {
|
|
width?: number;
|
|
height?: number;
|
|
/** `null` simula uma ref sem valor (o código trata como 0). */
|
|
progress?: number | null;
|
|
visible?: boolean;
|
|
};
|
|
|
|
function setup({
|
|
width = 1200,
|
|
height = 800,
|
|
progress = 0,
|
|
visible = true,
|
|
}: Setup = {}) {
|
|
const domElement = document.createElement("canvas");
|
|
const camera = new THREE.PerspectiveCamera(35, width / height, 0.05, 120);
|
|
camera.position.set(0, 0, 9);
|
|
camera.updateMatrixWorld();
|
|
mockState.three = {
|
|
gl: { domElement, getPixelRatio: () => 2 },
|
|
camera,
|
|
size: { width, height },
|
|
};
|
|
const progressRef = {
|
|
current: progress === null ? undefined : progress,
|
|
} as { current: number };
|
|
const onReady = jest.fn();
|
|
const onError = jest.fn();
|
|
const element = (isVisible: boolean) => (
|
|
<HeroFieldScene
|
|
progressRef={progressRef}
|
|
visible={isVisible}
|
|
onReady={onReady}
|
|
onError={onError}
|
|
/>
|
|
);
|
|
const view = render(element(visible));
|
|
|
|
// <group> é um elemento desconhecido no jsdom: ganha o que o três espera de um THREE.Group
|
|
const group = view.container.querySelector("group") as HTMLElement | null;
|
|
const real = new THREE.Group();
|
|
if (group)
|
|
Object.assign(group, {
|
|
rotation: real.rotation,
|
|
position: real.position,
|
|
matrixWorld: real.matrixWorld,
|
|
updateMatrixWorld: () => real.updateMatrixWorld(true),
|
|
worldToLocal: (point: THREE.Vector3) => point,
|
|
});
|
|
const uniforms = () => mockMaterials[mockMaterials.length - 1].uniforms;
|
|
|
|
function frame(time: number, delta = 1 / 60) {
|
|
const invalidate = jest.fn();
|
|
act(() => {
|
|
mockState.frame?.({ clock: { elapsedTime: time }, invalidate }, delta);
|
|
});
|
|
return invalidate;
|
|
}
|
|
|
|
/** Roda `count` quadros e devolve quantas vezes o próximo quadro foi pedido no último. */
|
|
function settle(count: number, delta = 1 / 20, startTime = 100) {
|
|
let last = jest.fn();
|
|
for (let i = 0; i < count; i++) last = frame(startTime + i * delta, delta);
|
|
return last;
|
|
}
|
|
|
|
return {
|
|
...view,
|
|
camera,
|
|
domElement,
|
|
progressRef,
|
|
onReady,
|
|
onError,
|
|
group: real,
|
|
uniforms,
|
|
frame,
|
|
settle,
|
|
rerender: (isVisible: boolean) => view.rerender(element(isVisible)),
|
|
};
|
|
}
|
|
|
|
const originalHardware = Object.getOwnPropertyDescriptor(
|
|
navigator,
|
|
"hardwareConcurrency",
|
|
);
|
|
|
|
function setCores(value: number | undefined) {
|
|
Object.defineProperty(navigator, "hardwareConcurrency", {
|
|
configurable: true,
|
|
value,
|
|
});
|
|
}
|
|
|
|
function setWindowWidth(value: number) {
|
|
Object.defineProperty(window, "innerWidth", {
|
|
configurable: true,
|
|
value,
|
|
});
|
|
}
|
|
|
|
function pointerMove(type: string, x = 600, y = 400) {
|
|
const event = new MouseEvent("pointermove", { clientX: x, clientY: y });
|
|
Object.defineProperty(event, "pointerType", { value: type });
|
|
act(() => {
|
|
window.dispatchEvent(event);
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
mockMaterials.length = 0;
|
|
mockGeometries.length = 0;
|
|
mockState.frame = null;
|
|
mockState.throwOnUseThree = false;
|
|
setCores(8);
|
|
setWindowWidth(1200);
|
|
// as tags do R3F (group, points) são elementos desconhecidos no DOM do jsdom
|
|
jest.spyOn(console, "error").mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.restoreAllMocks();
|
|
if (originalHardware)
|
|
Object.defineProperty(navigator, "hardwareConcurrency", originalHardware);
|
|
});
|
|
|
|
describe("Field: quadro (useFrame) — começo e rolagem", () => {
|
|
it("path: the group is gone (unmounted) -> the callback returns at once, nothing is requested", () => {
|
|
const scene = setup();
|
|
const callback = mockState.frame;
|
|
scene.unmount();
|
|
const invalidate = jest.fn();
|
|
|
|
callback?.({ clock: { elapsedTime: 1 }, invalidate }, 1 / 60);
|
|
|
|
expect(invalidate).not.toHaveBeenCalled();
|
|
expect(scene.onReady).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("path: onReady fires on the second frame, once", () => {
|
|
const scene = setup();
|
|
|
|
scene.frame(0);
|
|
expect(scene.onReady).not.toHaveBeenCalled();
|
|
scene.frame(0.02);
|
|
expect(scene.onReady).toHaveBeenCalledTimes(1);
|
|
scene.frame(0.04);
|
|
scene.frame(0.06);
|
|
|
|
expect(scene.onReady).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("path: the scroll progress is damped toward the target (and a long frame cannot overshoot)", () => {
|
|
const scene = setup({ progress: 1 });
|
|
|
|
scene.frame(0, 5); // delta enorme: o passo é limitado a 1/20 de segundo
|
|
|
|
const expected = 1 - Math.exp(-(1 / 20) * 5);
|
|
expect(scene.uniforms().uScroll.value).toBeCloseTo(expected, 9);
|
|
expect(scene.uniforms().uScroll.value).toBeLessThan(1);
|
|
});
|
|
|
|
it("path: no progress available (undefined) -> treated as the top of the page", () => {
|
|
const scene = setup({ progress: null });
|
|
|
|
scene.settle(5);
|
|
|
|
expect(scene.uniforms().uScroll.value).toBe(0);
|
|
});
|
|
|
|
it("path: the intro advances every frame and stops at 1", () => {
|
|
const scene = setup();
|
|
|
|
scene.frame(0, 1 / 20);
|
|
expect(scene.uniforms().uIntro.value).toBeCloseTo(0.05 / 2.6, 9);
|
|
|
|
scene.settle(80);
|
|
|
|
expect(scene.uniforms().uIntro.value).toBe(1);
|
|
});
|
|
|
|
it("path: the scene fades out only at the very end of the dive", () => {
|
|
const scene = setup({ progress: 0 });
|
|
scene.settle(10);
|
|
expect(scene.uniforms().uFade.value).toBe(1);
|
|
|
|
scene.progressRef.current = 1;
|
|
scene.settle(120);
|
|
|
|
expect(scene.uniforms().uFade.value).toBeCloseTo(0, 6);
|
|
});
|
|
|
|
it("path: layers spread apart only while they face the viewer", () => {
|
|
const scene = setup({ progress: 0 });
|
|
scene.settle(5);
|
|
expect(scene.uniforms().uSpread.value).toBeCloseTo(1.05, 6);
|
|
|
|
scene.progressRef.current = 0.5;
|
|
scene.settle(150);
|
|
|
|
expect(scene.uniforms().uSpread.value).toBeGreaterThan(5);
|
|
});
|
|
});
|
|
|
|
describe("Field: enquadramento e foco", () => {
|
|
it("path: landscape window -> the group sits just below center and the camera rests at 9", () => {
|
|
const scene = setup({ width: 1200, height: 800 });
|
|
|
|
scene.frame(0);
|
|
|
|
expect(scene.group.position.y).toBeCloseTo(-0.5, 9);
|
|
expect(scene.camera.position.z).toBeCloseTo(9, 9);
|
|
});
|
|
|
|
it("path: portrait window -> the group sits lower and the camera backs off to keep the layer in frame", () => {
|
|
const scene = setup({ width: 400, height: 800 });
|
|
|
|
scene.frame(0);
|
|
|
|
expect(scene.group.position.y).toBeLessThan(-0.5);
|
|
expect(scene.camera.position.z).toBeGreaterThan(9);
|
|
});
|
|
|
|
it("path: end of the dive -> the group has settled back to the center and the camera went through the layers", () => {
|
|
const scene = setup({ progress: 1 });
|
|
|
|
scene.settle(200);
|
|
|
|
expect(scene.group.position.y).toBeCloseTo(0, 6);
|
|
expect(scene.camera.position.z).toBeLessThan(0);
|
|
});
|
|
|
|
it("path: camera in front of the layers -> the lens focuses on the next layer ahead", () => {
|
|
const scene = setup({ progress: 0 });
|
|
|
|
scene.frame(0);
|
|
|
|
// câmera em 9 e primeira camada em 1,05: foco a 9 - 1,05
|
|
expect(scene.uniforms().uFocus.value).toBeCloseTo(9 - 1.05, 6);
|
|
});
|
|
|
|
it("path: camera past every layer -> the focus never goes below 1.5", () => {
|
|
const scene = setup({ progress: 1 });
|
|
|
|
scene.settle(200);
|
|
|
|
expect(scene.uniforms().uFocus.value).toBe(1.5);
|
|
});
|
|
});
|
|
|
|
describe("Field: mouse", () => {
|
|
it("path: pointer type other than mouse (touch, pen) -> ignored, the cursor stays on its own path", () => {
|
|
const scene = setup();
|
|
jest.spyOn(performance, "now").mockReturnValue(5000);
|
|
pointerMove("touch");
|
|
pointerMove("pen");
|
|
|
|
scene.frame(5.5);
|
|
|
|
// sem mouse, o cursor fantasma passa a valer 0,4 (não 0,8)
|
|
scene.settle(120, 1 / 20, 5.5);
|
|
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.4, 2);
|
|
});
|
|
|
|
it("path: a mouse moved a moment ago -> the pointer drives the cursor strength to 0.8", () => {
|
|
const scene = setup();
|
|
jest.spyOn(performance, "now").mockReturnValue(5000);
|
|
pointerMove("mouse");
|
|
|
|
for (let i = 0; i < 120; i++) scene.frame(5.1 + i * 0.01, 1 / 20);
|
|
|
|
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.8, 2);
|
|
});
|
|
|
|
it("path: the mouse stopped long ago (over 2.5 s) -> back to the ghost cursor", () => {
|
|
const scene = setup();
|
|
jest.spyOn(performance, "now").mockReturnValue(5000);
|
|
pointerMove("mouse");
|
|
|
|
for (let i = 0; i < 120; i++) scene.frame(9 + i * 0.01, 1 / 20);
|
|
|
|
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.4, 2);
|
|
});
|
|
|
|
it("path: a mouse event at timestamp 0 is not counted as activity", () => {
|
|
const scene = setup();
|
|
jest.spyOn(performance, "now").mockReturnValue(0);
|
|
pointerMove("mouse");
|
|
|
|
for (let i = 0; i < 120; i++) scene.frame(0.1 + i * 0.001, 1 / 20);
|
|
|
|
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.4, 2);
|
|
});
|
|
|
|
it("path: cursor strength fades to 0 once the dive is under way", () => {
|
|
const scene = setup({ progress: 1 });
|
|
jest.spyOn(performance, "now").mockReturnValue(5000);
|
|
pointerMove("mouse");
|
|
|
|
for (let i = 0; i < 200; i++) scene.frame(5.1 + i * 0.005, 1 / 20);
|
|
|
|
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0, 2);
|
|
});
|
|
|
|
it("path: active mouse and the ray hits the layer plane -> the shader cursor follows the hit point", () => {
|
|
const scene = setup();
|
|
jest
|
|
.spyOn(THREE.Ray.prototype, "intersectPlane")
|
|
.mockImplementation(((_plane: THREE.Plane, target: THREE.Vector3) =>
|
|
target.set(1, 0, 2)) as never);
|
|
jest.spyOn(performance, "now").mockReturnValue(5000);
|
|
pointerMove("mouse");
|
|
|
|
scene.frame(5.1, 1 / 20);
|
|
|
|
const factor = 1 - Math.exp(-(1 / 20) * 10);
|
|
expect(scene.uniforms().uMouse.value.x).toBeCloseTo(factor * 1, 6);
|
|
expect(scene.uniforms().uMouse.value.y).toBeCloseTo(factor * 2, 6);
|
|
});
|
|
|
|
it("path: active mouse but the ray misses the plane -> the shader cursor stays where it was", () => {
|
|
const scene = setup();
|
|
jest
|
|
.spyOn(THREE.Ray.prototype, "intersectPlane")
|
|
.mockReturnValue(null as never);
|
|
jest.spyOn(performance, "now").mockReturnValue(5000);
|
|
pointerMove("mouse");
|
|
|
|
scene.frame(5.1, 1 / 20);
|
|
|
|
expect(scene.uniforms().uMouse.value.x).toBe(0);
|
|
expect(scene.uniforms().uMouse.value.y).toBe(0);
|
|
});
|
|
|
|
it("path: idle mouse -> the ghost cursor wanders on its own", () => {
|
|
const scene = setup();
|
|
|
|
scene.frame(10, 1 / 20);
|
|
|
|
const { x, y } = scene.uniforms().uMouse.value;
|
|
expect(Math.hypot(x, y)).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe("Field: quando pedir o próximo quadro", () => {
|
|
it("path: the intro is still running -> a frame is requested even when the hero is not visible", () => {
|
|
const scene = setup({ visible: false });
|
|
|
|
const invalidate = scene.frame(0, 1 / 60);
|
|
|
|
expect(invalidate).toHaveBeenCalled();
|
|
});
|
|
|
|
it("path: everything settled and the hero is visible but nothing interacts -> no extra frame (the pulse does it)", () => {
|
|
const scene = setup({ visible: true });
|
|
scene.settle(200);
|
|
|
|
const invalidate = scene.frame(500, 1 / 60);
|
|
|
|
expect(invalidate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("path: everything settled and the hero is not visible -> no frame", () => {
|
|
const scene = setup({ visible: false });
|
|
scene.settle(200);
|
|
|
|
const invalidate = scene.frame(500, 1 / 60);
|
|
|
|
expect(invalidate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("path: settled, visible, and the mouse is moving -> frames are requested", () => {
|
|
const scene = setup({ visible: true });
|
|
scene.settle(200);
|
|
jest.spyOn(performance, "now").mockReturnValue(600_000);
|
|
pointerMove("mouse");
|
|
|
|
const invalidate = scene.frame(600.1, 1 / 60);
|
|
|
|
expect(invalidate).toHaveBeenCalled();
|
|
});
|
|
|
|
it("path: settled, mouse active, but the hero is not visible -> no frame (cursor only counts on screen)", () => {
|
|
const scene = setup({ visible: false });
|
|
scene.settle(200);
|
|
jest.spyOn(performance, "now").mockReturnValue(600_000);
|
|
pointerMove("mouse");
|
|
|
|
const invalidate = scene.frame(600.1, 1 / 60);
|
|
|
|
expect(invalidate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("path: the scroll target moved after the hero left the screen -> still finishes settling", () => {
|
|
const scene = setup({ visible: false });
|
|
scene.settle(200);
|
|
scene.progressRef.current = 0.5;
|
|
|
|
const invalidate = scene.frame(500, 1 / 60);
|
|
|
|
expect(invalidate).toHaveBeenCalled();
|
|
});
|
|
|
|
it("path: becoming visible later -> the new callback sees the new flag", () => {
|
|
const scene = setup({ visible: false });
|
|
scene.settle(200);
|
|
jest.spyOn(performance, "now").mockReturnValue(600_000);
|
|
pointerMove("mouse");
|
|
expect(scene.frame(600.1, 1 / 60)).not.toHaveBeenCalled();
|
|
|
|
scene.rerender(true);
|
|
|
|
expect(scene.frame(600.2, 1 / 60)).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("Field: densidade, contexto WebGL e limpeza", () => {
|
|
const pointCount = () => {
|
|
const geometry = mockGeometries[mockGeometries.length - 1];
|
|
return geometry.getAttribute("position").count;
|
|
};
|
|
|
|
it("path: wide window and 4+ cores -> the dense field", () => {
|
|
setWindowWidth(1200);
|
|
setCores(8);
|
|
setup();
|
|
const dense = pointCount();
|
|
|
|
mockGeometries.length = 0;
|
|
setWindowWidth(600);
|
|
setup();
|
|
|
|
expect(dense).toBeGreaterThan(pointCount());
|
|
});
|
|
|
|
it("path: wide window but 2 cores -> the light field", () => {
|
|
setWindowWidth(1200);
|
|
setCores(2);
|
|
setup();
|
|
const light = pointCount();
|
|
|
|
mockGeometries.length = 0;
|
|
setCores(8);
|
|
setup();
|
|
|
|
expect(pointCount()).toBeGreaterThan(light);
|
|
});
|
|
|
|
it("path: the browser does not report cores -> counts as capable (4): dense, not light", () => {
|
|
setWindowWidth(1200);
|
|
const counts: Record<string, number> = {};
|
|
for (const [label, cores] of [
|
|
["dense", 8],
|
|
["light", 2],
|
|
["unknown", undefined],
|
|
] as const) {
|
|
mockGeometries.length = 0;
|
|
setCores(cores);
|
|
setup();
|
|
counts[label] = pointCount();
|
|
}
|
|
|
|
expect(Math.abs(counts.unknown - counts.dense)).toBeLessThan(
|
|
Math.abs(counts.unknown - counts.light),
|
|
);
|
|
});
|
|
|
|
it("path: the WebGL context is lost -> the default recovery is cancelled and the page is told", () => {
|
|
const scene = setup();
|
|
const event = new Event("webglcontextlost", { cancelable: true });
|
|
|
|
act(() => {
|
|
scene.domElement.dispatchEvent(event);
|
|
});
|
|
|
|
expect(event.defaultPrevented).toBe(true);
|
|
expect(scene.onError).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("path: unmount -> geometry and material are disposed and the listeners go away", () => {
|
|
const scene = setup();
|
|
const geometry = mockGeometries[mockGeometries.length - 1];
|
|
const material = mockMaterials[mockMaterials.length - 1];
|
|
const disposeGeometry = jest.spyOn(geometry, "dispose");
|
|
const disposeMaterial = jest.spyOn(material, "dispose");
|
|
const event = new Event("webglcontextlost", { cancelable: true });
|
|
|
|
scene.unmount();
|
|
scene.domElement.dispatchEvent(event);
|
|
|
|
expect(disposeGeometry).toHaveBeenCalledTimes(1);
|
|
expect(disposeMaterial).toHaveBeenCalledTimes(1);
|
|
expect(event.defaultPrevented).toBe(false);
|
|
expect(scene.onError).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("SceneBoundary", () => {
|
|
it("path: a child fails while drawing -> nothing is rendered and the page is told once", () => {
|
|
mockState.throwOnUseThree = true;
|
|
const onError = jest.fn();
|
|
|
|
const { container } = render(
|
|
<HeroFieldScene
|
|
progressRef={{ current: 0 }}
|
|
visible
|
|
onReady={() => {}}
|
|
onError={onError}
|
|
/>,
|
|
);
|
|
|
|
expect(container).toBeEmptyDOMElement();
|
|
expect(onError).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("path: no failure -> the scene is rendered and onError is not called", () => {
|
|
const scene = setup();
|
|
|
|
expect(scene.container.querySelector("group")).not.toBeNull();
|
|
expect(scene.onError).not.toHaveBeenCalled();
|
|
});
|
|
});
|