// @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();
});
});