diff --git a/src/app.spec.tsx b/src/app.spec.tsx index 096f3d8..a98b954 100644 --- a/src/app.spec.tsx +++ b/src/app.spec.tsx @@ -1,7 +1,35 @@ -import { describe, it, expect, vi } from 'vitest'; +import { beforeEach, describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; import { App } from './app'; +type MockSearch = { + wps?: string; +}; + +type MockWaypoint = { + id: string; + geocodeResults: Array<{ + selected?: boolean; + sourcelnglat?: [number, number]; + }>; + userInput: string; +}; + +const mockUseParams = vi.fn(() => ({ activeTab: 'directions' })); +const mockUseSearch = vi.fn<() => MockSearch>(() => ({ wps: undefined })); +const mockRefetchDirections = vi.fn().mockResolvedValue(undefined); +const mockReverseGeocode = vi.fn().mockResolvedValue([]); +let mockMapReady = true; +let mockWaypoints: MockWaypoint[] = [ + { id: '0', geocodeResults: [], userInput: '' }, + { id: '1', geocodeResults: [], userInput: '' }, +]; + +vi.mock('@tanstack/react-router', () => ({ + useParams: () => mockUseParams(), + useSearch: () => mockUseSearch(), +})); + vi.mock('react-map-gl/maplibre', () => ({ MapProvider: ({ children }: { children: React.ReactNode }) => (
{children}
@@ -32,7 +60,38 @@ vi.mock('@/components/ui/sonner', () => ({ ), })); +vi.mock('@/hooks/use-directions-queries', () => ({ + useDirectionsQuery: () => ({ + refetch: mockRefetchDirections, + }), + useReverseGeocodeDirections: () => ({ + reverseGeocode: mockReverseGeocode, + }), +})); + +vi.mock('@/stores/common-store', () => ({ + useCommonStore: (selector: (state: { mapReady: boolean }) => unknown) => + selector({ mapReady: mockMapReady }), +})); + +vi.mock('@/stores/directions-store', () => ({ + useDirectionsStore: ( + selector: (state: { waypoints: typeof mockWaypoints }) => unknown + ) => selector({ waypoints: mockWaypoints }), +})); + describe('App', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseParams.mockReturnValue({ activeTab: 'directions' }); + mockUseSearch.mockReturnValue({ wps: undefined }); + mockMapReady = true; + mockWaypoints = [ + { id: '0', geocodeResults: [], userInput: '' }, + { id: '1', geocodeResults: [], userInput: '' }, + ]; + }); + it('should render without crashing', () => { expect(() => render()).not.toThrow(); }); @@ -73,4 +132,60 @@ describe('App', () => { expect(mapProvider).toContainElement(screen.getByTestId('settings-panel')); expect(mapProvider).toContainElement(screen.getByTestId('toaster')); }); + + it('should hydrate waypoints from URL search params on initial load', () => { + mockUseSearch.mockReturnValue({ + wps: '13.343067169189455,52.5296422146409,13.33414077758789,52.50901237642168', + }); + + render(); + + expect(mockReverseGeocode).toHaveBeenCalledTimes(2); + expect(mockReverseGeocode).toHaveBeenNthCalledWith( + 1, + 13.343067169189455, + 52.5296422146409, + 0, + { isPermalink: true } + ); + expect(mockReverseGeocode).toHaveBeenNthCalledWith( + 2, + 13.33414077758789, + 52.50901237642168, + 1, + { isPermalink: true } + ); + }); + + it('should auto-render directions when URL waypoints already exist in state', () => { + mockUseSearch.mockReturnValue({ + wps: '13.343067169189455,52.5296422146409,13.33414077758789,52.50901237642168', + }); + mockWaypoints = [ + { + id: '0', + geocodeResults: [ + { + selected: true, + sourcelnglat: [13.343067169189455, 52.5296422146409], + }, + ], + userInput: 'Waypoint 1', + }, + { + id: '1', + geocodeResults: [ + { + selected: true, + sourcelnglat: [13.33414077758789, 52.50901237642168], + }, + ], + userInput: 'Waypoint 2', + }, + ]; + + render(); + + expect(mockRefetchDirections).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/app.tsx b/src/app.tsx index 9fd9be8..cf96918 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,10 +1,110 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { useParams, useSearch } from '@tanstack/react-router'; import { MapProvider } from 'react-map-gl/maplibre'; import { MapComponent } from './components/map'; import { RoutePlanner } from './components/route-planner'; import { SettingsPanel } from './components/settings-panel/settings-panel'; import { Toaster } from '@/components/ui/sonner'; +import { + useDirectionsQuery, + useReverseGeocodeDirections, +} from '@/hooks/use-directions-queries'; +import { useCommonStore } from '@/stores/common-store'; +import { useDirectionsStore } from '@/stores/directions-store'; +import { isValidCoordinates } from '@/utils/geom'; export const App = () => { + const { activeTab } = useParams({ from: '/$activeTab' }); + const { wps } = useSearch({ from: '/$activeTab' }); + const { refetch: refetchDirections } = useDirectionsQuery(); + const { reverseGeocode } = useReverseGeocodeDirections(); + const mapReady = useCommonStore((state) => state.mapReady); + const waypoints = useDirectionsStore((state) => state.waypoints); + const initialWpsRef = useRef(wps); + const urlWaypointsLoadedRef = useRef(false); + const urlRouteRenderedRef = useRef(false); + + const activeWaypointCount = useMemo( + () => + waypoints.filter((wp) => + wp.geocodeResults.some((result) => result.selected) + ).length, + [waypoints] + ); + + useEffect(() => { + const initialWps = initialWpsRef.current; + + if ( + activeTab !== 'directions' || + !initialWps || + urlWaypointsLoadedRef.current + ) { + return; + } + + urlWaypointsLoadedRef.current = true; + console.info('[App] Hydrating directions from URL search params', { + wps: initialWps, + }); + + const coordinates = initialWps.split(',').map(Number); + let hasValidWaypoint = false; + + for (let i = 0; i < coordinates.length; i += 2) { + const lng = coordinates[i]; + const lat = coordinates[i + 1]; + + if ( + lng === undefined || + lat === undefined || + Number.isNaN(lng) || + Number.isNaN(lat) || + !isValidCoordinates(lat, lng) + ) { + continue; + } + + hasValidWaypoint = true; + + void reverseGeocode(lng, lat, i / 2, { isPermalink: true }).catch( + (error) => { + console.error('[App] Failed to hydrate waypoint from URL', error); + } + ); + } + + if (!hasValidWaypoint) { + console.debug( + '[App] No valid route coordinates found in URL search params' + ); + } + }, [activeTab, reverseGeocode]); + + useEffect(() => { + const initialWps = initialWpsRef.current; + + if ( + activeTab !== 'directions' || + !initialWps || + !mapReady || + urlRouteRenderedRef.current || + activeWaypointCount < 2 + ) { + return; + } + + urlRouteRenderedRef.current = true; + console.info('[App] Auto-rendering route from URL search params', { + activeWaypointCount, + }); + + void refetchDirections().catch((error) => { + console.error('[App] Failed to auto-render route from URL', error); + urlRouteRenderedRef.current = false; + }); + }, [activeTab, activeWaypointCount, mapReady, refetchDirections]); + return ( diff --git a/src/components/directions/directions.spec.tsx b/src/components/directions/directions.spec.tsx index b888329..2dbeb41 100644 --- a/src/components/directions/directions.spec.tsx +++ b/src/components/directions/directions.spec.tsx @@ -5,7 +5,6 @@ import { DirectionsControl } from './directions'; const mockNavigate = vi.fn(); const mockRefetchDirections = vi.fn(); -const mockReverseGeocode = vi.fn().mockResolvedValue([]); const mockAddEmptyWaypointToEnd = vi.fn(); const mockClearWaypoints = vi.fn(); const mockClearRoutes = vi.fn(); @@ -15,10 +14,6 @@ vi.mock('@tanstack/react-router', () => ({ useNavigate: vi.fn(() => mockNavigate), })); -vi.mock('@/utils/parse-url-params', () => ({ - parseUrlParams: vi.fn(() => ({})), -})); - const mockWaypoints = [ { id: '0', geocodeResults: [], userInput: '' }, { id: '1', geocodeResults: [], userInput: '' }, @@ -63,9 +58,6 @@ vi.mock('@/hooks/use-directions-queries', () => ({ useDirectionsQuery: vi.fn(() => ({ refetch: mockRefetchDirections, })), - useReverseGeocodeDirections: vi.fn(() => ({ - reverseGeocode: mockReverseGeocode, - })), })); vi.mock('@/hooks/use-optimized-route-query', () => ({ @@ -292,89 +284,3 @@ describe('DirectionsControl', () => { expect(result.wps).toBe('13.4,52.5,10,48'); }); }); - -describe('DirectionsControl URL parsing', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockResults.data = null; - mockWaypoints.length = 0; - mockWaypoints.push( - { id: '0', geocodeResults: [], userInput: '' }, - { id: '1', geocodeResults: [], userInput: '' } - ); - }); - - it('should process URL params with valid coordinates (Berlin)', async () => { - const parseUrlParams = await import('@/utils/parse-url-params'); - vi.mocked(parseUrlParams.parseUrlParams).mockReturnValue({ - wps: '13.365016850476763,52.483706198952575,13.422421655040836,52.49336042169804', - }); - - render(); - - expect(mockReverseGeocode).toHaveBeenCalledTimes(2); - expect(mockReverseGeocode).toHaveBeenCalledWith( - 13.365016850476763, - 52.483706198952575, - 0, - { isPermalink: true } - ); - expect(mockReverseGeocode).toHaveBeenCalledWith( - 13.422421655040836, - 52.49336042169804, - 1, - { isPermalink: true } - ); - }); - - it('should process URL params with valid coordinates where lng > 90 (Singapore)', async () => { - const parseUrlParams = await import('@/utils/parse-url-params'); - vi.mocked(parseUrlParams.parseUrlParams).mockReturnValue({ - wps: '103.66492937866911,1.4827280571964963,103.66421854954496,1.4840285187178779', - }); - - render(); - - expect(mockReverseGeocode).toHaveBeenCalledTimes(2); - expect(mockReverseGeocode).toHaveBeenCalledWith( - 103.66492937866911, - 1.4827280571964963, - 0, - { isPermalink: true } - ); - expect(mockReverseGeocode).toHaveBeenCalledWith( - 103.66421854954496, - 1.4840285187178779, - 1, - { isPermalink: true } - ); - }); - - it('should skip truly invalid coordinates from URL', async () => { - const parseUrlParams = await import('@/utils/parse-url-params'); - vi.mocked(parseUrlParams.parseUrlParams).mockReturnValue({ - wps: '999,999', - }); - - render(); - - expect(mockReverseGeocode).not.toHaveBeenCalled(); - }); - - it('should handle coordinates near edge of valid range', async () => { - const parseUrlParams = await import('@/utils/parse-url-params'); - vi.mocked(parseUrlParams.parseUrlParams).mockReturnValue({ - wps: '179.9,89,-179.9,-89', - }); - - render(); - - expect(mockReverseGeocode).toHaveBeenCalledTimes(2); - expect(mockReverseGeocode).toHaveBeenCalledWith(179.9, 89, 0, { - isPermalink: true, - }); - expect(mockReverseGeocode).toHaveBeenCalledWith(-179.9, -89, 1, { - isPermalink: true, - }); - }); -}); diff --git a/src/components/directions/directions.tsx b/src/components/directions/directions.tsx index 3147dd5..d5344c1 100644 --- a/src/components/directions/directions.tsx +++ b/src/components/directions/directions.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect } from 'react'; import { Waypoints } from './waypoints/waypoint-list'; @@ -11,14 +11,9 @@ import type { ParsedDirectionsGeometry } from '@/components/types'; import { Button } from '@/components/ui/button'; import { MapPinPlus, MapPinXInside } from 'lucide-react'; import { RouteCard } from './route-card'; -import { parseUrlParams } from '@/utils/parse-url-params'; -import { isValidCoordinates } from '@/utils/geom'; import { useNavigate } from '@tanstack/react-router'; import { useDirectionsStore } from '@/stores/directions-store'; -import { - useDirectionsQuery, - useReverseGeocodeDirections, -} from '@/hooks/use-directions-queries'; +import { useDirectionsQuery } from '@/hooks/use-directions-queries'; import { useOptimizedRouteQuery } from '@/hooks/use-optimized-route-query'; import { Sparkles } from 'lucide-react'; import { @@ -35,13 +30,10 @@ export const DirectionsControl = () => { ); const clearWaypoints = useDirectionsStore((state) => state.clearWaypoints); const clearRoutes = useDirectionsStore((state) => state.clearRoutes); - const initialUrlParams = useRef(parseUrlParams()); - const urlParamsProcessed = useRef(false); const navigate = useNavigate({ from: '/$activeTab' }); const updateDateTime = useCommonStore((state) => state.updateDateTime); const dateTime = useCommonStore((state) => state.dateTime); const { refetch: refetchDirections } = useDirectionsQuery(); - const { reverseGeocode } = useReverseGeocodeDirections(); const { optimizeRoute, isPending: isOptimizing } = useOptimizedRouteQuery(); const isOptimized = useDirectionsStore((state) => state.isOptimized); const activeRouteIndex = useDirectionsStore( @@ -51,30 +43,6 @@ export const DirectionsControl = () => { (state) => state.setActiveRouteIndex ); - useEffect(() => { - if (urlParamsProcessed.current) return; - - const wpsParam = initialUrlParams.current.wps; - - if (wpsParam) { - const coordinates = wpsParam.split(',').map(Number); - - for (let i = 0; i < coordinates.length; i += 2) { - const lng = coordinates[i]!; - const lat = coordinates[i + 1]!; - - if (!isValidCoordinates(lat, lng) || isNaN(lng) || isNaN(lat)) continue; - - const index = i / 2; - reverseGeocode(lng, lat, index, { isPermalink: true }); - } - refetchDirections(); - } - - urlParamsProcessed.current = true; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - useEffect(() => { const wps: number[] = [];