// @vitest-environment jsdom import { render, act } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import GradientBlinds from './GradientBlinds'; // Mock ogl vi.mock('ogl', () => { return { Renderer: class { gl = { // Use a real canvas element so appendChild works canvas: document.createElement('canvas'), drawingBufferWidth: 100, drawingBufferHeight: 100, }; setSize() { } render() { } }, Program: class { remove() { } }, Mesh: class { remove() { } }, Geometry: class { remove() { } }, Triangle: class { remove() { } }, }; }); // Mock ResizeObserver globalThis.ResizeObserver = class ResizeObserver { observe() { } unobserve() { } disconnect() { } }; describe('GradientBlinds', () => { let rafSpy: any; let cancelRafSpy: any; let addEventListenerSpy: any; let removeEventListenerSpy: any; let ioCallback: (entries: IntersectionObserverEntry[]) => void; let observeSpy: any; let disconnectSpy: any; beforeEach(() => { rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((_cb: any) => { return 123; }); cancelRafSpy = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation((_id: number) => { }); addEventListenerSpy = vi.spyOn(window, 'addEventListener'); removeEventListenerSpy = vi.spyOn(window, 'removeEventListener'); // Mock IntersectionObserver observeSpy = vi.fn(); disconnectSpy = vi.fn(); (globalThis as any).IntersectionObserver = class IntersectionObserver { constructor(cb: any) { ioCallback = cb; } observe = observeSpy; unobserve = vi.fn(); disconnect = disconnectSpy; }; }); afterEach(() => { vi.clearAllMocks(); }); it('does not start animation loop on mount until visible', () => { const { unmount } = render(); expect(rafSpy).not.toHaveBeenCalled(); // Simulate on-screen act(() => { if (ioCallback) { ioCallback([{ isIntersecting: true } as IntersectionObserverEntry]); } }); expect(rafSpy).toHaveBeenCalled(); unmount(); expect(cancelRafSpy).toHaveBeenCalled(); }); it('pauses animation loop when off-screen and resumes when on-screen', () => { const { unmount } = render(); // Should not start initially expect(rafSpy).not.toHaveBeenCalled(); // Simulate on-screen (start) act(() => { if (ioCallback) { ioCallback([{ isIntersecting: true } as IntersectionObserverEntry]); } }); expect(rafSpy).toHaveBeenCalledTimes(1); // Reset spies to check for subsequent calls rafSpy.mockClear(); cancelRafSpy.mockClear(); // Simulate off-screen act(() => { if (ioCallback) { ioCallback([{ isIntersecting: false } as IntersectionObserverEntry]); } }); // Should cancel animation frame expect(cancelRafSpy).toHaveBeenCalled(); // Should NOT request new frame (loop stopped) expect(rafSpy).not.toHaveBeenCalled(); // Reset spies cancelRafSpy.mockClear(); // Simulate on-screen act(() => { if (ioCallback) { ioCallback([{ isIntersecting: true } as IntersectionObserverEntry]); } }); // Should restart animation loop expect(rafSpy).toHaveBeenCalled(); unmount(); expect(disconnectSpy).toHaveBeenCalled(); }); it('toggles pointer event listener based on visibility', () => { const { unmount } = render(); // Should not listen initially (not visible) // Initially not called expect(addEventListenerSpy).not.toHaveBeenCalledWith('pointermove', expect.any(Function)); // Simulate on-screen act(() => { if (ioCallback) { ioCallback([{ isIntersecting: true } as IntersectionObserverEntry]); } }); expect(addEventListenerSpy).toHaveBeenCalledWith('pointermove', expect.any(Function)); // Reset addEventListenerSpy.mockClear(); removeEventListenerSpy.mockClear(); // Simulate off-screen act(() => { if (ioCallback) { ioCallback([{ isIntersecting: false } as IntersectionObserverEntry]); } }); expect(removeEventListenerSpy).toHaveBeenCalledWith('pointermove', expect.any(Function)); // Simulate on-screen again act(() => { if (ioCallback) { ioCallback([{ isIntersecting: true } as IntersectionObserverEntry]); } }); expect(addEventListenerSpy).toHaveBeenCalledWith('pointermove', expect.any(Function)); unmount(); expect(removeEventListenerSpy).toHaveBeenCalledWith('pointermove', expect.any(Function)); }); it('minimizes getBoundingClientRect calls during pointer move', () => { const { unmount } = render(); const spy = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect'); spy.mockClear(); act(() => { const event = new PointerEvent('pointermove', { clientX: 200, clientY: 200, bubbles: true, }); window.dispatchEvent(event); }); expect(spy).not.toHaveBeenCalled(); unmount(); }); it('avoids expensive DOM reads (scrollX/Y) in pointermove handler when visible', () => { const { unmount } = render(); // Spy on scrollX/scrollY getters // Note: In jsdom, these are properties on window. const scrollSpy = vi.spyOn(window, 'scrollX', 'get'); // Make visible to attach listener act(() => { if (ioCallback) { ioCallback([{ isIntersecting: true } as IntersectionObserverEntry]); } }); scrollSpy.mockClear(); // Trigger pointer move act(() => { window.dispatchEvent(new PointerEvent('pointermove', { clientX: 100, clientY: 100 })); }); // With the optimization (moving to RAF loop), this should be 0. // Without optimization, this will be > 0. // Since we are mocking RAF and not running the loop, if it's in the loop, it won't be called. expect(scrollSpy).not.toHaveBeenCalled(); unmount(); }); });