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