Skip to content

Commit 67e4fae

Browse files
test: add unit tests for ImagesGrid, PlotOfTheDay, RelatedSpecs, useInfiniteScroll, responsiveImage, and stats endpoints
- Implement tests for ImagesGrid component - Add tests for PlotOfTheDay functionality - Create tests for RelatedSpecs component - Introduce tests for useInfiniteScroll hook - Add tests for responsiveImage utility functions - Implement tests for stats endpoint and refresh functionality
1 parent 53ee57c commit 67e4fae

File tree

8 files changed

+1663
-0
lines changed

8 files changed

+1663
-0
lines changed
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { render, screen } from '../test-utils';
3+
import { ImagesGrid } from './ImagesGrid';
4+
import type { PlotImage, LibraryInfo, SpecInfo } from '../types';
5+
import type { ImageSize } from '../constants';
6+
import { createRef } from 'react';
7+
8+
// Mock child components to isolate ImagesGrid
9+
vi.mock('./ImageCard', () => ({
10+
ImageCard: ({ image, index }: { image: PlotImage; index: number }) => (
11+
<div data-testid={`image-card-${index}`}>{image.library}</div>
12+
),
13+
}));
14+
15+
vi.mock('./LoaderSpinner', () => ({
16+
LoaderSpinner: ({ size }: { size: string }) => (
17+
<div data-testid="loader-spinner">{size}</div>
18+
),
19+
}));
20+
21+
const mockLibraries: LibraryInfo[] = [
22+
{ id: 'matplotlib', name: 'Matplotlib', description: 'Matplotlib desc', documentation_url: 'https://matplotlib.org' },
23+
];
24+
25+
const mockSpecs: SpecInfo[] = [
26+
{ id: 'scatter-basic', title: 'Basic Scatter Plot', description: 'A scatter plot' },
27+
];
28+
29+
function makeImages(n: number): PlotImage[] {
30+
return Array.from({ length: n }, (_, i) => ({
31+
library: 'matplotlib',
32+
url: `https://example.com/img${i}.png`,
33+
spec_id: `scatter-${i}`,
34+
}));
35+
}
36+
37+
interface DefaultProps {
38+
images: PlotImage[];
39+
viewMode: 'spec' | 'library';
40+
selectedSpec: string;
41+
selectedLibrary: string;
42+
loading: boolean;
43+
hasMore: boolean;
44+
isLoadingMore: boolean;
45+
isTransitioning: boolean;
46+
librariesData: LibraryInfo[];
47+
specsData: SpecInfo[];
48+
openTooltip: string | null;
49+
loadMoreRef: React.RefObject<HTMLDivElement | null>;
50+
imageSize: ImageSize;
51+
onTooltipToggle: ReturnType<typeof vi.fn>;
52+
onCardClick: ReturnType<typeof vi.fn>;
53+
}
54+
55+
function getDefaultProps(overrides: Partial<DefaultProps> = {}): DefaultProps {
56+
return {
57+
images: [],
58+
viewMode: 'library',
59+
selectedSpec: '',
60+
selectedLibrary: '',
61+
loading: false,
62+
hasMore: false,
63+
isLoadingMore: false,
64+
isTransitioning: false,
65+
librariesData: mockLibraries,
66+
specsData: mockSpecs,
67+
openTooltip: null,
68+
loadMoreRef: createRef<HTMLDivElement>(),
69+
imageSize: 'normal',
70+
onTooltipToggle: vi.fn(),
71+
onCardClick: vi.fn(),
72+
...overrides,
73+
};
74+
}
75+
76+
describe('ImagesGrid', () => {
77+
it('shows loading spinner when loading with no images', () => {
78+
render(<ImagesGrid {...getDefaultProps({ loading: true })} />);
79+
expect(screen.getByTestId('loader-spinner')).toBeInTheDocument();
80+
expect(screen.getByTestId('loader-spinner')).toHaveTextContent('large');
81+
});
82+
83+
it('shows "No images found" alert when images are empty and not loading', () => {
84+
render(<ImagesGrid {...getDefaultProps({ loading: false, images: [] })} />);
85+
expect(screen.getByText('No images found for this spec.')).toBeInTheDocument();
86+
});
87+
88+
it('renders an ImageCard for each image', () => {
89+
const images = makeImages(3);
90+
render(<ImagesGrid {...getDefaultProps({ images })} />);
91+
92+
expect(screen.getByTestId('image-card-0')).toBeInTheDocument();
93+
expect(screen.getByTestId('image-card-1')).toBeInTheDocument();
94+
expect(screen.getByTestId('image-card-2')).toBeInTheDocument();
95+
});
96+
97+
it('renders correctly with compact image size', () => {
98+
const images = makeImages(2);
99+
render(<ImagesGrid {...getDefaultProps({ images, imageSize: 'compact' })} />);
100+
101+
expect(screen.getByTestId('image-card-0')).toBeInTheDocument();
102+
expect(screen.getByTestId('image-card-1')).toBeInTheDocument();
103+
});
104+
105+
it('renders bottom loading indicator when isLoadingMore and hasMore', () => {
106+
const images = makeImages(3);
107+
render(
108+
<ImagesGrid {...getDefaultProps({ images, hasMore: true, isLoadingMore: true })} />,
109+
);
110+
// There should be two loader spinners: one is the bottom small spinner
111+
const spinners = screen.getAllByTestId('loader-spinner');
112+
expect(spinners.some((s) => s.textContent === 'small')).toBe(true);
113+
});
114+
115+
it('does not show loading spinner when loading but isTransitioning is true', () => {
116+
render(
117+
<ImagesGrid {...getDefaultProps({ loading: true, isTransitioning: true })} />,
118+
);
119+
// When isTransitioning is true and images is empty and loading, the first branch
120+
// is skipped. The second branch renders the empty alert because images.length===0
121+
// and !loading is false — so the condition (images.length > 0 || !loading) is false
122+
// and the component returns null.
123+
expect(screen.queryByTestId('loader-spinner')).not.toBeInTheDocument();
124+
});
125+
126+
it('returns null when images empty, loading true, and transitioning true', () => {
127+
const { container } = render(
128+
<ImagesGrid {...getDefaultProps({ loading: true, isTransitioning: true, images: [] })} />,
129+
);
130+
// The component returns null in this case (neither spinner nor alert)
131+
expect(container.firstChild).toBeNull();
132+
});
133+
});
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import { render, screen, waitFor } from '../test-utils';
3+
import { PlotOfTheDay } from './PlotOfTheDay';
4+
5+
// Mock sessionStorage
6+
const sessionStorageMock: Record<string, string> = {};
7+
const sessionStorageStub = {
8+
getItem: vi.fn((key: string) => sessionStorageMock[key] ?? null),
9+
setItem: vi.fn((key: string, value: string) => { sessionStorageMock[key] = value; }),
10+
removeItem: vi.fn((key: string) => { delete sessionStorageMock[key]; }),
11+
clear: vi.fn(() => { Object.keys(sessionStorageMock).forEach(k => delete sessionStorageMock[k]); }),
12+
get length() { return Object.keys(sessionStorageMock).length; },
13+
key: vi.fn(() => null),
14+
};
15+
16+
const mockData = {
17+
spec_id: 'scatter-basic',
18+
spec_title: 'Basic Scatter Plot',
19+
description: 'A scatter plot',
20+
library_id: 'matplotlib',
21+
library_name: 'Matplotlib',
22+
quality_score: 9,
23+
preview_url: 'https://cdn.example.com/plots/scatter-basic/matplotlib/plot.png',
24+
image_description: 'Shows data points with clear labels',
25+
library_version: '3.8.0',
26+
python_version: '3.12',
27+
date: '2026-04-11',
28+
};
29+
30+
describe('PlotOfTheDay', () => {
31+
let fetchMock: ReturnType<typeof vi.fn>;
32+
33+
beforeEach(() => {
34+
fetchMock = vi.fn();
35+
vi.stubGlobal('fetch', fetchMock);
36+
// Reset sessionStorage mock state
37+
Object.keys(sessionStorageMock).forEach(k => delete sessionStorageMock[k]);
38+
vi.stubGlobal('sessionStorage', sessionStorageStub);
39+
sessionStorageStub.getItem.mockClear();
40+
sessionStorageStub.setItem.mockClear();
41+
});
42+
43+
afterEach(() => {
44+
vi.restoreAllMocks();
45+
});
46+
47+
it('renders a placeholder while loading', () => {
48+
// Never resolve the fetch, so component stays in loading state
49+
fetchMock.mockReturnValue(new Promise(() => {}));
50+
51+
const { container } = render(<PlotOfTheDay />);
52+
53+
// The loading state renders a Box with minHeight for CLS prevention
54+
const placeholder = container.firstChild as HTMLElement;
55+
expect(placeholder).toBeInTheDocument();
56+
// Should not show any text content yet
57+
expect(screen.queryByText('plot of the day')).not.toBeInTheDocument();
58+
});
59+
60+
it('shows the card after successful fetch', async () => {
61+
fetchMock.mockResolvedValueOnce({
62+
ok: true,
63+
json: () => Promise.resolve(mockData),
64+
});
65+
66+
render(<PlotOfTheDay />);
67+
68+
await waitFor(() => {
69+
expect(screen.getByText('plot of the day')).toBeInTheDocument();
70+
});
71+
72+
expect(screen.getByText('Basic Scatter Plot')).toBeInTheDocument();
73+
});
74+
75+
it('shows image description when present', async () => {
76+
fetchMock.mockResolvedValueOnce({
77+
ok: true,
78+
json: () => Promise.resolve(mockData),
79+
});
80+
81+
render(<PlotOfTheDay />);
82+
83+
await waitFor(() => {
84+
expect(screen.getByText(/Shows data points with clear labels/)).toBeInTheDocument();
85+
});
86+
});
87+
88+
it('shows library version and python version in bottom bar', async () => {
89+
fetchMock.mockResolvedValueOnce({
90+
ok: true,
91+
json: () => Promise.resolve(mockData),
92+
});
93+
94+
render(<PlotOfTheDay />);
95+
96+
await waitFor(() => {
97+
expect(screen.getByText(/Matplotlib 3\.8\.0/)).toBeInTheDocument();
98+
expect(screen.getByText(/Python 3\.12/)).toBeInTheDocument();
99+
});
100+
});
101+
102+
it('returns null immediately when dismissed via sessionStorage', () => {
103+
sessionStorageMock['potd_dismissed'] = 'true';
104+
105+
const { container } = render(<PlotOfTheDay />);
106+
107+
// Dismissed state returns null — no DOM output
108+
expect(container.firstChild).toBeNull();
109+
// Should not have fetched
110+
expect(fetchMock).not.toHaveBeenCalled();
111+
});
112+
113+
it('returns null after API error', async () => {
114+
fetchMock.mockResolvedValueOnce({ ok: false });
115+
116+
const { container } = render(<PlotOfTheDay />);
117+
118+
await waitFor(() => {
119+
// After loading finishes with error, data is null so component returns null
120+
expect(container.firstChild).toBeNull();
121+
});
122+
});
123+
124+
it('dismisses on close button click', async () => {
125+
fetchMock.mockResolvedValueOnce({
126+
ok: true,
127+
json: () => Promise.resolve(mockData),
128+
});
129+
130+
const { userEvent } = await import('../test-utils');
131+
const user = userEvent.setup();
132+
133+
const { container } = render(<PlotOfTheDay />);
134+
135+
await waitFor(() => {
136+
expect(screen.getByText('plot of the day')).toBeInTheDocument();
137+
});
138+
139+
const dismissButton = screen.getByLabelText('Dismiss plot of the day');
140+
await user.click(dismissButton);
141+
142+
// After dismiss, component should return null
143+
expect(container.firstChild).toBeNull();
144+
expect(sessionStorageStub.setItem).toHaveBeenCalledWith('potd_dismissed', 'true');
145+
});
146+
147+
it('hides library version when it is "unknown"', async () => {
148+
const dataWithUnknownVersion = { ...mockData, library_version: 'unknown' };
149+
fetchMock.mockResolvedValueOnce({
150+
ok: true,
151+
json: () => Promise.resolve(dataWithUnknownVersion),
152+
});
153+
154+
render(<PlotOfTheDay />);
155+
156+
await waitFor(() => {
157+
expect(screen.getByText('plot of the day')).toBeInTheDocument();
158+
});
159+
160+
// Should show "Matplotlib" without " unknown" appended
161+
// The bottom bar text node should contain just "Matplotlib" followed by python version
162+
const bottomText = screen.getByText(/Matplotlib/);
163+
expect(bottomText.textContent).not.toContain('unknown');
164+
});
165+
});

0 commit comments

Comments
 (0)