import React from 'react' import { describe, it, expect, vi, afterEach } from 'vitest' import { render, screen } from '../../../tests/helpers/render' import { act, fireEvent, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { resetAllStores } from '../../../tests/helpers/store' import { buildPlace, buildReservation } from '../../../tests/helpers/factories' import { MAP_MAX_ZOOM } from '../../constants/mapDefaults' import { useAuthStore } from '../../store/authStore' import { CATEGORY_ICON_MAP } from '../shared/categoryIcons' import * as photoService from '../../services/photoService' const mapMock = vi.hoisted(() => ({ panTo: vi.fn(), setView: vi.fn(), fitBounds: vi.fn(), getZoom: vi.fn().mockReturnValue(10), on: vi.fn(), off: vi.fn(), panBy: vi.fn(), latLngToContainerPoint: vi.fn(() => ({ x: 0, y: 0, distanceTo: () => 1000 })), // Panes: jsdom has none, so keep them in a map the pane tests can read back. panes: new Map(), getPane: vi.fn(function (this: void, name: string) { return mapMock.panes.get(name) }), createPane: vi.fn(function (this: void, name: string) { const el = document.createElement('div') mapMock.panes.set(name, el) return el }), getBounds: vi.fn(() => ({ getSouth: () => 47, getWest: () => 1, getNorth: () => 49, getEast: () => 3 })), whenReady: vi.fn((cb: () => void) => { cb() }), })) // Live-location fixture; tests flip this before rendering. const geoMock = vi.hoisted(() => ({ position: null as { lat: number; lng: number; accuracy: number; heading: number | null } | null, mode: 'off' as 'off' | 'show' | 'follow', error: null as string | null, cycleMode: vi.fn(), setMode: vi.fn(), })) vi.mock('../../hooks/useGeolocation', () => ({ useGeolocation: () => geoMock, })) // onThumbReady callbacks are captured so a test can play back a photo landing // after the map already rendered. const thumbCallbacks = vi.hoisted(() => new Map void>()) vi.mock('react-leaflet', () => ({ // center/zoom are surfaced so tests can assert the camera the map is built // with; maxZoom because a cluster refuses to attach to a map without one. MapContainer: ({ children, center, zoom, maxZoom }: any) => (
{children}
), TileLayer: () =>
, Marker: ({ children, eventHandlers, position, icon, zIndexOffset }: any) => (
eventHandlers?.click?.()} >
), // pathOptions has to reach the DOM: the style props are what the track-colour // feature (#776) actually asserts on, and the real Leaflet only repaints when // that object's reference changes. // bubblingMouseEvents and pane are surfaced too: a jsdom mock cannot reproduce // Leaflet's event propagation or its pane stacking, so the tests assert the // options that govern them instead. Polyline: ({ positions, pathOptions, eventHandlers, bubblingMouseEvents, pane }: any) => (
eventHandlers?.click?.()} /> ), CircleMarker: () =>
, Circle: () =>
, Tooltip: ({ children }: any) => <>{children}, useMap: () => mapMock, useMapEvents: () => ({}), })) vi.mock('react-leaflet-cluster', () => ({ // The real cluster group calls iconCreateFunction itself; the probe button // lets a test invoke it with a chosen child count. default: ({ children, iconCreateFunction }: any) => (
), })) vi.mock('leaflet', () => { const divIcon = vi.fn((options: any) => ({ ...options })) const leaflet = { divIcon, Icon: { Default: { prototype: {}, mergeOptions: vi.fn() } }, latLngBounds: vi.fn(() => ({ isValid: () => true })), point: vi.fn((x: number, y: number) => [x, y]), } return { default: leaflet, ...leaflet } }) vi.mock('../../services/photoService', () => ({ getCached: vi.fn(() => null), isLoading: vi.fn(() => false), fetchPhoto: vi.fn(), onThumbReady: vi.fn((key: string, cb: (thumb: string) => void) => { thumbCallbacks.set(key, cb) return () => { thumbCallbacks.delete(key) } }), getAllThumbs: vi.fn(() => ({})), })) import { MapView } from './MapView' // Helper: build a place with the extra fields MapView uses (category_name/color/icon) // that exist on joined DB rows but are not in the base Place TypeScript type. function buildMapPlace(overrides: Record = {}) { return { ...buildPlace(), category_name: null, category_color: null, category_icon: null, ...overrides, } as any } const ORIGINAL_WIDTH = window.innerWidth afterEach(() => { vi.clearAllMocks() resetAllStores() mapMock.panes.clear() thumbCallbacks.clear() geoMock.position = null geoMock.mode = 'off' geoMock.error = null Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: ORIGINAL_WIDTH }) }) describe('MapView', () => { it('FE-COMP-MAPVIEW-001: renders map container', () => { render() expect(screen.getByTestId('map-container')).toBeTruthy() }) it('FE-COMP-MAPVIEW-002: renders one marker per place', () => { const places = [ buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }), buildMapPlace({ id: 2, name: 'Louvre', lat: 48.86, lng: 2.337 }), ] render() expect(screen.getAllByTestId('marker').length).toBe(2) }) it('FE-COMP-MAPVIEW-003: marker click calls onMarkerClick with place id', () => { const onMarkerClick = vi.fn() const places = [buildMapPlace({ id: 42, lat: 48.8584, lng: 2.2945 })] render() fireEvent.click(screen.getByTestId('marker')) expect(onMarkerClick).toHaveBeenCalledWith(42) }) it('FE-COMP-MAPVIEW-004: tooltip shows place name', async () => { const user = userEvent.setup() const places = [buildMapPlace({ name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945 })] render() await user.click(screen.getByTestId('marker-hover-trigger')) expect(screen.getByTestId('tooltip').textContent).toContain('Eiffel Tower') }) it('FE-COMP-MAPVIEW-005: tooltip shows category name when present', async () => { const user = userEvent.setup() const places = [ buildMapPlace({ name: 'Louvre', lat: 48.86, lng: 2.337, category_name: 'Museum', category_icon: null }), ] render() await user.click(screen.getByTestId('marker-hover-trigger')) expect(screen.getByTestId('tooltip').textContent).toContain('Museum') }) it('FE-COMP-MAPVIEW-006: renders polyline when route has 2+ points', () => { render() // Apple-Maps style draws a casing + a core line per segment. expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0) }) it('FE-COMP-MAPVIEW-007: does not render polyline when route is null', () => { render() expect(screen.queryByTestId('polyline')).toBeNull() }) it('FE-COMP-MAPVIEW-008: does not render polyline for single-point route', () => { render() expect(screen.queryByTestId('polyline')).toBeNull() }) it('FE-COMP-MAPVIEW-009: GPX geometry polyline rendered for place with route_geometry', () => { const places = [ buildMapPlace({ lat: 48.0, lng: 2.0, route_geometry: '[[48.0,2.0],[49.0,3.0]]' }), ] render() // Three per track: casing, the visible line, and the invisible fat one // that catches clicks. The casing is always mounted so that toggling a // colour never remounts a path into the wrong stacking position. expect(screen.getAllByTestId('polyline').length).toBe(3) }) it('FE-COMP-MAPVIEW-010: MarkerClusterGroup is rendered', () => { const places = [buildMapPlace({ lat: 48.8584, lng: 2.2945 })] render() expect(screen.getByTestId('cluster-group')).toBeTruthy() }) it('FE-COMP-MAPVIEW-010b: the map carries a zoom ceiling of its own, whatever the basemap is', () => { // Without it, a vector basemap leaves the map with none and the cluster // above throws on attach, taking the planner to the error boundary. The // ceiling has to sit on the map because only a GridLayer can contribute one. render() expect(screen.getByTestId('map-container').getAttribute('data-maxzoom')).toBe(String(MAP_MAX_ZOOM)) }) it('FE-COMP-MAPVIEW-011: renders the route polyline; travel times are no longer drawn on the map', () => { const route = [[[48.0, 2.0], [49.0, 3.0]]] as unknown as [number, number][][] render() // The route is drawn; per-segment times now live in the day sidebar, not on the map. expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0) }) it('FE-COMP-MAPVIEW-012: invalid route_geometry JSON triggers catch and skips polyline', () => { const places = [ buildMapPlace({ lat: 48.0, lng: 2.0, route_geometry: 'NOT_VALID_JSON' }), ] // Should not throw; invalid JSON is caught silently render() expect(screen.queryByTestId('polyline')).toBeNull() }) it('FE-COMP-MAPVIEW-013: route_geometry with fewer than 2 coords skips polyline', () => { const places = [ buildMapPlace({ lat: 48.0, lng: 2.0, route_geometry: '[[48.0,2.0]]' }), ] render() expect(screen.queryByTestId('polyline')).toBeNull() }) // ── Track colours (#776) ────────────────────────────────────────────────── // The style has to travel through pathOptions: react-leaflet only calls // setStyle when that object's reference changes, so bare color/weight props // would freeze at their mount-time value and a recolour would never show. const trackOptions = () => screen.getAllByTestId('polyline') .map(el => JSON.parse(el.getAttribute('data-path-options') || 'null')) .filter(Boolean) it('FE-COMP-MAPVIEW-025: a picked route_color beats the category colour', () => { const places = [buildMapPlace({ lat: 48.0, lng: 2.0, route_geometry: '[[48.0,2.0],[49.0,3.0]]', category_color: '#00ff00', route_color: '#e11d48', })] render() expect(trackOptions().some(o => o.color === '#e11d48')).toBe(true) expect(trackOptions().some(o => o.color === '#00ff00')).toBe(false) }) it('FE-COMP-MAPVIEW-026: without route_color the category colour still wins', () => { const places = [buildMapPlace({ lat: 48.0, lng: 2.0, route_geometry: '[[48.0,2.0],[49.0,3.0]]', category_color: '#00ff00', route_color: null, })] render() expect(trackOptions().some(o => o.color === '#00ff00')).toBe(true) }) it('FE-COMP-MAPVIEW-027: with neither colour the track keeps the old blue', () => { const places = [buildMapPlace({ lat: 48.0, lng: 2.0, route_geometry: '[[48.0,2.0],[49.0,3.0]]' })] render() expect(trackOptions().some(o => o.color === '#3b82f6' && o.weight === 3.5 && o.opacity === 0.75)).toBe(true) // The casing is mounted but invisible — a track nobody coloured looks // exactly as it did before. expect(trackOptions().some(o => o.color === '#ffffff' && o.opacity === 0)).toBe(true) expect(trackOptions().some(o => o.color === '#ffffff' && o.opacity > 0)).toBe(false) }) it('FE-COMP-MAPVIEW-028: a coloured track gets a white casing underneath', () => { const places = [buildMapPlace({ lat: 48.0, lng: 2.0, route_geometry: '[[48.0,2.0],[49.0,3.0]]', route_color: '#059669', })] render() expect(trackOptions().some(o => o.color === '#ffffff' && o.weight === 6.5 && o.opacity === 0.7)).toBe(true) expect(trackOptions().some(o => o.color === '#059669' && o.opacity === 0.9)).toBe(true) }) it('FE-COMP-MAPVIEW-029: clicking a track selects its place', () => { const onMarkerClick = vi.fn() const places = [buildMapPlace({ id: 77, lat: 48.0, lng: 2.0, route_geometry: '[[48.0,2.0],[49.0,3.0]]', })] render() const hit = screen.getAllByTestId('polyline') .find(el => JSON.parse(el.getAttribute('data-path-options') || '{}').weight === 14) fireEvent.click(hit!) expect(onMarkerClick).toHaveBeenCalledWith(77) // Paths bubble to the map by default and the map click clears the selection // this one just made — the mock cannot reproduce that, so assert the option. expect(hit!.getAttribute('data-bubbling')).toBe('false') }) it('FE-COMP-MAPVIEW-014: marker icon uses base64 image_url for photo places', () => { const dataUrl = 'data:image/jpeg;base64,/9j/4AA' const places = [buildMapPlace({ id: 10, lat: 48.0, lng: 2.0, image_url: dataUrl })] render() // Marker still renders; base64 path in createPlaceIcon should be exercised expect(screen.getByTestId('marker')).toBeTruthy() }) it('FE-COMP-MAPVIEW-015: uses cached photo thumb from photoService when available', () => { vi.mocked(photoService.getCached).mockReturnValue({ thumbDataUrl: 'data:image/jpeg;base64,abc' } as any) const places = [ buildMapPlace({ id: 20, lat: 48.0, lng: 2.0, google_place_id: 'gplace_123' }), ] render() expect(screen.getByTestId('marker')).toBeTruthy() vi.mocked(photoService.getCached).mockReturnValue(null) }) it('FE-COMP-MAPVIEW-016: tooltip shows address when present', async () => { const user = userEvent.setup() const places = [ buildMapPlace({ name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945, address: '5 Av. Anatole France' }), ] render() await user.click(screen.getByTestId('marker-hover-trigger')) expect(screen.getByTestId('tooltip').textContent).toContain('5 Av. Anatole France') }) it('FE-COMP-MAPVIEW-017: renders selected marker with higher z-index offset', () => { const places = [ buildMapPlace({ id: 5, lat: 48.8584, lng: 2.2945 }), ] render() expect(screen.getByTestId('marker')).toBeTruthy() }) it('FE-COMP-MAPVIEW-018: changing selectedPlaceId/hasInspector does not refit bounds (issue #921)', () => { const places = [ buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }), buildMapPlace({ id: 2, lat: 48.86, lng: 2.337 }), ] const { rerender } = render() const initialCount = mapMock.fitBounds.mock.calls.length // Toggle selectedPlaceId on — mimics opening place inspector (hasInspector flips, // paddingOpts memo creates new object). fitBounds must NOT fire again. rerender() expect(mapMock.fitBounds).toHaveBeenCalledTimes(initialCount) // Toggle selectedPlaceId off — mimics closing inspector via X button. rerender() expect(mapMock.fitBounds).toHaveBeenCalledTimes(initialCount) }) it('FE-COMP-MAPVIEW-019: bumping fitKey triggers a new fitBounds call', () => { const places = [ buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }), ] const { rerender } = render() const afterFirst = mapMock.fitBounds.mock.calls.length rerender() expect(mapMock.fitBounds.mock.calls.length).toBeGreaterThan(afterFirst) }) it('FE-COMP-MAPVIEW-021: clicking a marker clears the hover tooltip (#1404)', async () => { const user = userEvent.setup() const places = [buildMapPlace({ id: 3, name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945 })] render() await user.click(screen.getByTestId('marker-hover-trigger')) expect(screen.getByTestId('tooltip')).toBeTruthy() // The recenter that follows the click moves the marker out from under the // cursor — no mouseout will ever fire, so the click itself must clear. fireEvent.click(screen.getByTestId('marker')) expect(screen.queryByTestId('tooltip')).toBeNull() }) it('FE-COMP-MAPVIEW-022: camera movement clears the tooltip and suppresses re-show until it ends (#1404)', async () => { const user = userEvent.setup() const places = [buildMapPlace({ id: 4, name: 'Louvre', lat: 48.86, lng: 2.337 })] render() await user.click(screen.getByTestId('marker-hover-trigger')) expect(screen.getByTestId('tooltip')).toBeTruthy() const findHandler = (event: string) => mapMock.on.mock.calls.find(c => c[0] === event)?.[1] as (() => void) | undefined const start = findHandler('movestart zoomstart') const end = findHandler('moveend zoomend') expect(start).toBeTypeOf('function') expect(end).toBeTypeOf('function') fireEvent.click(screen.getByTestId('marker-hover-trigger')) // ensure hover is showing start!() await waitFor(() => expect(screen.queryByTestId('tooltip')).toBeNull()) // during the pan animation a mouseover must not re-show the card fireEvent.click(screen.getByTestId('marker-hover-trigger')) expect(screen.queryByTestId('tooltip')).toBeNull() // once the move ends, hover works again end!() await user.click(screen.getByTestId('marker-hover-trigger')) expect(screen.getByTestId('tooltip')).toBeTruthy() }) it('FE-COMP-MAPVIEW-020: a day fit expands to include the route once it arrives (#1128)', async () => { const L = ((await import('leaflet')).default) as unknown as { latLngBounds: ReturnType } const dayPlaces = [ buildMapPlace({ id: 1, lat: 48.0, lng: 2.0 }), buildMapPlace({ id: 2, lat: 48.1, lng: 2.1 }), ] // The map opens already framed on its places, so nothing fits on mount. const { rerender } = render() const lastBounds = () => { const c = L.latLngBounds.mock.calls; return c[c.length - 1][0] } // Day selected, route not computed yet → first fit is the two destinations. L.latLngBounds.mockClear() rerender() expect(lastBounds()).toHaveLength(2) // The day's route arrives → one-shot re-fit including the 3 route points. L.latLngBounds.mockClear() rerender() expect(L.latLngBounds).toHaveBeenCalled() expect(lastBounds()).toHaveLength(5) // 2 destinations + 3 route points }) describe('opening camera', () => { const camera = () => { const el = screen.getByTestId('map-container') return { center: JSON.parse(el.getAttribute('data-center')!) as [number, number], zoom: Number(el.getAttribute('data-zoom')), } } it('FE-COMP-MAPVIEW-021: builds the map framed on the places', () => { render() const { center, zoom } = camera() expect(center[0]).toBeCloseTo(34.85, 1) expect(center[1]).toBeCloseTo(135.63, 1) expect(zoom).toBeGreaterThan(7) expect(zoom).toBeLessThan(13) }) it('FE-COMP-MAPVIEW-022: does not fit on mount when it opened already framed', async () => { const L = ((await import('leaflet')).default) as unknown as { latLngBounds: ReturnType } L.latLngBounds.mockClear() render() expect(L.latLngBounds).not.toHaveBeenCalled() }) it('FE-COMP-MAPVIEW-023: falls back to the world view when no place has coordinates', () => { render() const { center, zoom } = camera() expect(center).toEqual([0, 0]) expect(zoom).toBe(2) }) }) it('FE-COMP-MAPVIEW-023: a routable reservation not in visibleConnectionIds draws no route', () => { const reservation = buildReservation({ id: 43, type: 'flight', endpoints: [ { role: 'from', sequence: 0, name: 'A', code: 'AAA', lat: 1, lng: 2, timezone: null, local_time: null, local_date: null }, { role: 'to', sequence: 1, name: 'B', code: 'BBB', lat: 3, lng: 4, timezone: null, local_time: null, local_date: null }, ], } as any) render() expect(screen.queryByTestId('polyline')).not.toBeInTheDocument() }) it('FE-COMP-MAPVIEW-024: a routable reservation in visibleConnectionIds draws its route', () => { const reservation = buildReservation({ id: 42, type: 'flight', endpoints: [ { role: 'from', sequence: 0, name: 'A', code: 'AAA', lat: 1, lng: 2, timezone: null, local_time: null, local_date: null }, { role: 'to', sequence: 1, name: 'B', code: 'BBB', lat: 3, lng: 4, timezone: null, local_time: null, local_date: null }, ], } as any) render() expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0) }) it('FE-COMP-MAPVIEW-073: a category icon that throws leaves the marker circle intact', () => { // Icon components come from a lookup table a plugin can extend; one that // throws must not take the whole map down, only its own glyph. const key = '__ExplodingIcon' CATEGORY_ICON_MAP[key] = (() => { throw new Error('icon blew up') }) as unknown as typeof CATEGORY_ICON_MAP['MapPin'] try { render() const html = screen.getByTestId('marker').getAttribute('data-icon-html') || '' expect(html).toContain('border-radius:50%') expect(html).not.toContain(' ((await import('leaflet')).default) as unknown as { divIcon: ReturnType latLngBounds: ReturnType } const iconHtmlOf = (el: HTMLElement) => el.getAttribute('data-icon-html') || '' const markersWithZ = (z: string) => screen.queryAllByTestId('marker').filter(m => m.getAttribute('data-zindex') === z) const handlerFor = (event: string) => mapMock.on.mock.calls.find(c => c[0] === event)?.[1] as (() => void) | undefined function buildPoi(overrides: Record = {}) { return { osm_id: 'node/1', name: 'Café Central', lat: 48.21, lng: 16.36, category: 'cafe', poi_type: 'cafe', address: null, website: null, phone: null, opening_hours: null, cuisine: null, source: 'openstreetmap', ...overrides, } } describe('MapView clusters and badges', () => { it('FE-COMP-MAPVIEW-030: the cluster icon grows with the number of markers it hides', () => { render() const probe = screen.getByTestId('cluster-icon-probe') const iconFor = (count: number) => { probe.setAttribute('data-count', String(count)) fireEvent.click(probe) return probe.getAttribute('data-icon-html') || '' } expect(iconFor(4)).toContain('width:36px') expect(iconFor(4)).toContain('4') expect(iconFor(20)).toContain('width:42px') expect(iconFor(120)).toContain('width:48px') }) it('FE-COMP-MAPVIEW-031: a place on one day gets a single order badge', () => { render() const html = iconHtmlOf(screen.getAllByTestId('marker')[0]) expect(html).toContain('>2') expect(html).toContain('height:18px') }) it('FE-COMP-MAPVIEW-032: a place visited on several days gets a wider combined badge', () => { render() const html = iconHtmlOf(screen.getAllByTestId('marker')[0]) expect(html).toContain('>1 · 3') expect(html).toContain('height:16px') }) }) describe('MapView explore POIs', () => { it('FE-COMP-MAPVIEW-033: renders a pin per POI in its category colour with a name tooltip', () => { render() const poiMarker = markersWithZ('500')[0] expect(poiMarker).toBeTruthy() expect(iconHtmlOf(poiMarker)).toContain('#B45309') // cafe expect(poiMarker.textContent).toContain('Café Central') }) it('FE-COMP-MAPVIEW-034: clicking a POI hands the whole POI back to the caller', () => { const onPoiClick = vi.fn() const poi = buildPoi({ osm_id: 'node/7' }) render() fireEvent.click(markersWithZ('500')[0]) expect(onPoiClick).toHaveBeenCalledWith(poi) }) it('FE-COMP-MAPVIEW-035: an unknown POI category falls back to grey and draws no glyph', () => { render() const html = iconHtmlOf(markersWithZ('500')[0]) expect(html).toContain('#6b7280') expect(html).not.toContain(' { const L = await leafletMock() render() const poiIcons = L.divIcon.mock.calls.filter(c => JSON.stringify((c[0] as { iconSize: number[] }).iconSize) === '[26,26]') expect(markersWithZ('500')).toHaveLength(2) expect(poiIcons).toHaveLength(1) }) }) describe('MapView plugin route vias', () => { const via = (overrides: Record = {}) => ({ lat: 48.5, lng: 2.5, tone: 'default', ...overrides }) it('FE-COMP-MAPVIEW-037: draws a tone dot for each via point', () => { render() const vias = markersWithZ('800') expect(vias).toHaveLength(2) expect(iconHtmlOf(vias[0])).toContain('#10b981') expect(iconHtmlOf(vias[1])).toContain('#ef4444') }) it('FE-COMP-MAPVIEW-038: an unknown tone falls back to the default indigo', () => { render() expect(iconHtmlOf(markersWithZ('800')[0])).toContain('#4F46E5') }) it('FE-COMP-MAPVIEW-039: vias of the same tone share one cached icon', async () => { const L = await leafletMock() render() const viaIcons = L.divIcon.mock.calls.filter(c => JSON.stringify((c[0] as { iconSize: number[] }).iconSize) === '[13,13]') expect(markersWithZ('800')).toHaveLength(2) expect(viaIcons).toHaveLength(1) }) it('FE-COMP-MAPVIEW-040: a via tooltip joins its label and its dwell time', () => { render() expect(markersWithZ('800')[0].textContent).toContain('Supercharger · 1 h 30 min') }) it('FE-COMP-MAPVIEW-041: a dwell under an hour is shown in minutes alone', () => { render() expect(markersWithZ('800')[0].textContent).toContain('25 min') }) it('FE-COMP-MAPVIEW-042: a bare via carries no tooltip at all', () => { render() expect(markersWithZ('800')[0].textContent).toBe('') }) it('FE-COMP-MAPVIEW-074: a via with a label but no dwell shows the label alone', () => { render() expect(markersWithZ('800')[0].textContent).toBe('Rest area') }) }) describe('MapView map event wiring', () => { it('FE-COMP-MAPVIEW-043: reports the viewport bbox once the map is ready and again on every move', () => { const onViewportChange = vi.fn() render() expect(onViewportChange).toHaveBeenCalledWith({ south: 47, west: 1, north: 49, east: 3 }) const moveend = handlerFor('moveend') const zoomend = handlerFor('zoomend') expect(moveend).toBeTypeOf('function') moveend!() zoomend!() expect(onViewportChange).toHaveBeenCalledTimes(3) }) it('FE-COMP-MAPVIEW-044: no viewport callback means no listeners at all', () => { render() expect(mapMock.on.mock.calls.some(c => c[0] === 'moveend')).toBe(false) }) it('FE-COMP-MAPVIEW-045: unmounting detaches the viewport listeners', () => { const { unmount } = render() unmount() expect(mapMock.off).toHaveBeenCalledWith('moveend', expect.any(Function)) expect(mapMock.off).toHaveBeenCalledWith('zoomend', expect.any(Function)) }) it('FE-COMP-MAPVIEW-046: a map-click handler is attached and detached again', () => { const onMapClick = vi.fn() const { unmount } = render() expect(mapMock.on).toHaveBeenCalledWith('click', onMapClick) unmount() expect(mapMock.off).toHaveBeenCalledWith('click', onMapClick) }) it('FE-COMP-MAPVIEW-047: without a click handler nothing is bound', () => { render() expect(mapMock.on.mock.calls.some(c => c[0] === 'click')).toBe(false) }) it('FE-COMP-MAPVIEW-048: a context-menu handler is attached and detached again', () => { const onMapContextMenu = vi.fn() const { unmount } = render() expect(mapMock.on).toHaveBeenCalledWith('contextmenu', onMapContextMenu) unmount() expect(mapMock.off).toHaveBeenCalledWith('contextmenu', onMapContextMenu) }) it('FE-COMP-MAPVIEW-049: changing the center prop moves the camera there', () => { const places = [buildMapPlace({ id: 1, lat: 48, lng: 2 })] const { rerender } = render() mapMock.setView.mockClear() rerender() expect(mapMock.setView).toHaveBeenCalledWith([35.01, 135.76], 11) // The same center again must not re-issue the camera move. mapMock.setView.mockClear() rerender() expect(mapMock.setView).not.toHaveBeenCalled() }) }) describe('MapView track casing pane (#776)', () => { it('FE-COMP-MAPVIEW-050: creates the casing pane under the overlay pane and draws casings into it', () => { render() expect(mapMock.createPane).toHaveBeenCalledWith('trek-track-casing') expect(mapMock.panes.get('trek-track-casing')!.style.zIndex).toBe('398') const casing = screen.getAllByTestId('polyline') .find(el => JSON.parse(el.getAttribute('data-path-options') || '{}').weight === 6.5) expect(casing!.getAttribute('data-pane')).toBe('trek-track-casing') }) it('FE-COMP-MAPVIEW-072: a renderer without pane support still draws every track', () => { const paneless = mapMock as unknown as Record const { getPane, createPane } = paneless delete paneless.getPane delete paneless.createPane try { render() // Casing, line and hit area are all mounted — just in insertion order. expect(screen.getAllByTestId('polyline')).toHaveLength(3) const casing = screen.getAllByTestId('polyline') .find(el => JSON.parse(el.getAttribute('data-path-options') || '{}').weight === 6.5) expect(casing!.getAttribute('data-pane')).toBe('') } finally { paneless.getPane = getPane paneless.createPane = createPane } }) }) describe('MapView selection panning (#921)', () => { it('FE-COMP-MAPVIEW-051: pans to the padding-corrected point when the map can project', () => { const projectable = mapMock as unknown as Record const add = vi.fn(() => ({ x: 5, y: 6 })) projectable.project = vi.fn(() => ({ add })) projectable.unproject = vi.fn(() => [49, 4]) try { const places = [buildMapPlace({ id: 8, lat: 48, lng: 2 })] const { rerender } = render() rerender() expect(projectable.project).toHaveBeenCalledWith([48, 2]) // The offset is half the difference between the two padding corners. expect(add).toHaveBeenCalledWith([0, 0]) expect(mapMock.panTo).toHaveBeenCalledWith([49, 4], { animate: true }) } finally { delete projectable.project delete projectable.unproject } }) it('FE-COMP-MAPVIEW-052: falls back to a plain pan when the map cannot project', () => { const places = [buildMapPlace({ id: 9, lat: 48, lng: 2 })] const { rerender } = render() rerender() expect(mapMock.panTo).toHaveBeenCalledWith([48, 2], { animate: true }) }) it('FE-COMP-MAPVIEW-053: a selected place without coordinates is not panned to', () => { const places = [buildMapPlace({ id: 10, lat: null, lng: null })] const { rerender } = render() rerender() expect(mapMock.panTo).not.toHaveBeenCalled() }) }) describe('MapView hover card', () => { const place = () => buildMapPlace({ id: 12, name: 'Eiffel Tower', lat: 48.85, lng: 2.29 }) it('FE-COMP-MAPVIEW-054: mousemove keeps the card following the cursor', () => { render() fireEvent.click(screen.getByTestId('marker-move-trigger')) const card = screen.getByTestId('tooltip') expect(card.textContent).toContain('Eiffel Tower') expect(card.style.left).toBe('164px') // 150 + 14 expect(card.style.top).toBe('150px') // 160 - 10 }) it('FE-COMP-MAPVIEW-055: mouseout clears the card', () => { render() fireEvent.click(screen.getByTestId('marker-hover-trigger')) expect(screen.getByTestId('tooltip')).toBeTruthy() fireEvent.click(screen.getByTestId('marker-out-trigger')) expect(screen.queryByTestId('tooltip')).toBeNull() }) it('FE-COMP-MAPVIEW-056: scrolling the page drops the card so it cannot get orphaned', () => { render() fireEvent.click(screen.getByTestId('marker-hover-trigger')) expect(screen.getByTestId('tooltip')).toBeTruthy() fireEvent.scroll(window) expect(screen.queryByTestId('tooltip')).toBeNull() }) it('FE-COMP-MAPVIEW-057: hoverDisabled suppresses the card entirely', () => { render() fireEvent.click(screen.getByTestId('marker-hover-trigger')) fireEvent.click(screen.getByTestId('marker-move-trigger')) expect(screen.queryByTestId('tooltip')).toBeNull() }) }) describe('MapView live location', () => { const fix = (over: Record = {}) => ({ lat: 48.2, lng: 16.37, accuracy: 25, heading: null, ...over }) it('FE-COMP-MAPVIEW-058: no fix means no dot, circle or heading cone', () => { render() expect(screen.queryByTestId('circle-marker')).toBeNull() expect(screen.queryByTestId('circle')).toBeNull() }) it('FE-COMP-MAPVIEW-059: a fix draws the blue dot and its accuracy circle', () => { geoMock.position = fix() geoMock.mode = 'show' render() expect(screen.getByTestId('circle-marker')).toBeTruthy() expect(screen.getByTestId('circle')).toBeTruthy() }) it('FE-COMP-MAPVIEW-060: a city-level fix draws the dot but no accuracy circle', () => { geoMock.position = fix({ accuracy: 900 }) geoMock.mode = 'show' render() expect(screen.getByTestId('circle-marker')).toBeTruthy() expect(screen.queryByTestId('circle')).toBeNull() }) it('FE-COMP-MAPVIEW-061: a compass heading adds a rotated cone marker', () => { geoMock.position = fix({ heading: 135 }) geoMock.mode = 'show' render() const cone = screen.getAllByTestId('marker').find(m => iconHtmlOf(m).includes('rotate(135deg)')) expect(cone).toBeTruthy() }) it('FE-COMP-MAPVIEW-062: show mode recentres once and then leaves the camera alone', () => { geoMock.position = fix() geoMock.mode = 'show' const { rerender } = render() expect(mapMock.setView).toHaveBeenCalledWith([48.2, 16.37], 15) mapMock.setView.mockClear() geoMock.position = fix({ lat: 48.25 }) // A prop change forces the re-render the new fix would otherwise not cause. rerender() expect(mapMock.setView).not.toHaveBeenCalled() }) it('FE-COMP-MAPVIEW-063: follow mode recentres on every fix at navigation zoom', () => { geoMock.position = fix() geoMock.mode = 'follow' render() expect(mapMock.setView).toHaveBeenCalledWith([48.2, 16.37], 16, { animate: true, duration: 0.35 }) }) it('FE-COMP-MAPVIEW-064: a mobile viewport gets the location button, desktop does not', () => { render() expect(screen.queryByRole('button', { name: 'Show my location' })).toBeNull() Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 420 }) geoMock.mode = 'show' render() fireEvent.click(screen.getByRole('button', { name: 'Follow my location' })) expect(geoMock.cycleMode).toHaveBeenCalled() }) }) describe('MapView bounds fitting', () => { /* * The day fit used to compensate for the day panel twice: once through the * padding, and again 300ms later with panBy([0, 150]) (#1348). On a route * running north to south the fit is height-bound, so the northernmost stop * lands exactly on the top padding line and the nudge then pushed it off the * canvas — the map framed everything and then drifted upwards (#1982). * * So this case is the inverse of what it used to assert: the padding does the * work, and nothing moves the camera afterwards. */ it('FE-COMP-MAPVIEW-065: a day-detail fit reserves the panel in its padding and then leaves the camera alone (#1348, #1982)', async () => { const places = [ buildMapPlace({ id: 1, lat: 48, lng: 2 }), buildMapPlace({ id: 2, lat: 48.2, lng: 2.2 }), ] const { rerender } = render() rerender() expect(mapMock.fitBounds).toHaveBeenCalled() const calls = mapMock.fitBounds.mock.calls const opts = calls[calls.length - 1]?.[1] as { paddingBottomRight?: [number, number] } expect(opts?.paddingBottomRight?.[1]).toBe(280) // Long enough that the old 300ms nudge would have fired by now. await new Promise(r => setTimeout(r, 600)) expect(mapMock.panBy).not.toHaveBeenCalled() }) it('FE-COMP-MAPVIEW-065b: reserves only the small margin when no day panel is open (#1982)', () => { const places = [ buildMapPlace({ id: 1, lat: 48, lng: 2 }), buildMapPlace({ id: 2, lat: 48.2, lng: 2.2 }), ] const { rerender } = render() rerender() const calls = mapMock.fitBounds.mock.calls const opts = calls[calls.length - 1]?.[1] as { paddingBottomRight?: [number, number] } expect(opts?.paddingBottomRight?.[1]).toBe(60) }) it('FE-COMP-MAPVIEW-066: a fitKey that never changed is not re-fitted', async () => { const L = await leafletMock() L.latLngBounds.mockClear() render() expect(L.latLngBounds).not.toHaveBeenCalled() }) it('FE-COMP-MAPVIEW-067: an invalid bounds object is ignored instead of throwing', async () => { const L = await leafletMock() L.latLngBounds.mockImplementationOnce(() => ({ isValid: () => false })) const places = [buildMapPlace({ id: 1, lat: 48, lng: 2 })] const { rerender } = render() mapMock.fitBounds.mockClear() rerender() expect(mapMock.fitBounds).not.toHaveBeenCalled() }) }) describe('MapView photo thumbnails', () => { const THUMB = 'data:image/jpeg;base64,THUMBDATA' it('FE-COMP-MAPVIEW-068: a photo that arrives after mount is folded into the marker icon', async () => { render() const deliver = thumbCallbacks.get('gp-21') expect(deliver).toBeTypeOf('function') // Two deliveries in one frame collapse into a single re-render. act(() => { deliver!(THUMB); deliver!(THUMB) }) await waitFor(() => expect(iconHtmlOf(screen.getAllByTestId('marker')[0])).toContain(THUMB)) }) it('FE-COMP-MAPVIEW-069: re-delivering the same thumb does not change the icon', async () => { render() const deliver = thumbCallbacks.get('gp-22')! act(() => { deliver(THUMB) }) await waitFor(() => expect(iconHtmlOf(screen.getAllByTestId('marker')[0])).toContain(THUMB)) const before = iconHtmlOf(screen.getAllByTestId('marker')[0]) act(() => { deliver(THUMB) }) await waitFor(() => expect(iconHtmlOf(screen.getAllByTestId('marker')[0])).toBe(before)) }) it('FE-COMP-MAPVIEW-075: an unchanged batch keeps the previous photo state object', () => { // Same case as above, but with the batching frame under the test's control so // the "nothing changed" path in the state updater really runs before teardown. const frames: FrameRequestCallback[] = [] const flush = () => { frames.splice(0).forEach(cb => cb(0)) } vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => frames.push(cb)) vi.stubGlobal('cancelAnimationFrame', () => { frames.length = 0 }) try { render() const deliver = thumbCallbacks.get('gp-24')! act(() => { deliver(THUMB) }) act(flush) const withThumb = iconHtmlOf(screen.getAllByTestId('marker')[0]) expect(withThumb).toContain(THUMB) act(() => { deliver(THUMB) }) act(flush) expect(iconHtmlOf(screen.getAllByTestId('marker')[0])).toBe(withThumb) } finally { vi.unstubAllGlobals() } }) it('FE-COMP-MAPVIEW-070: a place with a custom uploaded image is never auto-fetched', () => { render() expect(thumbCallbacks.size).toBe(0) expect(vi.mocked(photoService.fetchPhoto)).not.toHaveBeenCalled() expect(iconHtmlOf(screen.getAllByTestId('marker')[0])).toContain('/uploads/places/mine.jpg') }) it('FE-COMP-MAPVIEW-076: a place with neither provider id nor coordinates has no cache key and is skipped', () => { render() expect(thumbCallbacks.size).toBe(0) expect(vi.mocked(photoService.fetchPhoto)).not.toHaveBeenCalled() }) it('FE-COMP-MAPVIEW-071: photos are not fetched at all when the feature is off', () => { useAuthStore.setState({ placesPhotosEnabled: false }) render() expect(thumbCallbacks.size).toBe(0) expect(vi.mocked(photoService.fetchPhoto)).not.toHaveBeenCalled() }) }) // The marker HTML is a hand-built string handed to L.divIcon, i.e. innerHTML. // Two of its interpolations carry values a user controls. describe('MapView — untrusted values in the marker HTML', () => { const iconHtml = () => screen.getAllByTestId('marker').map(el => el.getAttribute('data-icon-html') || '').join('') it('FE-COMP-MAPVIEW-072: a category colour that is not a hex value is dropped, not escaped', () => { // Escaping alone would stop the attribute breakout but still leave a working // CSS value, so the colour is allow-listed and anything else falls back. render( { render() expect(iconHtml()).toContain('#00ff00') }) })