Skip to content

Commit ab0f92f

Browse files
committed
zero coords in safety added
1 parent 563bec1 commit ab0f92f

4 files changed

Lines changed: 242 additions & 38 deletions

File tree

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { render, screen } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import { Summary } from './summary';
5+
import type { Summary as SummaryType } from '@/components/types';
6+
7+
const mockFitBounds = vi.fn();
8+
const mockToggleShowOnMap = vi.fn();
9+
10+
const mockTraceRouteStore = {
11+
results: { show: { 0: true } },
12+
toggleShowOnMap: mockToggleShowOnMap,
13+
successful: true,
14+
};
15+
16+
vi.mock('@/stores/trace-route-store', () => ({
17+
useTraceRouteStore: vi.fn(
18+
(selector: (state: typeof mockTraceRouteStore) => unknown) =>
19+
selector(mockTraceRouteStore)
20+
),
21+
}));
22+
23+
vi.mock('@/stores/common-store', () => ({
24+
useCommonStore: vi.fn((selector) =>
25+
selector({
26+
settingsPanelOpen: false,
27+
})
28+
),
29+
}));
30+
31+
vi.mock('react-map-gl/maplibre', () => ({
32+
useMap: vi.fn(() => ({
33+
mainMap: {
34+
fitBounds: mockFitBounds,
35+
},
36+
})),
37+
}));
38+
39+
const createMockSummary = (
40+
overrides: Partial<SummaryType> = {}
41+
): SummaryType => ({
42+
has_time_restrictions: false,
43+
has_toll: false,
44+
has_highway: false,
45+
has_ferry: false,
46+
min_lat: 0,
47+
min_lon: 0,
48+
max_lat: 1,
49+
max_lon: 2,
50+
time: 120,
51+
length: 1.2,
52+
cost: 1,
53+
...overrides,
54+
});
55+
56+
describe('TraceRoute Summary', () => {
57+
beforeEach(() => {
58+
vi.clearAllMocks();
59+
});
60+
61+
it('should recenter correctly when coordinates include zero values', async () => {
62+
const user = userEvent.setup();
63+
64+
render(
65+
<Summary
66+
summary={createMockSummary()}
67+
title="Main Route"
68+
index={0}
69+
routeCoordinates={[
70+
[0, 0],
71+
[1, 2],
72+
]}
73+
/>
74+
);
75+
76+
await user.click(screen.getByRole('button', { name: /zoom to route/i }));
77+
78+
expect(mockFitBounds).toHaveBeenCalledWith(
79+
[
80+
[0, 0],
81+
[2, 1],
82+
],
83+
expect.objectContaining({
84+
padding: expect.objectContaining({
85+
left: 420,
86+
right: 50,
87+
top: 50,
88+
bottom: 50,
89+
}),
90+
})
91+
);
92+
});
93+
94+
it('should ignore invalid coordinates and still fit to valid route points', async () => {
95+
const user = userEvent.setup();
96+
97+
render(
98+
<Summary
99+
summary={createMockSummary()}
100+
title="Main Route"
101+
index={0}
102+
routeCoordinates={[
103+
[Number.NaN, 4],
104+
[5, 6],
105+
[0, 3],
106+
]}
107+
/>
108+
);
109+
110+
await user.click(screen.getByRole('button', { name: /zoom to route/i }));
111+
112+
expect(mockFitBounds).toHaveBeenCalledWith(
113+
[
114+
[3, 0],
115+
[6, 5],
116+
],
117+
expect.any(Object)
118+
);
119+
});
120+
});

