perf(movimento): sem tempo morto, vídeos regravados e 3D sem React Three Fiber
Legenda do mergulho até Projetos cobrir a cena; transição de página sem blur e com fundo escuro; foco no conteúdo ao trocar de página; listas com marcador e dois pesos de título; vídeos da SEV e da SC Plus regravados sem quadros brancos; scrub a 12-15 fps (-30%); vitrine com uma parada de Tab e setas, contador no celular e CSS sob demanda; âncoras num módulo só (fragment-nav); FramePulse com setInterval; CSS morto removido.
This commit is contained in:
1 parent
f8545cd442
commit
6ac87824f3
68 files changed
+5735
-3300
No files matched your search
@@ -1,43 +1,83 @@
|
||||
import { act, render } from "@testing-library/react";
|
||||
import * as THREE from "three";
|
||||
import HeroFieldScene, { prepareField } from "./HeroFieldScene";
|
||||
import {
|
||||
type BufferGeometry,
|
||||
type Group,
|
||||
type PerspectiveCamera,
|
||||
type Plane,
|
||||
Ray,
|
||||
type ShaderMaterial,
|
||||
type Vector3,
|
||||
} from "three";
|
||||
import {
|
||||
createField,
|
||||
type FieldEngine,
|
||||
needsFrame,
|
||||
prepareField,
|
||||
type Rig,
|
||||
type Tick,
|
||||
updatePointer,
|
||||
updateRig,
|
||||
} 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);
|
||||
* Testes de caminhos-base do quadro da cena 3D (createField, sem React) e das contas que o compõem:
|
||||
* - onReady no 2º quadro, amortecimento da rolagem (limite de dt, progresso ausente), entrada, escurecimento e abertura;
|
||||
* - 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.
|
||||
* - perda do contexto WebGL, falha ao desenhar e limpeza.
|
||||
* O WebGLRenderer é um dublê (o jsdom não tem WebGL); o `three` é o de verdade, com ShaderMaterial, BufferGeometry,
|
||||
* PerspectiveCamera e Group registrando as instâncias criadas. O quadro é chamado direto por `renderAt`, com o relógio
|
||||
* que o teste escolhe.
|
||||
*/
|
||||
|
||||
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 mockMaterials: ShaderMaterial[] = [];
|
||||
const mockGeometries: BufferGeometry[] = [];
|
||||
const mockCameras: PerspectiveCamera[] = [];
|
||||
const mockGroups: Group[] = [];
|
||||
const mockCounts = { vectors: 0 };
|
||||
const mockGeometries: THREE.BufferGeometry[] = [];
|
||||
const mockRenderers: MockRenderer[] = [];
|
||||
let mockRenderThrows = false;
|
||||
|
||||
class MockRenderer {
|
||||
domElement = document.createElement("canvas");
|
||||
pixelRatio = 2;
|
||||
render = jest.fn(() => {
|
||||
if (mockRenderThrows) throw new Error("falha ao desenhar");
|
||||
});
|
||||
dispose = jest.fn();
|
||||
forceContextLoss = jest.fn();
|
||||
setSize = jest.fn();
|
||||
setPixelRatio = jest.fn();
|
||||
getPixelRatio = () => this.pixelRatio;
|
||||
constructor() {
|
||||
mockRenderers.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
mockMaterials.push(this as unknown as ShaderMaterial);
|
||||
}
|
||||
}
|
||||
class CapturingGeometry extends actual.BufferGeometry {
|
||||
constructor() {
|
||||
super();
|
||||
mockGeometries.push(this as unknown as THREE.BufferGeometry);
|
||||
mockGeometries.push(this as unknown as BufferGeometry);
|
||||
}
|
||||
}
|
||||
class CapturingCamera extends actual.PerspectiveCamera {
|
||||
constructor(...args: unknown[]) {
|
||||
super(...args);
|
||||
mockCameras.push(this as unknown as PerspectiveCamera);
|
||||
}
|
||||
}
|
||||
class CapturingGroup extends actual.Group {
|
||||
constructor() {
|
||||
super();
|
||||
mockGroups.push(this as unknown as Group);
|
||||
}
|
||||
}
|
||||
class CountingVector3 extends actual.Vector3 {
|
||||
@@ -48,22 +88,14 @@ jest.mock("three", () => {
|
||||
}
|
||||
return {
|
||||
...actual,
|
||||
WebGLRenderer: jest.fn(() => new MockRenderer()),
|
||||
ShaderMaterial: CapturingMaterial,
|
||||
BufferGeometry: CapturingGeometry,
|
||||
PerspectiveCamera: CapturingCamera,
|
||||
Group: CapturingGroup,
|
||||
Vector3: CountingVector3,
|
||||
};
|
||||
});
|
||||
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;
|
||||
@@ -73,76 +105,66 @@ type Setup = {
|
||||
visible?: boolean;
|
||||
};
|
||||
|
||||
let engines: FieldEngine[] = [];
|
||||
let frameRequests: jest.SpyInstance;
|
||||
|
||||
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 host = document.createElement("div");
|
||||
Object.defineProperty(host, "clientWidth", { value: width });
|
||||
Object.defineProperty(host, "clientHeight", { value: height });
|
||||
document.body.append(host);
|
||||
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));
|
||||
const engine = createField(host, { progressRef, onReady, onError });
|
||||
engines.push(engine);
|
||||
engine.setVisible(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 renderer = mockRenderers[mockRenderers.length - 1];
|
||||
const camera = mockCameras[mockCameras.length - 1];
|
||||
const group = mockGroups[mockGroups.length - 1];
|
||||
const uniforms = () => mockMaterials[mockMaterials.length - 1].uniforms;
|
||||
let clock = 1000;
|
||||
|
||||
function frame(time: number, delta = 1 / 60) {
|
||||
const invalidate = jest.fn();
|
||||
act(() => {
|
||||
mockState.frame?.({ clock: { elapsedTime: time }, invalidate }, delta);
|
||||
});
|
||||
return invalidate;
|
||||
/**
|
||||
* Um quadro `deltaMs` depois do anterior; devolve quantos quadros ele pediu em seguida. O primeiro quadro de uma
|
||||
* cena não tem anterior: o passo dele é 1/60 de segundo, seja qual for `deltaMs`.
|
||||
*/
|
||||
function frame(deltaMs = 1000 / 60) {
|
||||
frameRequests.mockClear();
|
||||
clock += deltaMs;
|
||||
engine.renderAt(clock);
|
||||
return frameRequests.mock.calls.length;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
function settle(count: number, deltaMs = 50) {
|
||||
let asked = 0;
|
||||
for (let i = 0; i < count; i++) asked = frame(deltaMs);
|
||||
return asked;
|
||||
}
|
||||
|
||||
return {
|
||||
...view,
|
||||
host,
|
||||
engine,
|
||||
renderer,
|
||||
camera,
|
||||
domElement,
|
||||
group,
|
||||
progressRef,
|
||||
onReady,
|
||||
onError,
|
||||
group: real,
|
||||
uniforms,
|
||||
frame,
|
||||
settle,
|
||||
rerender: (isVisible: boolean) => view.rerender(element(isVisible)),
|
||||
/** O relógio do quadro (ms), para os testes que pedem o tempo em segundos. */
|
||||
now: () => clock,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -158,61 +180,59 @@ function setCores(value: number | undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
function setWindowWidth(value: number) {
|
||||
function setWindowSize(width: number, height = 800) {
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
configurable: true,
|
||||
value,
|
||||
value: width,
|
||||
});
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
value: height,
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockCounts.vectors = 0;
|
||||
mockMaterials.length = 0;
|
||||
mockGeometries.length = 0;
|
||||
mockState.frame = null;
|
||||
mockState.throwOnUseThree = false;
|
||||
mockCameras.length = 0;
|
||||
mockGroups.length = 0;
|
||||
mockRenderers.length = 0;
|
||||
mockRenderThrows = false;
|
||||
engines = [];
|
||||
setCores(8);
|
||||
setWindowWidth(1200);
|
||||
// as tags do R3F (group, points) são elementos desconhecidos no DOM do jsdom
|
||||
jest.spyOn(console, "error").mockImplementation(() => {});
|
||||
setWindowSize(1200);
|
||||
// o relógio do cursor: performance.now() em segundos é o que `lastMove` guarda
|
||||
jest.spyOn(performance, "now").mockReturnValue(1000);
|
||||
frameRequests = jest
|
||||
.spyOn(window, "requestAnimationFrame")
|
||||
.mockImplementation(() => 1 as unknown as number);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const engine of engines) engine.dispose();
|
||||
jest.restoreAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
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();
|
||||
});
|
||||
|
||||
describe("createField: quadro — começo e rolagem", () => {
|
||||
it("path: onReady fires on the second frame, once", () => {
|
||||
const scene = setup();
|
||||
|
||||
scene.frame(0);
|
||||
scene.frame();
|
||||
expect(scene.onReady).not.toHaveBeenCalled();
|
||||
scene.frame(0.02);
|
||||
scene.frame();
|
||||
expect(scene.onReady).toHaveBeenCalledTimes(1);
|
||||
scene.frame(0.04);
|
||||
scene.frame(0.06);
|
||||
scene.frame();
|
||||
scene.frame();
|
||||
|
||||
expect(scene.onReady).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -220,9 +240,11 @@ describe("Field: quadro (useFrame) — começo e rolagem", () => {
|
||||
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
|
||||
scene.frame(); // o primeiro quadro: passo de 1/60 de segundo
|
||||
const afterFirst = 1 - Math.exp(-(1 / 60) * 5);
|
||||
scene.frame(5000); // delta enorme: o passo é limitado a 1/20 de segundo
|
||||
|
||||
const expected = 1 - Math.exp(-(1 / 20) * 5);
|
||||
const expected = 1 - (1 - afterFirst) * Math.exp(-(1 / 20) * 5);
|
||||
expect(scene.uniforms().uScroll.value).toBeCloseTo(expected, 9);
|
||||
expect(scene.uniforms().uScroll.value).toBeLessThan(1);
|
||||
});
|
||||
@@ -238,8 +260,10 @@ describe("Field: quadro (useFrame) — começo e rolagem", () => {
|
||||
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.frame(50); // o primeiro quadro: 1/60 de segundo
|
||||
expect(scene.uniforms().uIntro.value).toBeCloseTo(1 / 60 / 2.6, 9);
|
||||
scene.frame(50);
|
||||
expect(scene.uniforms().uIntro.value).toBeCloseTo((1 / 60 + 0.05) / 2.6, 9);
|
||||
|
||||
scene.settle(80);
|
||||
|
||||
@@ -271,13 +295,20 @@ describe("Field: quadro (useFrame) — começo e rolagem", () => {
|
||||
|
||||
expect(scene.uniforms().uSpread.value).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
it("path: the clock reaches the shader in seconds since the scene started", () => {
|
||||
const scene = setup();
|
||||
scene.frame(0);
|
||||
scene.frame(2000);
|
||||
expect(scene.uniforms().uTime.value).toBeCloseTo(2, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Field: enquadramento e foco", () => {
|
||||
describe("createField: 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);
|
||||
scene.frame();
|
||||
|
||||
expect(scene.group.position.y).toBeCloseTo(-0.5, 9);
|
||||
expect(scene.camera.position.z).toBeCloseTo(9, 9);
|
||||
@@ -286,12 +317,26 @@ describe("Field: enquadramento e foco", () => {
|
||||
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);
|
||||
scene.frame();
|
||||
|
||||
expect(scene.group.position.y).toBeLessThan(-0.5);
|
||||
expect(scene.camera.position.z).toBeGreaterThan(9);
|
||||
});
|
||||
|
||||
it("path: the renderer and the camera follow the size of the host", () => {
|
||||
const scene = setup({ width: 900, height: 300 });
|
||||
|
||||
expect(scene.renderer.setSize).toHaveBeenCalledWith(900, 300, false);
|
||||
expect(scene.camera.aspect).toBeCloseTo(3, 9);
|
||||
});
|
||||
|
||||
it("path: a host without size yet (0 x 0) -> never a zero-size canvas or a NaN aspect", () => {
|
||||
const scene = setup({ width: 0, height: 0 });
|
||||
|
||||
expect(scene.renderer.setSize).toHaveBeenCalledWith(1, 1, false);
|
||||
expect(Number.isFinite(scene.camera.aspect)).toBe(true);
|
||||
});
|
||||
|
||||
it("path: end of the dive -> the group has settled back to the center and the camera stopped in front of the data layer", () => {
|
||||
const scene = setup({ progress: 1 });
|
||||
|
||||
@@ -308,7 +353,7 @@ describe("Field: enquadramento e foco", () => {
|
||||
it("path: camera in front of the layers -> the lens focuses on the next layer ahead", () => {
|
||||
const scene = setup({ progress: 0 });
|
||||
|
||||
scene.frame(0);
|
||||
scene.frame();
|
||||
|
||||
// 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);
|
||||
@@ -341,56 +386,56 @@ describe("Field: enquadramento e foco", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Field: mouse", () => {
|
||||
describe("createField: 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);
|
||||
scene.settle(120);
|
||||
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);
|
||||
scene.frame(); // o relógio da cena começa
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
pointerMove("mouse");
|
||||
|
||||
for (let i = 0; i < 120; i++) scene.frame(5.1 + i * 0.01, 1 / 20);
|
||||
for (let i = 0; i < 40; i++) scene.frame(50); // 2 s: o mouse ainda conta
|
||||
|
||||
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);
|
||||
scene.frame(0);
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
pointerMove("mouse");
|
||||
|
||||
for (let i = 0; i < 120; i++) scene.frame(9 + i * 0.01, 1 / 20);
|
||||
for (let i = 0; i < 120; i++) scene.frame(50); // 6 s depois
|
||||
|
||||
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);
|
||||
(performance.now as jest.Mock).mockReturnValue(0);
|
||||
pointerMove("mouse");
|
||||
|
||||
for (let i = 0; i < 120; i++) scene.frame(0.1 + i * 0.001, 1 / 20);
|
||||
for (let i = 0; i < 30; i++) scene.frame(50);
|
||||
|
||||
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);
|
||||
scene.frame();
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
pointerMove("mouse");
|
||||
|
||||
for (let i = 0; i < 200; i++) scene.frame(5.1 + i * 0.005, 1 / 20);
|
||||
for (let i = 0; i < 200; i++) scene.frame(5);
|
||||
|
||||
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0, 2);
|
||||
});
|
||||
@@ -398,90 +443,91 @@ describe("Field: mouse", () => {
|
||||
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) =>
|
||||
.spyOn(Ray.prototype, "intersectPlane")
|
||||
.mockImplementation(((_plane: Plane, target: Vector3) =>
|
||||
target.set(1, 0, 2)) as never);
|
||||
jest.spyOn(performance, "now").mockReturnValue(5000);
|
||||
// o grupo real está girado e abaixado: aqui o ponto do raio já vale como ponto do grupo
|
||||
scene.group.worldToLocal = ((point: Vector3) => point) as never;
|
||||
scene.frame(16); // um mouse no instante 0 da cena não conta como atividade
|
||||
const before = scene.uniforms().uMouse.value.clone();
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
pointerMove("mouse");
|
||||
|
||||
scene.frame(5.1, 1 / 20);
|
||||
scene.frame(50);
|
||||
|
||||
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);
|
||||
expect(scene.uniforms().uMouse.value.x).toBeCloseTo(
|
||||
before.x + (1 - before.x) * factor,
|
||||
6,
|
||||
);
|
||||
expect(scene.uniforms().uMouse.value.y).toBeCloseTo(
|
||||
before.y + (2 - before.y) * factor,
|
||||
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);
|
||||
jest.spyOn(Ray.prototype, "intersectPlane").mockReturnValue(null as never);
|
||||
scene.frame(16); // um mouse no instante 0 da cena não conta como atividade
|
||||
const before = scene.uniforms().uMouse.value.clone();
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
pointerMove("mouse");
|
||||
|
||||
scene.frame(5.1, 1 / 20);
|
||||
scene.frame(50);
|
||||
|
||||
expect(scene.uniforms().uMouse.value.x).toBe(0);
|
||||
expect(scene.uniforms().uMouse.value.y).toBe(0);
|
||||
expect(scene.uniforms().uMouse.value.x).toBe(before.x);
|
||||
expect(scene.uniforms().uMouse.value.y).toBe(before.y);
|
||||
});
|
||||
|
||||
it("path: idle mouse -> the ghost cursor wanders on its own", () => {
|
||||
const scene = setup();
|
||||
|
||||
scene.frame(10, 1 / 20);
|
||||
scene.frame(0);
|
||||
scene.frame(9000);
|
||||
|
||||
const { x, y } = scene.uniforms().uMouse.value;
|
||||
expect(Math.hypot(x, y)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Field: quando pedir o próximo quadro", () => {
|
||||
describe("createField: 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();
|
||||
expect(scene.frame()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
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();
|
||||
expect(scene.frame()).toBe(0);
|
||||
});
|
||||
|
||||
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();
|
||||
expect(scene.frame()).toBe(0);
|
||||
});
|
||||
|
||||
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);
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
pointerMove("mouse");
|
||||
|
||||
const invalidate = scene.frame(600.1, 1 / 60);
|
||||
|
||||
expect(invalidate).toHaveBeenCalled();
|
||||
expect(scene.frame()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
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);
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
pointerMove("mouse");
|
||||
|
||||
const invalidate = scene.frame(600.1, 1 / 60);
|
||||
|
||||
expect(invalidate).not.toHaveBeenCalled();
|
||||
expect(scene.frame()).toBe(0);
|
||||
});
|
||||
|
||||
it("path: the scroll target moved after the hero left the screen -> still finishes settling", () => {
|
||||
@@ -489,25 +535,160 @@ describe("Field: quando pedir o próximo quadro", () => {
|
||||
scene.settle(200);
|
||||
scene.progressRef.current = 0.5;
|
||||
|
||||
const invalidate = scene.frame(500, 1 / 60);
|
||||
|
||||
expect(invalidate).toHaveBeenCalled();
|
||||
expect(scene.frame()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("path: becoming visible later -> the new callback sees the new flag", () => {
|
||||
it("path: becoming visible later -> the next frame sees the new flag", () => {
|
||||
const scene = setup({ visible: false });
|
||||
scene.settle(200);
|
||||
jest.spyOn(performance, "now").mockReturnValue(600_000);
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
pointerMove("mouse");
|
||||
expect(scene.frame(600.1, 1 / 60)).not.toHaveBeenCalled();
|
||||
expect(scene.frame()).toBe(0);
|
||||
|
||||
scene.rerender(true);
|
||||
scene.engine.setVisible(true);
|
||||
|
||||
expect(scene.frame(600.2, 1 / 60)).toHaveBeenCalled();
|
||||
expect(scene.frame()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("path: several requests before a frame become ONE animation frame", () => {
|
||||
const scene = setup({ visible: true });
|
||||
scene.settle(200);
|
||||
frameRequests.mockClear();
|
||||
// ligar a batida pede um quadro; religá-la sem que ele tenha vindo não pede outro
|
||||
scene.engine.setVisible(false);
|
||||
scene.engine.setVisible(true);
|
||||
scene.engine.setVisible(false);
|
||||
scene.engine.setVisible(true);
|
||||
expect(frameRequests.mock.calls.length).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Field: geometria pré-calculada e quadros sem alocação", () => {
|
||||
describe("needsFrame / updatePointer / updateRig (contas puras)", () => {
|
||||
function rig(): Rig {
|
||||
const scene = setup();
|
||||
scene.frame();
|
||||
const material = mockMaterials[mockMaterials.length - 1];
|
||||
return {
|
||||
group: scene.group,
|
||||
camera: scene.camera,
|
||||
uniforms: material.uniforms,
|
||||
layout: {
|
||||
wide: true,
|
||||
tanHalf: Math.tan((35 / 2) * (Math.PI / 180)),
|
||||
spreadMax: 5.5,
|
||||
rest: 9,
|
||||
scale: 1000,
|
||||
camera: [
|
||||
[0, 9],
|
||||
[1, -1],
|
||||
],
|
||||
} as unknown as Rig["layout"],
|
||||
state: {
|
||||
progress: 0,
|
||||
pointer: { set() {}, x: 0, y: 0 } as never,
|
||||
lastMove: -10,
|
||||
mouse: { lerp() {}, x: 0, y: 0, copy() {} } as never,
|
||||
tilt: { lerp() {}, x: 0, y: 0 } as never,
|
||||
frames: 10,
|
||||
},
|
||||
helpers: {} as Rig["helpers"],
|
||||
};
|
||||
}
|
||||
|
||||
const tick = (overrides: Partial<Tick> = {}): Tick => ({
|
||||
p: 0,
|
||||
face: 0,
|
||||
time: 1,
|
||||
dt: 1 / 60,
|
||||
cursorActive: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("needsFrame: settling wins over everything, interaction only counts on screen", () => {
|
||||
const r = rig();
|
||||
r.uniforms.uIntro.value = 1;
|
||||
r.uniforms.uMouseAmt.value = 0.4;
|
||||
expect(needsFrame(r.uniforms, r.state, 0, 0.4, false, true)).toBe(false);
|
||||
expect(needsFrame(r.uniforms, r.state, 0, 0.4, true, true)).toBe(true);
|
||||
expect(needsFrame(r.uniforms, r.state, 0, 0.4, true, false)).toBe(false);
|
||||
expect(needsFrame(r.uniforms, r.state, 0.5, 0.4, false, false)).toBe(true);
|
||||
r.uniforms.uIntro.value = 0.5;
|
||||
expect(needsFrame(r.uniforms, r.state, 0, 0.4, false, false)).toBe(true);
|
||||
});
|
||||
|
||||
it("needsFrame: the first frames always ask for another (the cursor strength still has to settle)", () => {
|
||||
const r = rig();
|
||||
r.uniforms.uIntro.value = 1;
|
||||
r.state.frames = 1;
|
||||
expect(needsFrame(r.uniforms, r.state, 0, 0.4, false, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("updatePointer: returns the strength it is heading for (0.8 with the mouse, 0.4 without, none in the dive)", () => {
|
||||
const r = rig();
|
||||
r.helpers = {
|
||||
target: { set: () => ({}), x: 0, y: 0 },
|
||||
ray: { setFromCamera() {}, ray: { intersectPlane: () => null } },
|
||||
plane: { set: () => ({ applyMatrix4() {} }) },
|
||||
up: {},
|
||||
hit: {},
|
||||
} as never;
|
||||
expect(updatePointer(r, tick({ cursorActive: true }))).toBeCloseTo(0.8, 9);
|
||||
expect(updatePointer(r, tick({ cursorActive: false }))).toBeCloseTo(0.4, 9);
|
||||
expect(updatePointer(r, tick({ p: 1 }))).toBe(0);
|
||||
});
|
||||
|
||||
it("updateRig: the rotation goes from three quarters (resting) to facing the viewer (diving)", () => {
|
||||
const r = rig();
|
||||
updateRig(r, tick({ face: 0 }));
|
||||
const resting = r.group.rotation.x;
|
||||
updateRig(r, tick({ face: 1 }));
|
||||
expect(resting).toBeCloseTo(0.42, 9);
|
||||
expect(r.group.rotation.x).toBeCloseTo(Math.PI / 2, 9);
|
||||
// de frente, o balanço lateral some
|
||||
expect(r.group.rotation.y).toBeCloseTo(0, 9);
|
||||
});
|
||||
|
||||
it("needsFrame: the cursor strength alone still far from its target asks for a frame, but only on screen", () => {
|
||||
const r = rig();
|
||||
r.uniforms.uIntro.value = 1;
|
||||
r.uniforms.uMouseAmt.value = 0;
|
||||
|
||||
expect(needsFrame(r.uniforms, r.state, 0, 0.8, false, true)).toBe(true);
|
||||
expect(needsFrame(r.uniforms, r.state, 0, 0.8, false, false)).toBe(false);
|
||||
// dentro da tolerância de 0,01 já está assentado
|
||||
r.uniforms.uMouseAmt.value = 0.795;
|
||||
expect(needsFrame(r.uniforms, r.state, 0, 0.8, false, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("needsFrame: the scroll damping counts as settling only beyond 0.0004 of the target", () => {
|
||||
const r = rig();
|
||||
r.uniforms.uIntro.value = 1;
|
||||
r.uniforms.uMouseAmt.value = 0.4;
|
||||
r.state.progress = 0.5;
|
||||
|
||||
expect(needsFrame(r.uniforms, r.state, 0.50039, 0.4, false, false)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(needsFrame(r.uniforms, r.state, 0.5005, 0.4, false, false)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("updateRig: the camera follows the choreography -> the first frame before it starts, a smoothed midpoint, the last frame after it ends", () => {
|
||||
const r = rig();
|
||||
|
||||
updateRig(r, tick({ p: -0.5 }));
|
||||
expect(r.camera.position.z).toBe(9);
|
||||
|
||||
updateRig(r, tick({ p: 0.5 }));
|
||||
expect(r.camera.position.z).toBeCloseTo(4, 9);
|
||||
|
||||
updateRig(r, tick({ p: 2 }));
|
||||
expect(r.camera.position.z).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createField: geometria pré-calculada e quadros sem alocação", () => {
|
||||
it("path: prepareField builds the points before the scene mounts, and mounting reuses them", () => {
|
||||
prepareField();
|
||||
expect(mockGeometries).toHaveLength(1);
|
||||
@@ -531,40 +712,42 @@ describe("Field: geometria pré-calculada e quadros sem alocação", () => {
|
||||
expect(mockGeometries).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("path: no vector is created per frame, not even with the mouse driving the cursor over the layer", () => {
|
||||
it("path: no vector is created per frame beyond the few the frame itself needs, not even with the mouse over the layer", () => {
|
||||
const scene = setup();
|
||||
jest.spyOn(performance, "now").mockReturnValue(5000);
|
||||
scene.frame(0);
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
pointerMove("mouse");
|
||||
scene.frame(5.1, 1 / 60);
|
||||
scene.frame();
|
||||
const before = mockCounts.vectors;
|
||||
|
||||
for (let i = 0; i < 60; i++) scene.frame(5.2 + i / 60, 1 / 60);
|
||||
for (let i = 0; i < 60; i++) scene.frame();
|
||||
|
||||
// nenhum Vector3 novo: os auxiliares são criados uma vez só
|
||||
expect(mockCounts.vectors).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Field: densidade, contexto WebGL e limpeza", () => {
|
||||
describe("createField: densidade, contexto WebGL, erros 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);
|
||||
setWindowSize(1200);
|
||||
setCores(8);
|
||||
setup();
|
||||
const dense = pointCount();
|
||||
|
||||
mockGeometries.length = 0;
|
||||
setWindowWidth(600);
|
||||
setWindowSize(600);
|
||||
setup();
|
||||
|
||||
expect(dense).toBeGreaterThan(pointCount());
|
||||
});
|
||||
|
||||
it("path: wide window but 2 cores -> the light field", () => {
|
||||
setWindowWidth(1200);
|
||||
setWindowSize(1200);
|
||||
setCores(2);
|
||||
setup();
|
||||
const light = pointCount();
|
||||
@@ -577,7 +760,7 @@ describe("Field: densidade, contexto WebGL e limpeza", () => {
|
||||
});
|
||||
|
||||
it("path: the browser does not report cores -> counts as capable (4): dense, not light", () => {
|
||||
setWindowWidth(1200);
|
||||
setWindowSize(1200);
|
||||
const counts: Record<string, number> = {};
|
||||
for (const [label, cores] of [
|
||||
["dense", 8],
|
||||
@@ -599,15 +782,22 @@ describe("Field: densidade, contexto WebGL e limpeza", () => {
|
||||
const scene = setup();
|
||||
const event = new Event("webglcontextlost", { cancelable: true });
|
||||
|
||||
act(() => {
|
||||
scene.domElement.dispatchEvent(event);
|
||||
});
|
||||
scene.renderer.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", () => {
|
||||
it("path: drawing throws -> the page is told and the scene does not crash the frame", () => {
|
||||
const scene = setup();
|
||||
mockRenderThrows = true;
|
||||
|
||||
expect(() => scene.frame()).not.toThrow();
|
||||
|
||||
expect(scene.onError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("path: dispose -> geometry, material and renderer are released, the canvas leaves, and the listeners go away", () => {
|
||||
const scene = setup();
|
||||
const geometry = mockGeometries[mockGeometries.length - 1];
|
||||
const material = mockMaterials[mockMaterials.length - 1];
|
||||
@@ -615,38 +805,60 @@ describe("Field: densidade, contexto WebGL e limpeza", () => {
|
||||
const disposeMaterial = jest.spyOn(material, "dispose");
|
||||
const event = new Event("webglcontextlost", { cancelable: true });
|
||||
|
||||
scene.unmount();
|
||||
scene.domElement.dispatchEvent(event);
|
||||
scene.engine.dispose();
|
||||
scene.renderer.domElement.dispatchEvent(event);
|
||||
pointerMove("mouse");
|
||||
|
||||
expect(disposeGeometry).toHaveBeenCalledTimes(1);
|
||||
expect(disposeMaterial).toHaveBeenCalledTimes(1);
|
||||
expect(scene.renderer.dispose).toHaveBeenCalledTimes(1);
|
||||
expect(scene.renderer.forceContextLoss).toHaveBeenCalledTimes(1);
|
||||
expect(scene.renderer.domElement.isConnected).toBe(false);
|
||||
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", () => {
|
||||
it("path: a frame after dispose does nothing", () => {
|
||||
const scene = setup();
|
||||
scene.engine.dispose();
|
||||
scene.renderer.render.mockClear();
|
||||
|
||||
expect(scene.container.querySelector("group")).not.toBeNull();
|
||||
expect(scene.onError).not.toHaveBeenCalled();
|
||||
scene.frame();
|
||||
|
||||
expect(scene.renderer.render).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("path: no ResizeObserver (old browser) -> the window resize event resizes the scene", () => {
|
||||
const original = globalThis.ResizeObserver;
|
||||
// @ts-expect-error o jsdom não tem ResizeObserver; o teste garante a ausência
|
||||
globalThis.ResizeObserver = undefined;
|
||||
try {
|
||||
const scene = setup({ width: 600, height: 400 });
|
||||
scene.renderer.setSize.mockClear();
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
expect(scene.renderer.setSize).toHaveBeenCalledWith(600, 400, false);
|
||||
} finally {
|
||||
globalThis.ResizeObserver = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createField: a batida de 30 quadros por segundo", () => {
|
||||
it("path: setVisible(true) twice in a row -> a single pulse; leaving and coming back starts a new one", () => {
|
||||
const interval = jest
|
||||
.spyOn(window, "setInterval")
|
||||
.mockReturnValue(7 as unknown as ReturnType<typeof setInterval>);
|
||||
jest.spyOn(window, "clearInterval").mockImplementation(() => {});
|
||||
const { engine } = setup({ visible: false });
|
||||
expect(interval).not.toHaveBeenCalled();
|
||||
|
||||
engine.setVisible(true);
|
||||
engine.setVisible(true);
|
||||
expect(interval).toHaveBeenCalledTimes(1);
|
||||
|
||||
engine.setVisible(false);
|
||||
engine.setVisible(false); // sair de novo sem estar à vista não desliga nada duas vezes
|
||||
engine.setVisible(true);
|
||||
expect(interval).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user