@zapperwing/pinterest-view
Version:
A Pinterest-style grid layout component for React.js with responsive design, dynamic content support, and bulletproof virtualization
331 lines (284 loc) • 9.71 kB
JavaScript
// src/components/__tests__/StackGrid.spec.js
import React from 'react';
import { render, act } from '@testing-library/react';
import StackGrid, { GridInline } from '../StackGrid';
// Mock react-sizeme to provide size prop that component needs
jest.mock('react-sizeme', () => ({
__esModule: true,
default: () => (Component) => function SizeMeWrapper(props) {
// Provide a mock size that the component needs
const mockSize = {
width: 800,
height: 600,
registerRef: jest.fn(),
unregisterRef: jest.fn(),
};
// eslint-disable-next-line react/jsx-props-no-spreading
return <Component {...props} size={mockSize} />;
},
}));
// Mock requestAnimationFrame for predictable testing
let rafCallbacks = [];
let rafId = 0;
global.requestAnimationFrame = (callback) => {
rafId += 1;
rafCallbacks.push({ id: rafId, callback });
return rafId;
};
global.cancelAnimationFrame = (id) => {
rafCallbacks = rafCallbacks.filter((cb) => cb.id !== id);
};
// Helper to flush RAF callbacks
const flushRAF = () => {
rafCallbacks.forEach(({ callback }) => callback());
rafCallbacks = [];
};
describe('StackGrid', () => {
beforeEach(() => {
jest.useFakeTimers();
rafCallbacks = [];
rafId = 0;
});
afterEach(() => {
jest.useRealTimers();
});
describe('Basic Rendering', () => {
it('renders children correctly', () => {
const { container } = render(
<StackGrid columnWidth={100}>
<div data-testid="child1">Item 1</div>
<div data-testid="child2">Item 2</div>
</StackGrid>,
);
expect(container.firstChild).toBeInTheDocument();
});
it('applies correct container styles', () => {
const { container } = render(
<StackGrid columnWidth={100} gutterWidth={10} gutterHeight={10}>
<div>Test Content</div>
</StackGrid>,
);
const gridContainer = container.querySelector('[style*="position: relative"]');
expect(gridContainer).toBeInTheDocument();
expect(gridContainer).toHaveStyle({ position: 'relative' });
});
});
describe('GridInline Component', () => {
const mockSize = {
width: 800,
height: 600,
registerRef: jest.fn(),
unregisterRef: jest.fn(),
};
it('renders GridInline with size props', () => {
const { container } = render(
<GridInline
size={mockSize}
columnWidth={100}
component="div"
itemComponent="div"
gutterWidth={5}
gutterHeight={5}
>
<div>Test Content</div>
</GridInline>,
);
expect(container.firstChild).toBeInTheDocument();
expect(mockSize.registerRef).toHaveBeenCalled();
});
});
describe('Public API Methods', () => {
it('exposes layout and updateLayout methods', () => {
const gridRef = React.createRef();
render(
<StackGrid ref={gridRef} columnWidth={100}>
<div key="1">Item 1</div>
</StackGrid>,
);
// Flush RAF to ensure component is mounted
act(() => {
flushRAF();
jest.runAllTimers();
});
expect(gridRef.current).toBeDefined();
expect(typeof gridRef.current.layout).toBe('function');
expect(typeof gridRef.current.updateLayout).toBe('function');
});
it('calls onLayout callback when layout updates', () => {
const onLayout = jest.fn();
render(
<StackGrid columnWidth={100} onLayout={onLayout}>
<div style={{ height: '200px' }}>Test Content</div>
</StackGrid>,
);
// Flush RAF to trigger initial layout
act(() => {
flushRAF();
jest.runAllTimers();
});
expect(onLayout).toHaveBeenCalled();
expect(onLayout).toHaveBeenCalledWith(expect.objectContaining({
height: expect.any(Number),
}));
});
});
describe('Measurement Phase Behavior', () => {
it('transitions from measurement to virtualized phase', () => {
const gridRef = React.createRef();
render(
<StackGrid ref={gridRef} columnWidth={100} virtualized>
<div key="1" style={{ height: '200px' }}>Item 1</div>
<div key="2" style={{ height: '200px' }}>Item 2</div>
</StackGrid>,
);
// Flush RAF and timers to complete measurement
act(() => {
flushRAF();
jest.runAllTimers();
});
// Simulate measurement completion by calling the method directly
act(() => {
if (gridRef.current.finalizeMeasurementPhase) {
gridRef.current.finalizeMeasurementPhase();
}
});
expect(gridRef.current.state.measurementPhase).toBe(false);
expect(gridRef.current.state.allItemsMeasured).toBe(true);
});
});
describe('Virtualization', () => {
it('renders all items when virtualization is disabled', () => {
const { container } = render(
<StackGrid columnWidth={100} virtualized={false}>
<div key="1">Item 1</div>
<div key="2">Item 2</div>
<div key="3">Item 3</div>
</StackGrid>,
);
act(() => {
flushRAF();
jest.runAllTimers();
});
// When virtualization is disabled, should render all items
// Note: The grid renders both measurement and virtualized items during measurement phase
const gridItems = container.querySelectorAll('.grid-item');
expect(gridItems.length).toBeGreaterThanOrEqual(3);
});
it('renders items when virtualization is enabled', () => {
const { container } = render(
<StackGrid columnWidth={100} virtualized>
<div key="1">Item 1</div>
<div key="2">Item 2</div>
<div key="3">Item 3</div>
</StackGrid>,
);
act(() => {
flushRAF();
jest.runAllTimers();
});
// Should render at least some items (viewport + buffer)
const gridItems = container.querySelectorAll('.grid-item');
expect(gridItems.length).toBeGreaterThan(0);
});
});
describe('Scroll Container Management', () => {
it('properly handles scroll container changes', () => {
const mockScrollContainer = {
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
scrollTop: 0,
};
const { rerender } = render(
<GridInline
size={{ width: 800, height: 600, registerRef: jest.fn(), unregisterRef: jest.fn() }}
columnWidth={100}
virtualized
scrollContainer={mockScrollContainer}
>
<div key="1" style={{ height: '200px' }}>Item 1</div>
<div key="2" style={{ height: '200px' }}>Item 2</div>
</GridInline>,
);
act(() => {
flushRAF();
jest.runAllTimers();
});
// Should add scroll listener to the custom container
expect(mockScrollContainer.addEventListener).toHaveBeenCalledWith('scroll', expect.any(Function), {
passive: true,
});
// Create a new scroll container
const newMockScrollContainer = {
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
scrollTop: 0,
};
// Rerender with new scroll container
rerender(
<GridInline
size={{ width: 800, height: 600, registerRef: jest.fn(), unregisterRef: jest.fn() }}
columnWidth={100}
virtualized
scrollContainer={newMockScrollContainer}
>
<div key="1" style={{ height: '200px' }}>Item 1</div>
<div key="2" style={{ height: '200px' }}>Item 2</div>
</GridInline>,
);
act(() => {
flushRAF();
jest.runAllTimers();
});
// Should remove listener from old container
expect(mockScrollContainer.removeEventListener).toHaveBeenCalledWith('scroll', expect.any(Function));
// Should add listener to new container
expect(newMockScrollContainer.addEventListener).toHaveBeenCalledWith('scroll', expect.any(Function), {
passive: true,
});
});
it('handles virtualization state changes correctly', () => {
const mockScrollContainer = {
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
scrollTop: 0,
};
const { rerender } = render(
<GridInline
size={{ width: 800, height: 600, registerRef: jest.fn(), unregisterRef: jest.fn() }}
columnWidth={100}
virtualized={false}
scrollContainer={mockScrollContainer}
>
<div key="1" style={{ height: '200px' }}>Item 1</div>
<div key="2" style={{ height: '200px' }}>Item 2</div>
</GridInline>,
);
act(() => {
flushRAF();
jest.runAllTimers();
});
// Should not add scroll listener when virtualization is disabled
expect(mockScrollContainer.addEventListener).not.toHaveBeenCalled();
// Enable virtualization
rerender(
<GridInline
size={{ width: 800, height: 600, registerRef: jest.fn(), unregisterRef: jest.fn() }}
columnWidth={100}
virtualized
scrollContainer={mockScrollContainer}
>
<div key="1" style={{ height: '200px' }}>Item 1</div>
<div key="2" style={{ height: '200px' }}>Item 2</div>
</GridInline>,
);
act(() => {
flushRAF();
jest.runAllTimers();
});
// Should add scroll listener when virtualization is enabled
expect(mockScrollContainer.addEventListener).toHaveBeenCalledWith('scroll', expect.any(Function), {
passive: true,
});
});
});
});