Files
portifolio-evandrodevbr/app/components/cinematic/HeroFieldScene.paths.test.tsx
T

958 lines
31 KiB
TypeScript

import {
type BufferGeometry,
type Group,
type PerspectiveCamera,
type Plane,
Ray,
type ShaderMaterial,
type Vector3,
} from "three";
import {
createField,
type FieldEngine,
isSettling,
prepareField,
type Rig,
type Tick,
updatePointer,
updateRig,
} from "./HeroFieldScene";
/**
* 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 e rolagem assentando) e quando deixar para a batida de 30 por segundo (cursor);
* - 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.
*/
const mockMaterials: ShaderMaterial[] = [];
const mockGeometries: BufferGeometry[] = [];
const mockCameras: PerspectiveCamera[] = [];
const mockGroups: Group[] = [];
const mockCounts = { vectors: 0 };
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();
// um WebGL que não conta o nome do renderizador (sem WEBGL_debug_renderer_info): não é por software
getContext = () => ({ getExtension: () => null });
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 ShaderMaterial);
}
}
class CapturingGeometry extends actual.BufferGeometry {
constructor() {
super();
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 {
constructor(x?: number, y?: number, z?: number) {
super(x, y, z);
mockCounts.vectors += 1;
}
}
return {
...actual,
WebGLRenderer: jest.fn(() => new MockRenderer()),
ShaderMaterial: CapturingMaterial,
BufferGeometry: CapturingGeometry,
PerspectiveCamera: CapturingCamera,
Group: CapturingGroup,
Vector3: CountingVector3,
};
});
type Setup = {
width?: number;
height?: number;
/** `null` simula uma ref sem valor (o código trata como 0). */
progress?: number | null;
visible?: boolean;
};
let engines: FieldEngine[] = [];
let frameRequests: jest.SpyInstance;
function setup({
width = 1200,
height = 800,
progress = 0,
visible = true,
}: Setup = {}) {
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 engine = createField(host, { progressRef, onReady, onError });
engines.push(engine);
engine.setVisible(visible);
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;
/**
* 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, deltaMs = 50) {
let asked = 0;
for (let i = 0; i < count; i++) asked = frame(deltaMs);
return asked;
}
return {
host,
engine,
renderer,
camera,
group,
progressRef,
onReady,
onError,
uniforms,
frame,
settle,
/** O relógio do quadro (ms), para os testes que pedem o tempo em segundos. */
now: () => clock,
};
}
const originalHardware = Object.getOwnPropertyDescriptor(
navigator,
"hardwareConcurrency",
);
function setCores(value: number | undefined) {
Object.defineProperty(navigator, "hardwareConcurrency", {
configurable: true,
value,
});
}
function setWindowSize(width: number, height = 800) {
Object.defineProperty(window, "innerWidth", {
configurable: true,
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 });
window.dispatchEvent(event);
}
beforeEach(() => {
mockCounts.vectors = 0;
mockMaterials.length = 0;
mockGeometries.length = 0;
mockCameras.length = 0;
mockGroups.length = 0;
mockRenderers.length = 0;
mockRenderThrows = false;
engines = [];
setCores(8);
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("createField: quadro — começo e rolagem", () => {
it("path: onReady fires on the second frame, once", () => {
const scene = setup();
scene.frame();
expect(scene.onReady).not.toHaveBeenCalled();
scene.frame();
expect(scene.onReady).toHaveBeenCalledTimes(1);
scene.frame();
scene.frame();
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(); // 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 - (1 - afterFirst) * 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(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);
expect(scene.uniforms().uIntro.value).toBe(1);
});
it("path: the scene only dims, and only at the very end of the dive (it never ends in the dark)", () => {
const scene = setup({ progress: 0 });
scene.settle(10);
expect(scene.uniforms().uFade.value).toBe(1);
scene.progressRef.current = 0.9;
scene.settle(120);
expect(scene.uniforms().uFade.value).toBe(1);
scene.progressRef.current = 1;
scene.settle(120);
expect(scene.uniforms().uFade.value).toBeCloseTo(0.6, 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);
});
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("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();
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();
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 });
scene.settle(200);
const spread = scene.uniforms().uSpread.value;
expect(scene.group.position.y).toBeCloseTo(0, 6);
// passou pelas duas primeiras camadas e parou diante da última (a dos dados), a menos de 6 unidades dela
expect(scene.camera.position.z).toBeLessThan(0);
expect(scene.camera.position.z).toBeGreaterThan(-spread);
expect(scene.camera.position.z + spread).toBeLessThan(6);
});
it("path: camera in front of the layers -> the lens focuses on the next layer ahead", () => {
const scene = setup({ progress: 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);
});
it("path: camera parked in front of the data layer -> the lens focuses on it, never closer than 1.5", () => {
const scene = setup({ progress: 1 });
scene.settle(200);
const spread = scene.uniforms().uSpread.value;
expect(scene.uniforms().uFocus.value).toBeCloseTo(
scene.camera.position.z + spread,
6,
);
expect(scene.uniforms().uFocus.value).toBeGreaterThanOrEqual(1.5);
});
it("path: camera between the first layer and the middle one -> the lens focuses on the middle layer, at the origin", () => {
const scene = setup({ progress: 0.55 });
scene.settle(200);
const spread = scene.uniforms().uSpread.value;
const z = scene.camera.position.z;
// já passou da primeira camada (z abaixo de spread) e ainda não chegou à do meio (z acima de 0,2)
expect(z).toBeGreaterThan(0.2);
expect(z).toBeLessThan(spread);
expect(scene.uniforms().uFocus.value).toBeCloseTo(Math.max(1.5, z), 6);
});
});
describe("createField: mouse", () => {
it("path: pointer type other than mouse (touch, pen) -> ignored, the cursor stays on its own path", () => {
const scene = setup();
pointerMove("touch");
pointerMove("pen");
// sem mouse, o cursor fantasma passa a valer 0,4 (não 0,8)
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();
scene.frame(); // o relógio da cena começa
(performance.now as jest.Mock).mockReturnValue(scene.now());
pointerMove("mouse");
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();
scene.frame(0);
(performance.now as jest.Mock).mockReturnValue(scene.now());
pointerMove("mouse");
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();
(performance.now as jest.Mock).mockReturnValue(0);
pointerMove("mouse");
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 });
scene.frame();
(performance.now as jest.Mock).mockReturnValue(scene.now());
pointerMove("mouse");
for (let i = 0; i < 200; i++) scene.frame(5);
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(Ray.prototype, "intersectPlane")
.mockImplementation(((_plane: Plane, target: Vector3) =>
target.set(1, 0, 2)) as never);
// 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(50);
const factor = 1 - Math.exp(-(1 / 20) * 10);
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(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(50);
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(0);
scene.frame(9000);
const { x, y } = scene.uniforms().uMouse.value;
expect(Math.hypot(x, y)).toBeGreaterThan(0);
});
});
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 });
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);
expect(scene.frame(50)).toBe(0);
});
it("path: everything settled and the hero is not visible -> no frame", () => {
const scene = setup({ visible: false });
scene.settle(200);
expect(scene.frame(50)).toBe(0);
});
it("path: settled, visible, and the mouse is moving -> the frame draws but asks for no other (the pulse brings the next at 30 Hz)", () => {
const scene = setup({ visible: true });
scene.settle(200);
(performance.now as jest.Mock).mockReturnValue(scene.now());
pointerMove("mouse");
const drawn = scene.renderer.render.mock.calls.length;
expect(scene.frame(50)).toBe(0);
expect(scene.renderer.render.mock.calls.length).toBe(drawn + 1);
});
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);
(performance.now as jest.Mock).mockReturnValue(scene.now());
pointerMove("mouse");
expect(scene.frame(50)).toBe(0);
});
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;
expect(scene.frame()).toBeGreaterThan(0);
});
it("path: becoming visible later -> the pulse starts and asks for a frame right away", () => {
const scene = setup({ visible: false });
scene.settle(200);
(performance.now as jest.Mock).mockReturnValue(scene.now());
pointerMove("mouse");
expect(scene.frame(50)).toBe(0);
frameRequests.mockClear();
scene.engine.setVisible(true);
expect(frameRequests).toHaveBeenCalledTimes(1);
});
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("isSettling / 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("isSettling: the intro still running keeps asking, the settled scene does not", () => {
const r = rig();
r.uniforms.uIntro.value = 1;
expect(isSettling(r.uniforms, r.state, 0)).toBe(false);
r.uniforms.uIntro.value = 0.5;
expect(isSettling(r.uniforms, r.state, 0)).toBe(true);
});
it("isSettling: the scroll target far from the shown progress keeps asking, the cursor does not (the pulse does that)", () => {
const r = rig();
r.uniforms.uIntro.value = 1;
r.uniforms.uMouseAmt.value = 0;
r.state.frames = 1;
r.state.lastMove = 1;
expect(isSettling(r.uniforms, r.state, 0)).toBe(false);
expect(isSettling(r.uniforms, r.state, 0.5)).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("isSettling: the scroll damping counts as settling only beyond 0.0004 of the target", () => {
const r = rig();
r.uniforms.uIntro.value = 1;
r.state.progress = 0.5;
expect(isSettling(r.uniforms, r.state, 0.50039)).toBe(false);
expect(isSettling(r.uniforms, r.state, 0.5005)).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);
const prepared = mockGeometries[0];
prepareField(); // de novo, sem montar: nada é refeito
expect(mockGeometries).toHaveLength(1);
setup();
expect(mockGeometries).toHaveLength(1);
expect(mockGeometries[0]).toBe(prepared);
expect(prepared.getAttribute("position").count).toBeGreaterThan(1000);
});
it("path: the preparation is used once; a second scene builds its own points", () => {
prepareField();
setup();
expect(mockGeometries).toHaveLength(1);
setup();
expect(mockGeometries).toHaveLength(2);
});
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();
scene.frame(0);
(performance.now as jest.Mock).mockReturnValue(scene.now());
pointerMove("mouse");
scene.frame();
const before = mockCounts.vectors;
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("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", () => {
setWindowSize(1200);
setCores(8);
setup();
const dense = pointCount();
mockGeometries.length = 0;
setWindowSize(600);
setup();
expect(dense).toBeGreaterThan(pointCount());
});
it("path: wide window but 2 cores -> the light field", () => {
setWindowSize(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", () => {
setWindowSize(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 });
scene.renderer.domElement.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(scene.onError).toHaveBeenCalledTimes(1);
});
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];
const disposeGeometry = jest.spyOn(geometry, "dispose");
const disposeMaterial = jest.spyOn(material, "dispose");
const event = new Event("webglcontextlost", { cancelable: true });
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();
});
it("path: a frame after dispose does nothing", () => {
const scene = setup();
scene.engine.dispose();
scene.renderer.render.mockClear();
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: o cursor sozinho não passa de ~30 quadros por segundo", () => {
/** Quantos quadros saem (renderer.render) em `seconds` de uma tela que entrega um quadro a cada `refreshMs`. */
function drawnOn(
scene: ReturnType<typeof setup>,
refreshMs: number,
seconds = 1,
) {
const before = scene.renderer.render.mock.calls.length;
const frames = Math.round((seconds * 1000) / refreshMs);
for (let i = 0; i < frames; i++) scene.frame(refreshMs);
return scene.renderer.render.mock.calls.length - before;
}
/**
* Uma tela de `refreshMs` entre quadros, com tudo assentado e o cursor mexendo: o único relógio é a batida (o
* setInterval de 33 ms do FramePulse, aqui tocado à mão), e um quadro da tela só roda se alguém o pediu.
*/
function pulsedScreen(refreshMs: number) {
let beat = () => {};
jest.spyOn(window, "setInterval").mockImplementation(((
callback: () => void,
) => {
beat = callback;
return 7;
}) as never);
jest.spyOn(window, "clearInterval").mockImplementation(() => {});
let pending: FrameRequestCallback | undefined;
frameRequests.mockImplementation(((callback: FrameRequestCallback) => {
pending = callback;
return 1;
}) as never);
const scene = setup({ visible: true });
let clock = 0;
let nextBeat = 33;
const run = (seconds: number) => {
const drawnBefore = scene.renderer.render.mock.calls.length;
frameRequests.mockClear();
const end = clock + seconds * 1000;
for (clock += refreshMs; clock <= end; clock += refreshMs) {
for (; nextBeat <= clock; nextBeat += 33) beat();
const frame = pending;
pending = undefined;
frame?.(1000 + clock);
}
return {
drawn: scene.renderer.render.mock.calls.length - drawnBefore,
requested: frameRequests.mock.calls.length,
};
};
run(6); // a entrada corre a cada quadro da tela e assenta
pointerMove("mouse");
return run;
}
it.each([
[144, 1000 / 144],
[120, 1000 / 120],
[60, 1000 / 60],
])(
"path: a %i Hz screen with the cursor moving -> about 30 frames a second drawn and asked for, never a wake-up per screen frame",
(_hz, refreshMs) => {
const run = pulsedScreen(refreshMs);
const { drawn, requested } = run(2);
expect(drawn).toBeGreaterThanOrEqual(2 * 25);
expect(drawn).toBeLessThanOrEqual(2 * 31);
// quem pede é a batida: nenhum requestAnimationFrame para pular o desenho e pedir outro
expect(requested).toBeLessThanOrEqual(drawn + 1);
},
);
it("path: a frame the pulse asks for soon after another is drawn, not skipped (nothing throttles it any more)", () => {
const run = pulsedScreen(1000 / 144);
const drawn = run(0.5).drawn;
expect(drawn).toBeGreaterThanOrEqual(14);
// 27,8 ms (4 quadros de 144 Hz) entre duas batidas que se alinharam mal: o desenho sai do mesmo jeito
expect(run(0.1).drawn).toBeGreaterThanOrEqual(2);
});
it("path: the intro and the scroll are not throttled (one frame per screen frame while they settle)", () => {
const scene = setup({ visible: true });
scene.frame(); // a entrada ainda corre
const intro = drawnOn(scene, 1000 / 144, 0.5);
expect(intro).toBe(Math.round(500 / (1000 / 144)));
scene.settle(200); // tudo assentado
scene.progressRef.current = 1; // a rolagem se move: o mergulho segue a cada quadro
const scrolling = drawnOn(scene, 1000 / 60, 0.25);
expect(scrolling).toBe(15);
});
it("path: the frame the pulse asks for 33 ms after the last one is drawn, not skipped", () => {
const scene = setup({ visible: true });
scene.settle(200);
scene.frame(33);
// o quadro de uma batida que sai com folga de temporizador (33 ms) entra
expect(scene.renderer.render.mock.calls.length).toBeGreaterThan(0);
const drawn = scene.renderer.render.mock.calls.length;
scene.frame(33);
expect(scene.renderer.render.mock.calls.length).toBe(drawn + 1);
});
});
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);
});
});