import { act, render } from "@testing-library/react"; import HeroScene from "./HeroScene"; /** * Testes de caminhos-base de HeroScene (fundo 3D das páginas internas): quando o desenho está ligado * (visível E à vista), a câmera que segue o mouse (CameraRig) e o relógio do chão em grade (GridFloor). * O @react-three/fiber é trocado por um dublê que renderiza os filhos e entrega os callbacks do useFrame. */ const mockFrames: Array<(state: unknown, delta: number) => void> = []; const mockCamera = { position: { x: 0, y: 0.5, z: 8 }, lookAt: jest.fn(), }; const mockPulse = jest.fn(); jest.mock("@react-three/fiber", () => ({ Canvas: ({ children }: { children?: React.ReactNode }) => <>{children}, useFrame: (callback: (state: unknown, delta: number) => void) => { mockFrames.push(callback); }, useThree: () => ({ camera: mockCamera }), })); jest.mock("./FramePulse", () => ({ __esModule: true, default: (props: { active: boolean }) => { mockPulse(props.active); return null; }, })); type Observer = { callback: IntersectionObserverCallback; disconnect: jest.Mock; }; let observers: Observer[] = []; const OriginalObserver = global.IntersectionObserver; beforeEach(() => { mockFrames.length = 0; mockPulse.mockClear(); mockCamera.position = { x: 0, y: 0.5, z: 8 }; mockCamera.lookAt.mockClear(); observers = []; global.IntersectionObserver = class { private readonly record: Observer; constructor(callback: IntersectionObserverCallback) { this.record = { callback, disconnect: jest.fn() }; observers.push(this.record); } observe() {} unobserve() {} disconnect() { this.record.disconnect(); } takeRecords() { return []; } } as unknown as typeof IntersectionObserver; // as tags do R3F (fog, mesh, ...) são elementos desconhecidos no DOM do jsdom jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { global.IntersectionObserver = OriginalObserver; jest.restoreAllMocks(); }); const lastPulse = () => mockPulse.mock.calls[mockPulse.mock.calls.length - 1][0]; function report(isIntersecting: boolean) { act(() => { observers[0].callback( [{ isIntersecting } as IntersectionObserverEntry], {} as IntersectionObserver, ); }); } describe("HeroScene: quando desenha", () => { it("path: visible by prop and in view (default) -> the pulse is on", () => { render(); expect(lastPulse()).toBe(true); }); it("path: isVisible=false -> the pulse is off even though the scene is in view", () => { render(); expect(lastPulse()).toBe(false); }); it("path: scrolled out of view -> off; back in view -> on", () => { render(); report(false); expect(lastPulse()).toBe(false); report(true); expect(lastPulse()).toBe(true); }); it("path: out of view and isVisible=false -> off", () => { render(); report(false); expect(lastPulse()).toBe(false); }); it("path: unmount -> the observer is disconnected", () => { const view = render(); view.unmount(); expect(observers[0].disconnect).toHaveBeenCalledTimes(1); }); }); describe("HeroScene: câmera que segue o mouse", () => { // a cada renderização CameraRig e GridFloor chamam useFrame, nessa ordem: valem os dois últimos const rigFrame = () => mockFrames[mockFrames.length - 2]; it("path: mouse at the center -> the camera eases toward the resting height and looks at the origin", () => { render(); act(() => { rigFrame()({}, 0.016); }); expect(mockCamera.position.x).toBeCloseTo(0, 9); expect(mockCamera.position.y).toBeCloseTo(0.5, 9); expect(mockCamera.lookAt).toHaveBeenCalledWith(0, 0, 0); }); it("path: mouse moved to the top-right corner -> the camera drifts right and up, a little per frame", () => { Object.defineProperty(window, "innerWidth", { configurable: true, value: 1000, }); Object.defineProperty(window, "innerHeight", { configurable: true, value: 800, }); render(); act(() => { window.dispatchEvent( new MouseEvent("mousemove", { clientX: 1000, clientY: 0 }), ); rigFrame()({}, 0.016); }); // alvo x = 1 * 0.5; alvo y = 1 * 0.3 + 0.5; passo de 2% por quadro expect(mockCamera.position.x).toBeCloseTo(0.5 * 0.02, 9); expect(mockCamera.position.y).toBeCloseTo(0.5 + (0.8 - 0.5) * 0.02, 9); }); it("path: unmount -> the mouse listener is removed", () => { const remove = jest.spyOn(window, "removeEventListener"); const view = render(); view.unmount(); expect(remove.mock.calls.map(([type]) => type)).toContain("mousemove"); }); }); describe("HeroScene: relógio do chão em grade", () => { const gridFrame = () => mockFrames[mockFrames.length - 1]; function attachMaterial(material: unknown) { const mesh = document.querySelector("mesh") as HTMLElement & { material?: unknown; }; mesh.material = material; } it("path: the mesh has shader uniforms -> uTime follows the clock", () => { render(); const uniforms = { uTime: { value: 0 } }; attachMaterial({ uniforms }); gridFrame()({ clock: { elapsedTime: 12.5 } }, 0.016); expect(uniforms.uTime.value).toBe(12.5); }); it("path: the material has no uniforms yet -> nothing happens", () => { render(); attachMaterial({}); expect(() => gridFrame()({ clock: { elapsedTime: 3 } }, 0.016), ).not.toThrow(); }); it("path: the mesh is gone (unmounted) -> the frame callback does nothing", () => { const view = render(); const frame = gridFrame(); view.unmount(); expect(() => frame({ clock: { elapsedTime: 3 } }, 0.016)).not.toThrow(); }); });