src/components/trace-route/summary.tsx

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -43,30 +43,40 @@ export const Summary = ({
4343

4444
const { mainMap } = useMap();
4545

46+
const isFiniteLatLon = (
47+
coord: number[] | undefined
48+
): coord is [number, number] =>
49+
Array.isArray(coord) &&
50+
coord.length >= 2 &&
51+
Number.isFinite(coord[0]) &&
52+
Number.isFinite(coord[1]);
53+
4654
const handleChange = (checked: boolean) => {
4755
toggleShowOnMap({ show: checked, idx: index });
4856
};
4957

5058
const handleRecenter = () => {
5159
if (!mainMap || routeCoordinates.length === 0) return;
5260

53-
const firstCoord = routeCoordinates[0];
54-
if (!firstCoord || !firstCoord[0] || !firstCoord[1]) return;
61+
const validCoords = routeCoordinates.filter(isFiniteLatLon);
62+
63+
const firstCoord = validCoords[0];
64+
if (!firstCoord) return;
5565

56-
const bounds: [[number, number], [number, number]] =
57-
routeCoordinates.reduce<[[number, number], [number, number]]>(
58-
(acc, coord) => {
59-
if (!coord || !coord[0] || !coord[1]) return acc;
60-
return [
61-
[Math.min(acc[0][0], coord[1]), Math.min(acc[0][1], coord[0])],
62-
[Math.max(acc[1][0], coord[1]), Math.max(acc[1][1], coord[0])],
63-
];
64-
},
65-
[
66-
[firstCoord[1], firstCoord[0]],
67-
[firstCoord[1], firstCoord[0]],
68-
]
69-
);
66+
const bounds: [[number, number], [number, number]] = validCoords.reduce<
67+
[[number, number], [number, number]]
68+
>(
69+
(acc, coord) => {
70+
return [
71+
[Math.min(acc[0][0], coord[1]), Math.min(acc[0][1], coord[0])],
72+
[Math.max(acc[1][0], coord[1]), Math.max(acc[1][1], coord[0])],
73+
];
74+
},
75+
[
76+
[firstCoord[1], firstCoord[0]],
77+
[firstCoord[1], firstCoord[0]],
78+
]
79+
);
7080

7181
mainMap.fitBounds(bounds, {
7282
padding: {

src/components/trace-route/trace-route.spec.tsx

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event';
44
import { TraceRouteControl } from './trace-route';
55
import { useTraceRouteQuery } from '@/hooks/use-trace-route-query';
66
import type { ParsedDirectionsGeometry } from '@/components/types';
7+
import { parseGpxToLatLng } from '@/utils/parse-gpx';
78

89
const mockTraceRoute = vi.fn();
910
const mockShowLoading = vi.fn();
@@ -13,6 +14,7 @@ const mockClearTraceRoute = vi.fn();
1314
const mockSetInputGeometry = vi.fn();
1415
const mockSetInputShape = vi.fn();
1516
const mockSetActiveRouteIndex = vi.fn();
17+
const mockToastWarning = vi.fn();
1618
const mockDecode = vi.fn(() => [
1719
[52.5, 13.4],
1820
[52.6, 13.5],
@@ -68,6 +70,12 @@ vi.mock('@/utils/parse-gpx', () => ({
6870
parseGpxToLatLng: vi.fn(() => []),
6971
}));
7072

73+
vi.mock('sonner', () => ({
74+
toast: {
75+
warning: (...args: unknown[]) => mockToastWarning(...args),
76+
},
77+
}));
78+
7179
vi.mock('@tanstack/react-router', () => ({
7280
useSearch: vi.fn(() => ({ profile: 'auto' })),
7381
}));
@@ -170,6 +178,70 @@ describe('TraceRouteControl', () => {
170178
expect(mockClearTraceRoute).toHaveBeenCalled();
171179
});
172180

181+
it('should keep Trace Route disabled while GPX file is still being read', async () => {
182+
const user = userEvent.setup();
183+
184+
vi.mocked(parseGpxToLatLng).mockReturnValue([
185+
[52.5, 13.4],
186+
[52.6, 13.5],
187+
]);
188+
189+
const deferredFileText: { resolve?: (value: string) => void } = {};
190+
const file = new File(['<gpx></gpx>'], 'route.gpx', {
191+
type: 'application/gpx+xml',
192+
});
193+
Object.defineProperty(file, 'text', {
194+
value: () =>
195+
new Promise<string>((resolve) => {
196+
deferredFileText.resolve = resolve;
197+
}),
198+
});
199+
200+
render(<TraceRouteControl />);
201+
202+
const traceButton = screen.getByRole('button', { name: 'Trace Route' });
203+
const fileInput = screen.getByLabelText('Upload GPX file');
204+
205+
await user.upload(fileInput, file);
206+
expect(traceButton).toBeDisabled();
207+
208+
if (!deferredFileText.resolve) {
209+
throw new Error('Expected file text resolver to be initialized');
210+
}
211+
212+
deferredFileText.resolve(
213+
'<gpx><trk><trkseg><trkpt lat="52.5" lon="13.4" /><trkpt lat="52.6" lon="13.5" /></trkseg></trk></gpx>'
214+
);
215+
216+
await waitFor(() => {
217+
expect(traceButton).toBeEnabled();
218+
});
219+
});
220+
221+
it('should reject oversized GPX files and show warning toast', async () => {
222+
const user = userEvent.setup();
223+
render(<TraceRouteControl />);
224+
225+
const oversizedFile = new File(
226+
['x'.repeat(2 * 1024 * 1024 + 1)],
227+
'too-big.gpx',
228+
{
229+
type: 'application/gpx+xml',
230+
}
231+
);
232+
233+
await user.upload(screen.getByLabelText('Upload GPX file'), oversizedFile);
234+
235+
expect(mockToastWarning).toHaveBeenCalledWith(
236+
'File too large',
237+
expect.objectContaining({
238+
description: expect.stringContaining('Max GPX size is 2 MB'),
239+
})
240+
);
241+
expect(mockClearTraceRoute).toHaveBeenCalled();
242+
expect(screen.getByRole('button', { name: 'Trace Route' })).toBeDisabled();
243+
});
244+
173245
it('should keep maneuvers hidden by default and show them on click', async () => {
174246
const user = userEvent.setup();
175247

src/components/trace-route/trace-route.tsx

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ type TraceRouteErrorPayload = {
3030
export const TraceRouteControl = () => {
3131
const [encodedPolyline, setEncodedPolyline] = useState('');
3232
const [fileText, setFileText] = useState('');
33-
const [file, setFile] = useState<File | null>(null);
3433
const [isProcessing, setIsProcessing] = useState(false);
3534
const { profile } = useSearch({ from: '/$activeTab' }) as {
3635
profile: Profile;
@@ -64,6 +63,17 @@ export const TraceRouteControl = () => {
6463
(state) => state.setActiveRouteIndex
6564
);
6665

66+
const decodePolylineSafely = (value: string) => {
67+
try {
68+
const decoded = decode(value, 6) as [number, number][];
69+
return decoded.filter(
70+
(coord) => Number.isFinite(coord[0]) && Number.isFinite(coord[1])
71+
);
72+
} catch {
73+
return [] as [number, number][];
74+
}
75+
};
76+
6777
const shapeBuilder = (coords: [number, number][]) => {
6878
const shape = coords.map(([lat, lon]) => ({ lat, lon })) as {
6979
lat: number;
@@ -115,6 +125,8 @@ export const TraceRouteControl = () => {
115125
const selectedRoute =
116126
routeOptions.find((route) => route.index === activeRouteIndex) ??
117127
routeOptions[0];
128+
const hasTraceInput =
129+
encodedPolyline.trim().length > 0 || fileText.trim().length > 0;
118130

119131
useEffect(() => {
120132
setShowManeuvers(false);
@@ -130,23 +142,6 @@ export const TraceRouteControl = () => {
130142
};
131143
}, []);
132144

133-
useEffect(() => {
134-
const t = window.setTimeout(() => {
135-
const value = encodedPolyline.trim();
136-
if (!value) {
137-
setInputGeometry(null);
138-
setInputShape(null);
139-
clearTraceRoute();
140-
return;
141-
}
142-
143-
const coords = decode(value, 6) as [number, number][];
144-
setInputGeometry(coords.length >= 2 ? coords : null);
145-
}, 250);
146-
147-
return () => window.clearTimeout(t);
148-
}, [encodedPolyline, setInputGeometry, setInputShape, clearTraceRoute]);
149-
150145
const onPolylineChange = (value: string) => {
151146
setEncodedPolyline(value);
152147
const v = value.trim();
@@ -157,7 +152,15 @@ export const TraceRouteControl = () => {
157152

158153
return;
159154
}
160-
const coords = decode(v, 6) as [number, number][];
155+
const coords = decodePolylineSafely(v);
156+
157+
if (coords.length === 0) {
158+
setInputGeometry(null);
159+
setInputShape(null);
160+
clearTraceRoute();
161+
return;
162+
}
163+
161164
zoomTo(coords);
162165
setInputGeometry(coords.length >= 2 ? coords : null);
163166

@@ -169,7 +172,6 @@ export const TraceRouteControl = () => {
169172

170173
const onFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
171174
const readSeq = ++fileReadSeqRef.current;
172-
setFile(e.target.files?.[0] || null);
173175
const file = e.target.files?.[0];
174176
if (!file) {
175177
setFileName('');
@@ -183,11 +185,11 @@ export const TraceRouteControl = () => {
183185
if (file.size > MAX_GPX_BYTES) {
184186
e.target.value = '';
185187

186-
setFile(null);
187188
setFileName('');
188189
setFileText('');
189190
setInputGeometry(null);
190191
setInputShape(null);
192+
clearTraceRoute();
191193

192194
toast.warning('File too large', {
193195
description: `Max GPX size is ${(MAX_GPX_BYTES / (1024 * 1024)).toFixed(0)} MB.`,
@@ -302,7 +304,7 @@ export const TraceRouteControl = () => {
302304

303305
<Button
304306
className="w-full"
305-
disabled={isProcessing || (!encodedPolyline && !file)}
307+
disabled={isProcessing || !hasTraceInput}
306308
onClick={handleTraceRoute}
307309
>
308310
{isProcessing ? 'Loading...' : 'Trace Route'}

0 commit comments

Comments
 (0)