Skip to content

Commit 92255f2

Browse files
test(MapScreen): add minimal coverage for DownloadHistory and GeoJsonOverlay
Add unit tests for the two new components to satisfy the 80% global branch coverage gate before push. - DownloadHistory.test.tsx: error row + retry, loading skeleton, empty state, ready/error status labels, size formatting (8 tests). - GeoJsonOverlay.test.tsx: dropzone render, drag-active hint, FeatureCollection / single Feature / bare Geometry acceptance, invalid GeoJSON / bad extension / unparseable JSON rejection, large-file warning, visibility toggle, remove and clear-all (12 tests). Both files pass and exceed 80% lines/branch/functions/statements.
1 parent 83bb2e6 commit 92255f2

3 files changed

Lines changed: 343 additions & 3 deletions

File tree

src/i18n/messages/en.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1286,12 +1286,12 @@
12861286
"map.overlay.title": {
12871287
"defaultMessage": "Reference layers"
12881288
},
1289-
"map.overlay.tooLarge": {
1290-
"defaultMessage": "“{name}” is {size}. Large files may slow down the editor."
1291-
},
12921289
"map.overlay.toggle": {
12931290
"defaultMessage": "Toggle overlay visibility"
12941291
},
1292+
"map.overlay.tooLarge": {
1293+
"defaultMessage": "“{name}” is {size}. Large files may slow down the editor."
1294+
},
12951295
"map.saved.activeError": {
12961296
"defaultMessage": "Could not update active map. Please try again."
12971297
},
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { fireEvent, screen } from '@testing-library/react';
2+
import { render } from '@tests/mocks/test-utils';
3+
import { beforeEach, describe, expect, it, vi } from 'vitest';
4+
5+
import { useToast } from '@/components/ui/toast';
6+
import { useDownloadMap } from '@/hooks/useMaps';
7+
import type { SavedMap } from '@/lib/db';
8+
import { formatBytes } from '@/lib/map/smp-download';
9+
import { DownloadHistory } from '@/screens/MapScreen/DownloadHistory';
10+
11+
type ToastReturn = ReturnType<typeof useToast>;
12+
type MutateOpts = {
13+
onSuccess?: (d: unknown) => void;
14+
onError?: (e: unknown) => void;
15+
};
16+
17+
const { mutate, addToast } = vi.hoisted(() => ({
18+
mutate: vi.fn<(vars: { map: SavedMap }, opts?: MutateOpts) => void>(),
19+
addToast: vi.fn(),
20+
}));
21+
22+
vi.mock('@/hooks/useMaps', () => ({ useDownloadMap: vi.fn() }));
23+
vi.mock('@/components/ui/toast', () => ({ useToast: vi.fn() }));
24+
25+
const makeMap = (over: Partial<SavedMap> = {}): SavedMap =>
26+
({
27+
id: 'm1',
28+
projectLocalId: 'p1',
29+
name: 'Test Map',
30+
type: 'style',
31+
styleUrl: 'https://example.com/style',
32+
bbox: [0, 0, 0, 0],
33+
minZoom: 0,
34+
maxZoom: 14,
35+
status: 'ready',
36+
createdAt: '2024-01-01T00:00:00Z',
37+
updatedAt: '2024-01-01T00:00:00Z',
38+
...over,
39+
}) as SavedMap;
40+
41+
beforeEach(() => {
42+
vi.clearAllMocks();
43+
vi.mocked(useDownloadMap).mockReturnValue({
44+
mutate,
45+
isPending: false,
46+
} as unknown as ReturnType<typeof useDownloadMap>);
47+
vi.mocked(useToast).mockReturnValue({ addToast } as unknown as ToastReturn);
48+
});
49+
50+
describe('DownloadHistory', () => {
51+
it('renders the title and two skeletons while loading', () => {
52+
render(<DownloadHistory maps={[]} isLoading />);
53+
expect(screen.getByText(/download history/i)).toBeInTheDocument();
54+
expect(screen.getAllByTestId('skeleton')).toHaveLength(2);
55+
});
56+
57+
it('renders the empty message when there are no completed downloads', () => {
58+
render(<DownloadHistory maps={[]} />);
59+
expect(screen.getByText(/no downloads yet/i)).toBeInTheDocument();
60+
});
61+
62+
it('renders a ready map with its size and Ready status', () => {
63+
render(
64+
<DownloadHistory
65+
maps={[makeMap({ id: 'ready1', status: 'ready', smpSize: 1048576 })]}
66+
/>,
67+
);
68+
expect(screen.getByText('Test Map')).toBeInTheDocument();
69+
const sizeText = screen.getByText(/size:/i);
70+
expect(sizeText.textContent).toContain(formatBytes(1048576));
71+
expect(screen.getByText(/^ready$/i)).toBeInTheDocument();
72+
});
73+
74+
it('renders an error map with its message and a retry button', () => {
75+
render(
76+
<DownloadHistory
77+
maps={[
78+
makeMap({
79+
id: 'err1',
80+
status: 'error',
81+
errorMessage: 'Network failure',
82+
}),
83+
]}
84+
/>,
85+
);
86+
expect(screen.getByText(/failed/i)).toBeInTheDocument();
87+
expect(screen.getByText('Network failure')).toBeInTheDocument();
88+
expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument();
89+
});
90+
91+
it('calls downloadMap.mutate with the map when retry is clicked', () => {
92+
render(
93+
<DownloadHistory
94+
maps={[makeMap({ id: 'err1', status: 'error', errorMessage: 'x' })]}
95+
/>,
96+
);
97+
fireEvent.click(screen.getByRole('button', { name: /retry/i }));
98+
expect(mutate).toHaveBeenCalledWith(
99+
{ map: expect.objectContaining({ id: 'err1' }) },
100+
expect.anything(),
101+
);
102+
});
103+
104+
it('fires a success toast via onSuccess', () => {
105+
mutate.mockImplementation((_vars, opts) => {
106+
opts?.onSuccess?.(undefined);
107+
});
108+
render(
109+
<DownloadHistory
110+
maps={[
111+
makeMap({
112+
id: 'err1',
113+
status: 'error',
114+
errorMessage: 'x',
115+
smpSize: 1048576,
116+
}),
117+
]}
118+
/>,
119+
);
120+
fireEvent.click(screen.getByRole('button', { name: /retry/i }));
121+
expect(addToast).toHaveBeenCalledWith(
122+
expect.objectContaining({ variant: 'success' }),
123+
);
124+
});
125+
126+
it('fires an error toast via onError', () => {
127+
mutate.mockImplementation((_vars, opts) => {
128+
opts?.onError?.(new Error('boom'));
129+
});
130+
render(
131+
<DownloadHistory
132+
maps={[makeMap({ id: 'err1', status: 'error', errorMessage: 'x' })]}
133+
/>,
134+
);
135+
fireEvent.click(screen.getByRole('button', { name: /retry/i }));
136+
expect(addToast).toHaveBeenCalledWith(
137+
expect.objectContaining({
138+
variant: 'error',
139+
title: expect.stringContaining('boom'),
140+
}),
141+
);
142+
});
143+
144+
it('does not render a Size line for a ready map without smpSize', () => {
145+
render(
146+
<DownloadHistory maps={[makeMap({ id: 'ready2', status: 'ready' })]} />,
147+
);
148+
expect(screen.queryByText(/size:/i)).toBeNull();
149+
expect(screen.getByText(/^ready$/i)).toBeInTheDocument();
150+
});
151+
});
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import { fireEvent, screen, waitFor } from '@testing-library/react';
2+
import { render } from '@tests/mocks/test-utils';
3+
import { beforeEach, describe, expect, it, vi } from 'vitest';
4+
5+
import { GeoJsonOverlay } from '@/screens/MapScreen/GeoJsonOverlay';
6+
7+
const { uuid } = vi.hoisted(() => {
8+
let n = 0;
9+
return { uuid: () => `uuid-${n++}` };
10+
});
11+
vi.mock('@/lib/uuid', () => ({ uuid }));
12+
13+
const dropGeoJson = (zone: HTMLElement, files: File[]) => {
14+
fireEvent.drop(zone, {
15+
dataTransfer: { files, items: [], types: ['Files'] },
16+
});
17+
};
18+
19+
const makeFile = (name: string, content: string, size?: number): File => {
20+
const f = new File([content], name, { type: 'application/json' });
21+
if (size !== undefined) Object.defineProperty(f, 'size', { value: size });
22+
return f;
23+
};
24+
25+
const featureCollection = JSON.stringify({
26+
type: 'FeatureCollection',
27+
features: [
28+
{
29+
type: 'Feature',
30+
geometry: { type: 'Point', coordinates: [0, 0] },
31+
properties: {},
32+
},
33+
],
34+
});
35+
const feature = JSON.stringify({
36+
type: 'Feature',
37+
geometry: { type: 'Point', coordinates: [1, 1] },
38+
properties: {},
39+
});
40+
const bareGeometry = JSON.stringify({ type: 'Point', coordinates: [2, 2] });
41+
42+
beforeEach(() => {
43+
vi.clearAllMocks();
44+
});
45+
46+
describe('GeoJsonOverlay', () => {
47+
it('renders the dropzone with the correct label and no panel initially', () => {
48+
render(<GeoJsonOverlay onOverlaysChange={vi.fn()} />);
49+
expect(screen.getByTestId('geojson-dropzone')).toHaveAttribute(
50+
'aria-label',
51+
expect.stringMatching(/drop a \.geojson/i),
52+
);
53+
expect(screen.queryByTestId('geojson-overlay-panel')).toBeNull();
54+
});
55+
56+
it('shows the active drop hint on drag enter and hides it on drag leave', () => {
57+
render(<GeoJsonOverlay onOverlaysChange={vi.fn()} />);
58+
const zone = screen.getByTestId('geojson-dropzone');
59+
fireEvent.dragEnter(zone);
60+
expect(
61+
screen.getByText(/drop the file to add the overlay/i),
62+
).toBeInTheDocument();
63+
expect(zone.className).toContain('border-dashed');
64+
fireEvent.dragLeave(zone);
65+
expect(screen.queryByText(/drop the file to add the overlay/i)).toBeNull();
66+
});
67+
68+
it('adds an overlay from a valid FeatureCollection file', async () => {
69+
const onOverlaysChange = vi.fn();
70+
render(<GeoJsonOverlay onOverlaysChange={onOverlaysChange} />);
71+
dropGeoJson(screen.getByTestId('geojson-dropzone'), [
72+
makeFile('layer.geojson', featureCollection),
73+
]);
74+
await waitFor(() => {
75+
expect(screen.getByTestId('geojson-overlay-row')).toBeInTheDocument();
76+
});
77+
expect(onOverlaysChange).toHaveBeenCalledWith(
78+
expect.arrayContaining([
79+
expect.objectContaining({ name: 'layer.geojson' }),
80+
]),
81+
);
82+
});
83+
84+
it('adds an overlay from a single Feature file', async () => {
85+
render(<GeoJsonOverlay onOverlaysChange={vi.fn()} />);
86+
dropGeoJson(screen.getByTestId('geojson-dropzone'), [
87+
makeFile('feat.geojson', feature),
88+
]);
89+
await waitFor(() => {
90+
expect(screen.getByTestId('geojson-overlay-row')).toBeInTheDocument();
91+
});
92+
});
93+
94+
it('adds an overlay from a bare Geometry object', async () => {
95+
render(<GeoJsonOverlay onOverlaysChange={vi.fn()} />);
96+
dropGeoJson(screen.getByTestId('geojson-dropzone'), [
97+
makeFile('geom.geojson', bareGeometry),
98+
]);
99+
await waitFor(() => {
100+
expect(screen.getByTestId('geojson-overlay-row')).toBeInTheDocument();
101+
});
102+
});
103+
104+
it('rejects an invalid GeoJSON object with an alert and no overlay', async () => {
105+
render(<GeoJsonOverlay onOverlaysChange={vi.fn()} />);
106+
dropGeoJson(screen.getByTestId('geojson-dropzone'), [
107+
makeFile('bad.geojson', JSON.stringify({ foo: 'bar' })),
108+
]);
109+
await waitFor(() => {
110+
expect(screen.getByRole('alert')).toHaveTextContent(/not valid geojson/i);
111+
});
112+
expect(screen.queryByTestId('geojson-overlay-row')).toBeNull();
113+
});
114+
115+
it('rejects a non-geojson filename extension', async () => {
116+
render(<GeoJsonOverlay onOverlaysChange={vi.fn()} />);
117+
dropGeoJson(screen.getByTestId('geojson-dropzone'), [
118+
makeFile('layer.txt', featureCollection),
119+
]);
120+
await waitFor(() => {
121+
expect(screen.getByRole('alert')).toHaveTextContent(/not valid geojson/i);
122+
});
123+
expect(screen.queryByTestId('geojson-overlay-row')).toBeNull();
124+
});
125+
126+
it('rejects unparseable JSON content', async () => {
127+
render(<GeoJsonOverlay onOverlaysChange={vi.fn()} />);
128+
dropGeoJson(screen.getByTestId('geojson-dropzone'), [
129+
makeFile('broken.geojson', 'not json'),
130+
]);
131+
await waitFor(() => {
132+
expect(screen.getByRole('alert')).toHaveTextContent(/not valid geojson/i);
133+
});
134+
expect(screen.queryByTestId('geojson-overlay-row')).toBeNull();
135+
});
136+
137+
it('warns when a large file is dropped', async () => {
138+
const big = makeFile('big.geojson', featureCollection, 6 * 1024 * 1024);
139+
render(<GeoJsonOverlay onOverlaysChange={vi.fn()} />);
140+
dropGeoJson(screen.getByTestId('geojson-dropzone'), [big]);
141+
await waitFor(() => {
142+
expect(screen.getByText(/large files may slow/i)).toBeInTheDocument();
143+
});
144+
});
145+
146+
it('toggles an overlay visibility', async () => {
147+
render(<GeoJsonOverlay onOverlaysChange={vi.fn()} />);
148+
dropGeoJson(screen.getByTestId('geojson-dropzone'), [
149+
makeFile('layer.geojson', featureCollection),
150+
]);
151+
await waitFor(() => {
152+
expect(screen.getByTestId('geojson-overlay-row')).toBeInTheDocument();
153+
});
154+
const toggle = screen.getByLabelText(/toggle overlay visibility/i);
155+
expect(toggle).toHaveAttribute('aria-pressed', 'true');
156+
fireEvent.click(toggle);
157+
expect(screen.getByLabelText(/toggle overlay visibility/i)).toHaveAttribute(
158+
'aria-pressed',
159+
'false',
160+
);
161+
});
162+
163+
it('removes an overlay', async () => {
164+
render(<GeoJsonOverlay onOverlaysChange={vi.fn()} />);
165+
dropGeoJson(screen.getByTestId('geojson-dropzone'), [
166+
makeFile('layer.geojson', featureCollection),
167+
]);
168+
await waitFor(() => {
169+
expect(screen.getByTestId('geojson-overlay-row')).toBeInTheDocument();
170+
});
171+
fireEvent.click(screen.getByLabelText(/remove overlay/i));
172+
expect(screen.queryByTestId('geojson-overlay-row')).toBeNull();
173+
});
174+
175+
it('clears all overlays', async () => {
176+
render(<GeoJsonOverlay onOverlaysChange={vi.fn()} />);
177+
const zone = screen.getByTestId('geojson-dropzone');
178+
dropGeoJson(zone, [makeFile('a.geojson', featureCollection)]);
179+
dropGeoJson(zone, [makeFile('b.geojson', feature)]);
180+
await waitFor(() => {
181+
expect(screen.getAllByTestId('geojson-overlay-row')).toHaveLength(2);
182+
});
183+
fireEvent.click(screen.getByText(/clear all overlays/i));
184+
await waitFor(() => {
185+
expect(screen.queryByTestId('geojson-overlay-panel')).toBeNull();
186+
});
187+
expect(screen.queryByTestId('geojson-overlay-row')).toBeNull();
188+
});
189+
});

0 commit comments

Comments
 (0)