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 (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, 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(); 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 -> frames are requested", () => { const scene = setup({ visible: true }); scene.settle(200); (performance.now as jest.Mock).mockReturnValue(scene.now()); pointerMove("mouse"); expect(scene.frame(50)).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); (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 next frame sees the new flag", () => { const scene = setup({ visible: false }); scene.settle(200); (performance.now as jest.Mock).mockReturnValue(scene.now()); pointerMove("mouse"); expect(scene.frame(50)).toBe(0); scene.engine.setVisible(true); expect(scene.frame(50)).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("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 => ({ 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); 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 = {}; 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, 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; } function withMovingCursor() { const scene = setup({ visible: true }); scene.settle(200); (performance.now as jest.Mock).mockReturnValue(scene.now()); pointerMove("mouse"); return scene; } 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, not one per screen frame", (_hz, refreshMs) => { const scene = withMovingCursor(); const drawn = drawnOn(scene, refreshMs, 2); expect(drawn).toBeGreaterThanOrEqual(2 * 25); expect(drawn).toBeLessThanOrEqual(2 * 31); }, ); it("path: a skipped frame draws nothing, keeps the clock of the last drawn one and asks for the next", () => { const scene = withMovingCursor(); scene.frame(50); // o último desenhado const drawn = scene.renderer.render.mock.calls.length; expect(scene.frame(10)).toBe(1); // 10 ms depois: pula e pede outro expect(scene.renderer.render.mock.calls.length).toBe(drawn); expect(scene.frame(10)).toBe(1); expect(scene.frame(10)).toBe(1); expect(scene.renderer.render.mock.calls.length).toBe(drawn); // 40 ms depois do último desenhado: sai (o relógio do quadro pulado não conta) scene.frame(10); expect(scene.renderer.render.mock.calls.length).toBe(drawn + 1); }); 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); 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); }); });