import React from 'react' import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest' import { http, HttpResponse } from 'msw' import { render } from '../../../tests/helpers/render' import { act, fireEvent, waitFor } from '@testing-library/react' import { resetAllStores } from '../../../tests/helpers/store' import { buildPlace } from '../../../tests/helpers/factories' import { server } from '../../../tests/helpers/msw/server' import { useSettingsStore } from '../../store/settingsStore' import { useAuthStore } from '../../store/authStore' import maplibregl from 'maplibre-gl' import { DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults' import { MAP_LAYER_SWITCHER_INSET } from './MapLayerSwitcher' import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation' import type { PluginMapLayer, PluginMapLayerFeature, PluginMapMarker } from '../../api/client' import type { Poi } from './poiCategories' import type { RouteVia } from '../../types' // Stable fake map so fitBounds call counts survive re-renders. The canvas // container is a single element so listeners registered by the component are // reachable from tests via dispatchEvent. const glCanvasContainer = vi.hoisted(() => document.createElement('div')) const glMap = vi.hoisted(() => ({ on: vi.fn(), off: vi.fn(), once: vi.fn(), loaded: vi.fn().mockReturnValue(true), fitBounds: vi.fn(), flyTo: vi.fn(), jumpTo: vi.fn(), getZoom: vi.fn().mockReturnValue(10), addControl: vi.fn(), removeControl: vi.fn(), remove: vi.fn(), addSource: vi.fn(), getSource: vi.fn().mockReturnValue(null), addLayer: vi.fn(), // Every overlay that adds a layer takes it away again on cleanup; a real // MapLibre/Mapbox map has both halves, and a double with only the adding half // fails the teardown rather than the behaviour under test. removeLayer: vi.fn(), removeSource: vi.fn(), setLayoutProperty: vi.fn(), getStyle: vi.fn().mockReturnValue({ layers: [] }), isStyleLoaded: vi.fn().mockReturnValue(true), getCanvasContainer: vi.fn(() => glCanvasContainer), getLayer: vi.fn().mockReturnValue(null), queryRenderedFeatures: vi.fn().mockReturnValue([]), querySourceFeatures: vi.fn().mockReturnValue([]), unproject: vi.fn(() => ({ lng: 2.3522, lat: 48.8566 })), // The MapLibre place pins position themselves with this, so a mock without it // never exercises the path the real map takes. project: vi.fn((lngLat: [number, number]) => ({ x: lngLat[0] * 10, y: lngLat[1] * 10 })), getBounds: vi.fn(() => ({ getSouth: () => 0, getWest: () => 0, getNorth: () => 1, getEast: () => 1 })), easeTo: vi.fn(), getCanvas: vi.fn(() => document.createElement('canvas')), getBearing: vi.fn(() => 0), setTerrain: vi.fn(), setConfigProperty: vi.fn(), queryTerrainElevation: vi.fn((): number | null => null), })) const glBounds = vi.hoisted(() => { const state = { instances: [] as Array<{ extend: ReturnType }>, } return { get instances() { return state.instances }, clear: () => { state.instances = [] }, create: () => { const bounds = { extend: vi.fn(() => bounds), } state.instances.push(bounds) return bounds }, } }) // Markers and popups are created through the mocked GL constructors, so the // tests can reach the very instances the component built (their DOM element, // the coordinates they were placed at, the popup content they were given). interface FakeMarker { element: HTMLElement lngLat: number[] | null setLngLat: (ll: number[]) => FakeMarker getLngLat: () => { lng: number; lat: number } addTo: (map: unknown) => FakeMarker remove: () => void getElement: () => HTMLElement } interface FakePopup { setLngLat: (ll: unknown) => FakePopup setHTML: (html: string) => FakePopup setText: (text: string) => FakePopup setDOMContent: (node: HTMLElement) => FakePopup addTo: (map: unknown) => FakePopup remove: () => void } const glMarkers = vi.hoisted(() => { const created: FakeMarker[] = [] return { get created() { return created }, clear: () => { created.length = 0 }, make: (opts?: { element?: HTMLElement }): FakeMarker => { const element = opts?.element ?? document.createElement('div') const marker: FakeMarker = { element, lngLat: null, setLngLat: vi.fn((ll: number[]) => { marker.lngLat = ll; return marker }), getLngLat: vi.fn(() => ({ lng: Number(marker.lngLat?.[0] ?? 0), lat: Number(marker.lngLat?.[1] ?? 0) })), addTo: vi.fn((_map: unknown) => marker), remove: vi.fn(), getElement: vi.fn(() => element), } created.push(marker) return marker }, } }) const glPopup = vi.hoisted((): FakePopup => { const popup: FakePopup = { setLngLat: vi.fn((_ll: unknown) => popup), setHTML: vi.fn((_html: string) => popup), setText: vi.fn((_text: string) => popup), setDOMContent: vi.fn((_node: HTMLElement) => popup), addTo: vi.fn((_map: unknown) => popup), remove: vi.fn(), } return popup }) vi.mock('mapbox-gl', () => ({ default: { accessToken: '', Map: vi.fn(function () { return glMap }), Marker: vi.fn(function (opts?: { element?: HTMLElement }) { return glMarkers.make(opts) }), LngLatBounds: vi.fn(function () { return glBounds.create() }), NavigationControl: vi.fn(), Popup: vi.fn(function () { return glPopup }), }, })) vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({})) vi.mock('maplibre-gl', () => ({ default: { Map: vi.fn(function () { return glMap }), Marker: vi.fn(function (opts?: { element?: HTMLElement }) { return glMarkers.make(opts) }), LngLatBounds: vi.fn(function () { return glBounds.create() }), NavigationControl: vi.fn(), Popup: vi.fn(function () { return glPopup }), }, })) vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({})) vi.mock('./mapboxSetup', () => ({ isStandardFamily: vi.fn(() => false), supportsCustom3d: vi.fn(() => false), wantsTerrain: vi.fn(() => false), addCustom3dBuildings: vi.fn(), addTerrainAndSky: vi.fn(), })) const locationMarker = vi.hoisted(() => ({ update: vi.fn(), destroy: vi.fn() })) vi.mock('./locationMarkerMapbox', () => ({ attachLocationMarker: vi.fn(() => locationMarker), })) const reservationOverlay = vi.hoisted(() => ({ update: vi.fn((_reservations: unknown, _opts: unknown, _routes: unknown) => {}), destroy: vi.fn(), })) vi.mock('./reservationsMapbox', () => ({ ReservationMapboxOverlay: vi.fn(function () { return reservationOverlay }), })) const geoStub = vi.hoisted(() => ({ position: null as GeoPosition | null, mode: 'off' as TrackingMode, error: null as string | null, errorCode: null as string | null, cycleMode: vi.fn(async () => {}), setMode: vi.fn((_m: TrackingMode | ((prev: TrackingMode) => TrackingMode)) => {}), })) vi.mock('../../hooks/useGeolocation', () => ({ useGeolocation: vi.fn(() => geoStub), })) vi.mock('../../services/photoService', () => ({ getCached: vi.fn(() => null), isLoading: vi.fn(() => false), fetchPhoto: vi.fn(), onThumbReady: vi.fn(() => () => {}), getAllThumbs: vi.fn(() => ({})), })) import mapboxgl from 'mapbox-gl' import { MapViewGL as MapViewGLWithEngine } from './MapViewGL' // The engine is a prop now, not a module import — that is what keeps mapbox-gl and // maplibre-gl in separate chunks. This shim preserves the suite's existing call // shape and makes the same choice glLazy.tsx makes in production, so the vi.mock // factories below still stand in for the right SDK. // eslint-disable-next-line @typescript-eslint/no-explicit-any function MapViewGL(props: any) { return } import * as mapboxSetup from './mapboxSetup' import * as photoService from '../../services/photoService' import { ReservationMapboxOverlay } from './reservationsMapbox' function buildMapPlace(overrides: Record = {}) { return { ...buildPlace(), category_name: null, category_color: null, category_icon: null, ...overrides, } as any } beforeEach(() => { glMap.on.mockImplementation(() => glMap) glMap.off.mockImplementation(() => glMap) glMap.once.mockImplementation(() => glMap) glMap.loaded.mockReturnValue(true) glMap.getSource.mockReturnValue(null) glMap.getLayer.mockReturnValue(null) glMap.queryRenderedFeatures.mockReturnValue([]) glMap.querySourceFeatures.mockReturnValue([]) glMap.getCanvas.mockImplementation(() => document.createElement('canvas')) glMap.getBearing.mockReturnValue(0) glMap.queryTerrainElevation.mockReturnValue(null) // A busy style, a refused source and a custom layer list are each set by one test // for the case it covers. Put back here, or every test after it would silently run // against a map that never settles. glMap.isStyleLoaded.mockReturnValue(true) glMap.getStyle.mockReturnValue({ layers: [] }) glMap.addSource.mockImplementation(() => undefined) glMarkers.clear() // clearAllMocks() wipes call history but keeps implementations, so anything a // test overrides with mockReturnValue has to be put back here. vi.mocked(mapboxSetup.isStandardFamily).mockReturnValue(false) vi.mocked(mapboxSetup.supportsCustom3d).mockReturnValue(false) vi.mocked(mapboxSetup.wantsTerrain).mockReturnValue(false) vi.mocked(photoService.getCached).mockReturnValue(undefined) vi.mocked(photoService.isLoading).mockReturnValue(false) vi.mocked(photoService.onThumbReady).mockReturnValue(() => {}) vi.mocked(photoService.getAllThumbs).mockReturnValue({}) geoStub.position = null geoStub.mode = 'off' geoStub.error = null useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, map_provider: 'mapbox-gl', mapbox_access_token: 'pk.test_token', mapbox_style: 'mapbox://styles/mapbox/streets-v12', mapbox_3d_enabled: false, }, } as any) }) afterEach(() => { vi.clearAllMocks() glBounds.clear() resetAllStores() }) describe('MapViewGL', () => { it('FE-COMP-MAPVIEWGL-001: opening place inspector does not refit bounds (issue #921)', async () => { const places = [ buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }), buildMapPlace({ id: 2, lat: 48.86, lng: 2.337 }), ] const { rerender } = render( , ) await act(async () => {}) const after_initial = glMap.fitBounds.mock.calls.length // Selecting a place flips hasInspector → paddingOpts memo changes. // fitBounds must NOT fire again (this was the bug). rerender( , ) await act(async () => {}) expect(glMap.fitBounds).toHaveBeenCalledTimes(after_initial) }) it('FE-COMP-MAPVIEWGL-002: closing inspector does not refit bounds (issue #921)', async () => { const places = [ buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }), ] const { rerender } = render( , ) await act(async () => {}) const after_initial = glMap.fitBounds.mock.calls.length // Closing inspector (X button) clears selectedPlaceId → hasInspector=false → new paddingOpts. rerender( , ) await act(async () => {}) expect(glMap.fitBounds).toHaveBeenCalledTimes(after_initial) }) it('FE-COMP-MAPVIEWGL-003: bumping fitKey triggers a new fitBounds call', async () => { const places = [ buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }), ] const { rerender } = render() await act(async () => {}) const after_first = glMap.fitBounds.mock.calls.length rerender() await act(async () => {}) expect(glMap.fitBounds.mock.calls.length).toBeGreaterThan(after_first) }) it('FE-COMP-MAPVIEWGL-004: renders with the MapLibre provider and no token', async () => { const mapboxgl = (await import('mapbox-gl')).default const maplibregl = (await import('maplibre-gl')).default useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, map_provider: 'maplibre-gl', mapbox_access_token: '', // MapLibre/OpenFreeMap is tokenless — must not short-circuit maplibre_style: 'https://tiles.openfreemap.org/styles/liberty', }, } as any) const places = [buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 })] render() await act(async () => {}) // The MapLibre engine builds the map even without a token; Mapbox is not used. expect(maplibregl.Map).toHaveBeenCalled() expect(mapboxgl.Map).not.toHaveBeenCalled() }) it('FE-COMP-MAPVIEWGL-014: MapLibre maps disable the around-center mouse rotate (#1545)', async () => { const mapboxgl = (await import('mapbox-gl')).default const maplibregl = (await import('maplibre-gl')).default useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, map_provider: 'maplibre-gl', mapbox_access_token: '', maplibre_style: 'https://tiles.openfreemap.org/styles/liberty', }, } as any) const places = [buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 })] render() await act(async () => {}) // MapLibre 5's around-center rotate reverses direction at a drifting // mid-screen line, so the map must opt out of it. expect((maplibregl.Map as any).mock.calls[0][0]).toMatchObject({ aroundCenter: false }) vi.clearAllMocks() useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, map_provider: 'mapbox-gl', mapbox_access_token: 'pk.test_token', }, } as any) render() await act(async () => {}) // mapbox-gl has no such option — it must not receive the stray key. expect((mapboxgl.Map as any).mock.calls[0][0]).not.toHaveProperty('aroundCenter') }) it('FE-COMP-MAPVIEWGL-005: adds the clustered place source + layers so markers group on zoom-out (#1385)', async () => { glMap.on.mockImplementation((event: string, handlerOrLayer: unknown) => { if (event === 'load' && typeof handlerOrLayer === 'function') (handlerOrLayer as () => void)() return glMap }) useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, map_provider: 'maplibre-gl', mapbox_access_token: '', maplibre_style: 'https://tiles.openfreemap.org/styles/liberty', }, } as any) render() await act(async () => {}) expect(glMap.addSource).toHaveBeenCalledWith('trip-place-clusters', expect.objectContaining({ type: 'geojson', cluster: true, clusterRadius: 20, clusterMaxZoom: 8, })) expect(glMap.addLayer).toHaveBeenCalledWith(expect.objectContaining({ id: 'trip-place-clusters-circle' })) expect(glMap.addLayer).toHaveBeenCalledWith(expect.objectContaining({ id: 'trip-place-clusters-count' })) }) it('FE-COMP-MAPVIEWGL-070: MapLibre place pins are positioned from the map render, not from pointer moves', async () => { glMap.on.mockImplementation((event: string, handlerOrLayer: unknown) => { if (event === 'load' && typeof handlerOrLayer === 'function') (handlerOrLayer as () => void)() return glMap }) useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, map_provider: 'maplibre-gl', mapbox_access_token: '', maplibre_style: 'https://tiles.openfreemap.org/styles/liberty', }, } as any) render() await act(async () => {}) // The pin sits in its own layer inside the canvas container, not in one of the // library's marker wrappers — those follow 'move', which fires per pointer sample. const layer = Array.from(glCanvasContainer.children).find( c => c instanceof HTMLElement && c.style.pointerEvents === 'none', ) as HTMLElement | undefined expect(layer).toBeTruthy() expect(layer!.children).toHaveLength(1) const pin = layer!.children[0] as HTMLElement // project() is mocked as lng*10 / lat*10. expect(pin.style.transform).toContain('translate(20px, 30px)') // Moving the camera and drawing a frame moves the pin with it. glMap.project.mockReturnValue({ x: 90, y: 70 }) const render1 = glMap.on.mock.calls.find(c => c[0] === 'render')?.[1] as (() => void) | undefined expect(render1).toBeTypeOf('function') act(() => { render1!() }) expect(pin.style.transform).toContain('translate(90px, 70px)') }) function touchEvent(type: string, touches: Array<{ clientX: number; clientY: number }>) { const ev = new Event(type, { bubbles: true }) Object.defineProperty(ev, 'touches', { value: touches }) return ev } it('FE-COMP-MAPVIEWGL-006: touch long-press opens Add-Place at the held position (#1398)', async () => { vi.useFakeTimers() try { const onContext = vi.fn() render() await act(async () => {}) act(() => { glCanvasContainer.dispatchEvent(touchEvent('touchstart', [{ clientX: 30, clientY: 40 }])) vi.advanceTimersByTime(650) }) expect(onContext).toHaveBeenCalledTimes(1) expect(onContext.mock.calls[0][0].latlng).toEqual({ lat: 48.8566, lng: 2.3522 }) } finally { vi.useRealTimers() } }) it('FE-COMP-MAPVIEWGL-007: a moving finger (pan) cancels the long-press (#1398)', async () => { vi.useFakeTimers() try { const onContext = vi.fn() render() await act(async () => {}) act(() => { glCanvasContainer.dispatchEvent(touchEvent('touchstart', [{ clientX: 30, clientY: 40 }])) glCanvasContainer.dispatchEvent(touchEvent('touchmove', [{ clientX: 60, clientY: 90 }])) vi.advanceTimersByTime(650) }) expect(onContext).not.toHaveBeenCalled() } finally { vi.useRealTimers() } }) it('FE-COMP-MAPVIEWGL-008: a second finger (pinch) cancels the long-press (#1398)', async () => { vi.useFakeTimers() try { const onContext = vi.fn() render() await act(async () => {}) act(() => { glCanvasContainer.dispatchEvent(touchEvent('touchstart', [{ clientX: 30, clientY: 40 }])) glCanvasContainer.dispatchEvent(touchEvent('touchstart', [{ clientX: 30, clientY: 40 }, { clientX: 80, clientY: 40 }])) vi.advanceTimersByTime(650) }) expect(onContext).not.toHaveBeenCalled() } finally { vi.useRealTimers() } }) it('FE-COMP-MAPVIEWGL-009: a plain right-click (map contextmenu event) opens Add-Place, deduped (#1398)', async () => { const onContext = vi.fn() render() await act(async () => {}) const handler = glMap.on.mock.calls.find(c => c[0] === 'contextmenu')?.[1] as (e: unknown) => void expect(handler).toBeTypeOf('function') act(() => { handler({ lngLat: { lat: 48.8566, lng: 2.3522 }, originalEvent: new MouseEvent('contextmenu') }) // Android long-press fires the native contextmenu on top of our timer — // a second event inside the dedupe window must not open a second form. handler({ lngLat: { lat: 48.8566, lng: 2.3522 }, originalEvent: new MouseEvent('contextmenu') }) }) expect(onContext).toHaveBeenCalledTimes(1) expect(onContext.mock.calls[0][0].latlng).toEqual({ lat: 48.8566, lng: 2.3522 }) }) it('FE-COMP-MAPVIEWGL-012: a right-button rotate/pitch drag does not open Add-Place on release (#1398)', async () => { const onContext = vi.fn() render() await act(async () => {}) const handler = glMap.on.mock.calls.find(c => c[0] === 'contextmenu')?.[1] as (e: unknown) => void act(() => { // mapbox-gl (unlike maplibre) still emits contextmenu after a right-drag // on Windows — the movement guard must drop it. glCanvasContainer.dispatchEvent(new MouseEvent('mousedown', { button: 2, clientX: 10, clientY: 10, bubbles: true })) handler({ lngLat: { lat: 1, lng: 2 }, originalEvent: new MouseEvent('contextmenu', { clientX: 140, clientY: 90 }) }) }) expect(onContext).not.toHaveBeenCalled() // ...while a stationary right-click still fires. act(() => { glCanvasContainer.dispatchEvent(new MouseEvent('mousedown', { button: 2, clientX: 10, clientY: 10, bubbles: true })) handler({ lngLat: { lat: 1, lng: 2 }, originalEvent: new MouseEvent('contextmenu', { clientX: 11, clientY: 10 }) }) }) expect(onContext).toHaveBeenCalledTimes(1) }) it('FE-COMP-MAPVIEWGL-013: a stale long-press suppression never swallows a later real tap (#1398)', async () => { vi.useFakeTimers() try { const onContext = vi.fn() const onMapClick = vi.fn() render() await act(async () => {}) // Long-press fires (arms the suppression), but no click follows. act(() => { glCanvasContainer.dispatchEvent(touchEvent('touchstart', [{ clientX: 30, clientY: 40 }])) vi.advanceTimersByTime(650) }) expect(onContext).toHaveBeenCalledTimes(1) // The NEXT gesture starts fresh: its tap must reach the map click handler. const clickHandler = glMap.on.mock.calls.find(c => c[0] === 'click')?.[1] as (e: unknown) => void act(() => { glCanvasContainer.dispatchEvent(touchEvent('touchstart', [{ clientX: 80, clientY: 90 }])) glCanvasContainer.dispatchEvent(touchEvent('touchend', [])) clickHandler({ lngLat: { lat: 3, lng: 4 }, originalEvent: { target: glCanvasContainer } }) }) expect(onMapClick).toHaveBeenCalledWith({ latlng: { lat: 3, lng: 4 } }) } finally { vi.useRealTimers() } }) it('FE-COMP-MAPVIEWGL-010: middle-click still opens Add-Place (#1398 regression guard)', async () => { const onContext = vi.fn() render() await act(async () => {}) act(() => { glCanvasContainer.dispatchEvent(new MouseEvent('mousedown', { button: 1, bubbles: true })) }) expect(onContext).toHaveBeenCalledTimes(1) }) it('FE-COMP-MAPVIEWGL-011: clicking a marker clears the hover card; movestart clears + suppresses it (#1404)', async () => { // Markers are only reconciled once the style has loaded — fire 'load' like GL-005 does. glMap.on.mockImplementation((event: string, handlerOrLayer: unknown) => { if (event === 'load' && typeof handlerOrLayer === 'function') (handlerOrLayer as () => void)() return glMap }) const mapboxgl = (await import('mapbox-gl')).default const places = [buildMapPlace({ id: 7, lat: 48.8584, lng: 2.2945, name: 'Tour Eiffel' })] const { queryByTestId } = render() await act(async () => {}) const markerCall = (mapboxgl.Marker as unknown as ReturnType).mock.calls .find(c => c[0]?.element) expect(markerCall).toBeTruthy() const el = markerCall![0].element as HTMLElement // hover shows the card act(() => { el.dispatchEvent(new MouseEvent('mouseenter', { clientX: 10, clientY: 10 })) }) expect(queryByTestId('tooltip')).toBeTruthy() // click clears it (the flyTo that follows moves the marker away, no mouseleave will come) act(() => { el.dispatchEvent(new MouseEvent('click', { bubbles: false })) }) expect(queryByTestId('tooltip')).toBeNull() // hover again, then camera movement clears + suppresses act(() => { el.dispatchEvent(new MouseEvent('mouseenter', { clientX: 10, clientY: 10 })) }) expect(queryByTestId('tooltip')).toBeTruthy() const moveStart = glMap.on.mock.calls.find(c => c[0] === 'movestart')?.[1] as () => void const moveEnds = glMap.on.mock.calls.filter(c => c[0] === 'moveend').map(c => c[1] as () => void) act(() => { moveStart() }) expect(queryByTestId('tooltip')).toBeNull() // while the camera is moving, a re-fired mouseenter must not bring it back act(() => { el.dispatchEvent(new MouseEvent('mouseenter', { clientX: 10, clientY: 10 })) }) expect(queryByTestId('tooltip')).toBeNull() // after the move ends, hover works again act(() => { moveEnds.forEach(fn => fn()) }) act(() => { el.dispatchEvent(new MouseEvent('mouseenter', { clientX: 10, clientY: 10 })) }) expect(queryByTestId('tooltip')).toBeTruthy() }) // The map opens already framed on its places, so these exercise the fits that happen // afterwards — picking a day bumps fitKey. it('FE-COMP-MAPVIEWGL-014: fits bounds immediately even when MapLibre loaded() is false', async () => { glMap.loaded.mockReturnValue(false) const places = [ buildMapPlace({ id: 1, lat: 35.38, lng: 136.94 }), buildMapPlace({ id: 2, lat: 35.42, lng: 136.76 }), ] const { rerender } = render( , ) await act(async () => {}) rerender() await act(async () => {}) expect(glMap.fitBounds).toHaveBeenCalled() }) it('FE-COMP-MAPVIEWGL-015: fits MapLibre bounds to route geometry when it arrives after a day fit', async () => { const dayPlaces = [ buildMapPlace({ id: 1, lat: 35.38, lng: 136.94 }), buildMapPlace({ id: 2, lat: 35.42, lng: 136.76 }), ] // The day's route is drawn as straight lines in the same batch as the fit, then // upgraded to the real road geometry — which detours well outside the markers. const straightLines: [number, number][][] = [[[35.38, 136.94], [35.42, 136.76]]] const roadGeometry: [number, number][][] = [[[35.38, 136.94], [35.72, 137.51], [35.42, 136.76]]] const { rerender } = render( , ) await act(async () => {}) // Pick a day: fits the markers, with only the straight-line route to go on so far. rerender( , ) await act(async () => {}) const afterDayFit = glMap.fitBounds.mock.calls.length expect(afterDayFit).toBeGreaterThan(0) // The real geometry lands a moment later and the fit widens to take it in. rerender( , ) await act(async () => {}) expect(glMap.fitBounds.mock.calls.length).toBeGreaterThan(afterDayFit) const latestBounds = glBounds.instances[glBounds.instances.length - 1] expect(latestBounds.extend).toHaveBeenCalledWith([137.51, 35.72]) }) describe('opening camera', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const mapOptions = () => (maplibregl.Map as any).mock.calls.at(-1)[0] it('FE-COMP-MAPVIEWGL-017: builds the map framed on the places, in [lng, lat] order', async () => { const places = [ buildMapPlace({ id: 1, lat: 35.01, lng: 135.76 }), // Kyoto buildMapPlace({ id: 2, lat: 34.69, lng: 135.5 }), // Osaka ] render() await act(async () => {}) const { center, zoom } = mapOptions() // GL takes [lng, lat] — the swap is the easiest thing to get backwards here. expect(center[0]).toBeCloseTo(135.63, 1) expect(center[1]).toBeCloseTo(34.85, 1) // Framed on two cities ~30km apart: regional, not the world and not street level. expect(zoom).toBeGreaterThan(6) expect(zoom).toBeLessThan(12) }) it('FE-COMP-MAPVIEWGL-020: does not jump to the default centre on mount, undoing the framing', async () => { const places = [buildMapPlace({ id: 1, lat: 35.01, lng: 135.76 })] render() await act(async () => {}) // The centre prop is the world-view default nobody passed. Jumping to it on mount would // throw away the camera the map was just built with and land on Null Island at zoom 2. expect(glMap.jumpTo).not.toHaveBeenCalled() }) it('FE-COMP-MAPVIEWGL-018: does not fit on mount when it opened already framed', async () => { const places = [buildMapPlace({ id: 1, lat: 35.01, lng: 135.76 })] const { rerender } = render() await act(async () => {}) // Fitting would only re-do the framing, and its maxZoom would overrule the gentler // zoom a lone place opens at. expect(glMap.fitBounds).not.toHaveBeenCalled() // Picking a day still fits, as always. rerender() await act(async () => {}) expect(glMap.fitBounds).toHaveBeenCalled() }) it('FE-COMP-MAPVIEWGL-019: falls back to the world view when no place has coordinates', async () => { render( , ) await act(async () => {}) const { center, zoom } = mapOptions() expect(center).toEqual([0, 0]) expect(zoom).toBe(DEFAULT_MAP_ZOOM) }) }) it('FE-COMP-MAPVIEWGL-016: leaves the camera alone when a route appears long after the fit', async () => { const dayPlaces = [ buildMapPlace({ id: 1, lat: 35.38, lng: 136.94 }), buildMapPlace({ id: 2, lat: 35.42, lng: 136.76 }), ] const { rerender } = render( , ) await act(async () => {}) // Pick a day with the route toggle off: no route is pending for this fit. rerender( , ) await act(async () => {}) const afterDayFit = glMap.fitBounds.mock.calls.length expect(afterDayFit).toBeGreaterThan(0) // Much later the user pans away and turns the route on. That is not the geometry this // fit was waiting for, so the camera must stay put. rerender( , ) await act(async () => {}) expect(glMap.fitBounds.mock.calls.length).toBe(afterDayFit) }) // ── Track colours (#776) ────────────────────────────────────────────────── // The gpx layer paints from a per-feature property, so what matters is the // GeoJSON handed to setData. getSource returns null by default in this suite, // which makes the effect bail out — the source has to be stubbed per test. const gpxFeatures = () => { const gpxSource = { setData: vi.fn() } glMap.getSource.mockImplementation((id: string) => (id === 'trip-gpx' ? gpxSource : null)) return () => { const calls = gpxSource.setData.mock.calls return calls.length ? (calls[calls.length - 1][0]?.features ?? []) : [] } } it('FE-COMP-MAPVIEWGL-021: a picked route_color reaches the feature, casing on', async () => { const features = gpxFeatures() const places = [buildMapPlace({ id: 5, lat: 48, lng: 2, route_geometry: '[[48.0,2.0],[49.0,3.0]]', category_color: '#00ff00', route_color: '#e11d48', })] render() await act(async () => {}) expect(features()[0]?.properties).toMatchObject({ color: '#e11d48', cased: true, place_id: 5 }) }) it('FE-COMP-MAPVIEWGL-022: without a pick it stays on the category colour and skips the casing', async () => { const features = gpxFeatures() const places = [buildMapPlace({ id: 6, lat: 48, lng: 2, route_geometry: '[[48.0,2.0],[49.0,3.0]]', category_color: '#00ff00', })] render() await act(async () => {}) expect(features()[0]?.properties).toMatchObject({ color: '#00ff00', cased: false }) }) // ── Shared helpers for the effects below ────────────────────────────────── // Nothing beyond the bare map is wired up until the style reports 'load', so // most of the overlay work only runs once that handler has fired. function loadOnAttach() { glMap.on.mockImplementation((event: string, handlerOrLayer: unknown) => { if (event === 'load' && typeof handlerOrLayer === 'function') (handlerOrLayer as () => void)() return glMap }) } // Layer-scoped listeners are registered as (event, layerId, handler); // map-level ones as (event, handler). const layerHandler = (event: string, layerId: string) => glMap.on.mock.calls.find(c => c[0] === event && c[1] === layerId)?.[2] as (e?: unknown) => void const mapHandler = (event: string) => glMap.on.mock.calls.find(c => c[0] === event && typeof c[1] === 'function')?.[1] as (e?: unknown) => void interface GeoFeature { properties: Record geometry: { type: string; coordinates: number[] | number[][] | number[][][] } } const geoSource = () => ({ setData: vi.fn((_data: unknown) => {}) }) function lastData(src: ReturnType): { features: GeoFeature[] } { const calls = vi.mocked(src.setData).mock.calls return calls[calls.length - 1][0] as { features: GeoFeature[] } } // jsdom drives requestAnimationFrame off a timer, so the marker reconcile and // the batched photo updates only land after a short tick. const flushFrames = () => act(async () => { await new Promise(resolve => setTimeout(resolve, 40)) }) it('FE-COMP-MAPVIEWGL-023: draws a photo marker for an uploaded image and a category pin otherwise', async () => { loadOnAttach() const places = [ buildMapPlace({ id: 11, lat: 48.11, lng: 2.11, image_url: '/uploads/places/eiffel.jpg' }), buildMapPlace({ id: 12, lat: 48.12, lng: 2.12, category_color: '#ff0000', category_icon: 'Utensils' }), ] render() await act(async () => {}) const photoMarker = glMarkers.created[0].element expect(photoMarker.querySelector('img')?.getAttribute('src')).toBe('/uploads/places/eiffel.jpg') expect(photoMarker.textContent).toContain('2') const pinMarker = glMarkers.created[1].element expect(pinMarker.querySelector('img')).toBeNull() expect(pinMarker.querySelector('svg')).toBeTruthy() expect(pinMarker.innerHTML).toContain('#ff0000') // A place that appears on several days lists all of them in one badge. expect(pinMarker.textContent).toContain('1 · 3') expect(glMarkers.created[1].lngLat).toEqual([2.12, 48.12]) }) // The Leaflet twin of this is FE-COMP-MAPVIEW-074. Both renderers build their // marker as an HTML string, so both need the escape pinned — otherwise it can // be dropped from one of them without a single test going red. it('FE-COMP-MAPVIEWGL-023b: an image_url that passes the prefix check is still escaped', async () => { loadOnAttach() render( { cluster.element.click() }) expect(glMap.fitBounds).toHaveBeenCalledWith(expect.anything(), { padding: 70, maxZoom: 18 }) expect(onPoiClick).not.toHaveBeenCalled() vi.mocked(glMap.getZoom).mockReturnValueOnce(18) act(() => { cluster.element.click() }) const calls = vi.mocked(glPopup.setDOMContent).mock.calls const list = calls[calls.length - 1][0] act(() => { list.querySelectorAll('button')[1].click() }) expect(onPoiClick).toHaveBeenCalledWith(other) vi.mocked(glMap.getZoom).mockReturnValue(10) }) it('FE-COMP-MAPVIEWGL-043: plugin markers render as tone dots with a text-only popup', async () => { loadOnAttach() const markers: PluginMapMarker[] = [ { pluginId: 'ev', id: 'm1', lat: 48.5, lng: 2.5, tone: 'success', label: 'Fastned', popupText: '150 kW', url: 'https://example.com/c' }, { pluginId: 'ev', id: 'm2', lat: 48.6, lng: 2.6, tone: 'default' }, ] server.use( http.get('/api/map-markers/:tripId', () => HttpResponse.json({ markers })), http.get('/api/map-layers/:tripId', () => HttpResponse.json({ layers: [] })), ) render() await waitFor(() => expect(glMarkers.created).toHaveLength(2)) expect(glMarkers.created[0].element.innerHTML).toContain('#10b981') act(() => { glMarkers.created[0].element.dispatchEvent(new MouseEvent('click')) }) const box = vi.mocked(glPopup.setDOMContent).mock.calls[0][0] expect(box.textContent).toContain('Fastned') expect(box.textContent).toContain('150 kW') expect(box.querySelector('a')?.getAttribute('href')).toBe('https://example.com/c') // A bare marker carries no popup at all. vi.mocked(glPopup.setDOMContent).mockClear() act(() => { glMarkers.created[1].element.dispatchEvent(new MouseEvent('click')) }) expect(glPopup.setDOMContent).not.toHaveBeenCalled() }) it('FE-COMP-MAPVIEWGL-044: plugin layer features become one geojson source with closed rings', async () => { loadOnAttach() const features: PluginMapLayerFeature[] = [ { type: 'polyline', points: [[48, 2], [49, 3]], tone: 'warn', width: 4, dash: 'dash', opacity: 0.6, fill: false }, { type: 'polygon', points: [[48, 2], [48, 3], [49, 3]], tone: 'danger', width: 2, dash: 'solid', opacity: 0.8, fill: true }, { type: 'circle', center: [48, 2], radiusM: 1000, tone: 'nonsense' as PluginMapLayerFeature['tone'], width: 2, dash: 'dot', opacity: 0.4, fill: false }, { type: 'polyline', tone: 'default', width: 2, dash: 'solid', opacity: 1, fill: false }, ] const layers: PluginMapLayer[] = [{ pluginId: 'ev', id: 'corridor', features }] server.use( http.get('/api/map-markers/:tripId', () => HttpResponse.json({ markers: [] })), http.get('/api/map-layers/:tripId', () => HttpResponse.json({ layers })), ) const pluginSource = geoSource() render() glMap.getSource.mockImplementation((id: string) => (id === 'trek-plugin-layers' ? pluginSource : null)) await waitFor(() => expect(lastData(pluginSource).features).toHaveLength(3)) const [line, polygon, circle] = lastData(pluginSource).features expect(line.geometry).toEqual({ type: 'LineString', coordinates: [[2, 48], [3, 49]] }) expect(line.properties).toMatchObject({ id: 'ev:corridor:0', color: '#f59e0b', dash: 'dash', fillOpacity: 0 }) const ring = polygon.geometry.coordinates[0] as unknown as number[][] expect(ring[0]).toEqual(ring[ring.length - 1]) // A filled shape never drowns the basemap. expect(polygon.properties.fillOpacity).toBe(0.25) // A metric circle is approximated as a polygon, since GL circles size in pixels. const circleRing = circle.geometry.coordinates[0] as unknown as number[][] expect(circleRing).toHaveLength(65) expect(circleRing[0][0]).toBeCloseTo(2 + 1000 / (111320 * Math.cos(48 * Math.PI / 180)), 4) // Unknown tones fall back to the default indigo. expect(circle.properties.color).toBe('#4F46E5') }) it('FE-COMP-MAPVIEWGL-045: failing plugin contributions leave the core map untouched', async () => { loadOnAttach() server.use( http.get('/api/map-markers/:tripId', () => HttpResponse.json({ error: 'down' }, { status: 500 })), http.get('/api/map-layers/:tripId', () => HttpResponse.json({ error: 'down' }, { status: 500 })), ) const pluginSource = geoSource() const { rerender } = render() glMap.getSource.mockImplementation((id: string) => (id === 'trek-plugin-layers' ? pluginSource : null)) await act(async () => {}) expect(glMarkers.created).toHaveLength(0) // Dropping the trip clears whatever was there. rerender() await act(async () => {}) expect(lastData(pluginSource).features).toHaveLength(0) }) it('FE-COMP-MAPVIEWGL-046: plugin route via points show their label and dwell time on tap', async () => { loadOnAttach() const routeVias: RouteVia[] = [ { lat: 48.5, lng: 2.5, tone: 'success', label: 'Fastned', dwellSeconds: 4500 }, { lat: 48.6, lng: 2.6, tone: 'warn', dwellSeconds: 900 }, { lat: 48.7, lng: 2.7, tone: 'default' }, ] render() await act(async () => {}) expect(glMarkers.created).toHaveLength(3) act(() => { glMarkers.created[0].element.dispatchEvent(new MouseEvent('click')) }) expect(glPopup.setText).toHaveBeenCalledWith('Fastned · 1 h 15 min') act(() => { glMarkers.created[1].element.dispatchEvent(new MouseEvent('click')) }) expect(glPopup.setText).toHaveBeenLastCalledWith('15 min') // Neither label nor dwell time: nothing to show, so the dot stays inert. act(() => { glMarkers.created[2].element.dispatchEvent(new MouseEvent('click')) }) expect(glPopup.setText).toHaveBeenCalledTimes(2) }) it('FE-COMP-MAPVIEWGL-047: only the unclustered leaves of the cluster source get a rich marker (#1385)', async () => { loadOnAttach() const clusterSource = geoSource() glMap.getSource.mockImplementation((id: string) => (id === 'trip-place-clusters' ? clusterSource : null)) glMap.querySourceFeatures.mockReturnValue([ { properties: { placeId: 61 } }, { properties: { placeId: '62' } }, // vector-tile encoders hand ids back as strings { properties: { placeId: 62 } }, // …and can repeat them { properties: { placeId: 999 } }, // no longer in the trip { properties: { placeId: 'nope' } }, { properties: {} }, ]) const places = [ buildMapPlace({ id: 61, lat: 48.1, lng: 2.1 }), buildMapPlace({ id: 62, lat: 48.2, lng: 2.2 }), buildMapPlace({ id: 63, lat: 48.3, lng: 2.3 }), ] render() await flushFrames() const source = lastData(clusterSource) expect(source.features).toHaveLength(3) expect(source.features[0].properties.placeId).toBe(61) expect(source.features[0].geometry.coordinates).toEqual([2.1, 48.1]) const drawn = glMarkers.created.map(m => m.lngLat) expect(drawn).toContainEqual([2.1, 48.1]) expect(drawn).toContainEqual([2.2, 48.2]) // 63 is inside a cluster bubble, so no HTML marker is drawn for it. expect(drawn).not.toContainEqual([2.3, 48.3]) }) it('FE-COMP-MAPVIEWGL-048: the day route becomes one LineString per drawn segment', async () => { const routeSource = geoSource() glMap.getSource.mockImplementation((id: string) => (id === 'trip-route' ? routeSource : null)) render() await act(async () => {}) const { features } = lastData(routeSource) // A one-point segment is not a line. expect(features).toHaveLength(1) expect(features[0].geometry.coordinates).toEqual([[2, 48], [3, 49]]) }) it('FE-COMP-MAPVIEWGL-048b: a caller-coloured route rides its colours on the feature', async () => { const routeSource = geoSource() glMap.getSource.mockImplementation((id: string) => (id === 'trip-route' ? routeSource : null)) render() await act(async () => {}) const { features } = lastData(routeSource) expect(features[0].properties.color).toBe('#ff9f0a') expect(features[0].properties.casing).toBe('#c2740a') // No colour means the day route's own blue, which the layer paint falls back to. expect(features[1].properties.color).toBeNull() }) it('FE-COMP-MAPVIEWGL-049: unusable GPX geometry is skipped instead of breaking the layer', async () => { const gpxSource = geoSource() glMap.getSource.mockImplementation((id: string) => (id === 'trip-gpx' ? gpxSource : null)) const places = [ buildMapPlace({ id: 71, lat: 48, lng: 2, route_geometry: null }), buildMapPlace({ id: 72, lat: 48, lng: 2, route_geometry: '[[48,2]]' }), buildMapPlace({ id: 73, lat: 48, lng: 2, route_geometry: 'not json at all' }), buildMapPlace({ id: 74, lat: 48, lng: 2, route_geometry: '[[48,2],[49,3]]' }), ] render() await act(async () => {}) const { features } = lastData(gpxSource) expect(features).toHaveLength(1) expect(features[0].properties.place_id).toBe(74) }) it('FE-COMP-MAPVIEWGL-050: without a Mapbox token it shows the settings hint and touches no camera', async () => { const mapboxgl = (await import('mapbox-gl')).default useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, mapbox_access_token: '' }, } as any) const places = [buildMapPlace({ id: 81, lat: 48, lng: 2 })] const { container, rerender } = render() await act(async () => {}) expect(container.textContent).toContain('No Mapbox access token configured') expect(container.textContent).toContain('Settings → Map → Mapbox GL') expect(mapboxgl.Map).not.toHaveBeenCalled() rerender() await act(async () => {}) expect(glMap.fitBounds).not.toHaveBeenCalled() expect(glMap.jumpTo).not.toHaveBeenCalled() }) it('FE-COMP-MAPVIEWGL-051: a later centre prop change jumps the camera in [lng, lat] order', async () => { const { rerender } = render() await act(async () => {}) expect(glMap.jumpTo).not.toHaveBeenCalled() rerender() await act(async () => {}) expect(glMap.jumpTo).toHaveBeenCalledWith({ center: [139.69, 35.68], zoom: 9 }) }) it('FE-COMP-MAPVIEWGL-052: a fit that throws before the style is ready is retried on load', async () => { const places = [ buildMapPlace({ id: 82, lat: 48.1, lng: 2.1 }), buildMapPlace({ id: 83, lat: 48.2, lng: 2.2 }), ] const { rerender } = render() await act(async () => {}) glMap.fitBounds.mockImplementationOnce(() => { throw new Error('style not ready') }) rerender() await act(async () => {}) expect(glMap.fitBounds).toHaveBeenCalledTimes(1) const retry = glMap.once.mock.calls.find(c => c[0] === 'load')?.[1] as () => void expect(retry).toBeTypeOf('function') act(() => { retry() }) expect(glMap.fitBounds).toHaveBeenCalledTimes(2) }) it('FE-COMP-MAPVIEWGL-053: on a phone it shows the location FAB, uses mobile padding and hides the hover card', async () => { loadOnAttach() const original = window.innerWidth Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 420 }) try { const places = [ buildMapPlace({ id: 84, lat: 48.1, lng: 2.1 }), buildMapPlace({ id: 85, lat: 48.2, lng: 2.2 }), ] const { rerender, getByRole, queryByTestId } = render() await act(async () => {}) expect(getByRole('button', { name: 'Show my location' })).toBeTruthy() act(() => { glMarkers.created[0].element.dispatchEvent(new MouseEvent('mouseenter', { clientX: 5, clientY: 5 })) }) expect(queryByTestId('tooltip')).toBeNull() rerender() await act(async () => {}) const fitOptions = glMap.fitBounds.mock.calls[0][1] as { padding: Record } expect(fitOptions.padding).toEqual({ top: 40, right: 20, bottom: 40, left: 20 }) } finally { Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: original }) } }) it('FE-COMP-MAPVIEWGL-054: desktop padding leaves room for the side panels and the inspector', async () => { const places = [ buildMapPlace({ id: 86, lat: 48.1, lng: 2.1 }), buildMapPlace({ id: 87, lat: 48.2, lng: 2.2 }), ] const { rerender } = render() await act(async () => {}) rerender() await act(async () => {}) const fitOptions = glMap.fitBounds.mock.calls[0][1] as { padding: Record; maxZoom: number } expect(fitOptions.padding).toEqual({ top: 60, right: 400, bottom: 320, left: 340 }) expect(fitOptions.maxZoom).toBe(15) }) it('FE-COMP-MAPVIEWGL-055: cached thumbs paint the marker while a custom upload is never re-fetched (#1136)', async () => { loadOnAttach() const thumbListeners: Record void> = {} vi.mocked(photoService.getCached).mockImplementation((key: string) => ( key === 'gp-1' ? ({ thumbDataUrl: 'data:image/png;base64,AAA' } as unknown as ReturnType) : undefined )) vi.mocked(photoService.onThumbReady).mockImplementation((key: string, fn: (thumb: string) => void) => { thumbListeners[key] = fn return () => {} }) const places = [ buildMapPlace({ id: 91, lat: 48.1, lng: 2.1, google_place_id: 'gp-1' }), buildMapPlace({ id: 92, lat: 48.2, lng: 2.2, osm_id: 'osm-2', name: 'Bar' }), buildMapPlace({ id: 93, lat: 48.3, lng: 2.3, image_url: '/uploads/places/own.jpg' }), ] render() await flushFrames() // Only the place without a cached photo and without a custom image is fetched. expect(photoService.fetchPhoto).toHaveBeenCalledTimes(1) expect(photoService.fetchPhoto).toHaveBeenCalledWith('osm-2', 'osm-2', 48.2, 2.2, 'Bar') const srcs = () => glMarkers.created.map(m => m.element.querySelector('img')?.getAttribute('src')) expect(srcs()).toContain('data:image/png;base64,AAA') expect(srcs()).toContain('/uploads/places/own.jpg') await act(async () => { thumbListeners['osm-2']?.('data:image/png;base64,BBB') await new Promise(resolve => setTimeout(resolve, 40)) }) expect(srcs()).toContain('data:image/png;base64,BBB') }) it('FE-COMP-MAPVIEWGL-056: an in-flight photo is not requested twice and the proxy url is used as the id', async () => { vi.mocked(photoService.isLoading).mockImplementation((key: string) => key === 'osm-loading') const places = [ buildMapPlace({ id: 94, lat: 48.4, lng: 2.4, osm_id: 'osm-loading' }), buildMapPlace({ id: 95, lat: 48.5, lng: 2.5, osm_id: 'osm-5', image_url: '/api/maps/place-photo/abc', name: 'Museum' }), ] render() await act(async () => {}) expect(photoService.fetchPhoto).toHaveBeenCalledTimes(1) expect(photoService.fetchPhoto).toHaveBeenCalledWith('osm-5', '/api/maps/place-photo/abc', 48.5, 2.5, 'Museum') }) it('FE-COMP-MAPVIEWGL-066: a place with neither provider id nor coordinates has no cache key and is skipped', async () => { render() await act(async () => {}) expect(photoService.fetchPhoto).not.toHaveBeenCalled() expect(photoService.onThumbReady).not.toHaveBeenCalled() }) it('FE-COMP-MAPVIEWGL-057: no photos are fetched when place photos are switched off', async () => { useAuthStore.setState({ placesPhotosEnabled: false }) render() await act(async () => {}) expect(photoService.fetchPhoto).not.toHaveBeenCalled() expect(photoService.onThumbReady).not.toHaveBeenCalled() }) it('FE-COMP-MAPVIEWGL-058: unmounting unsubscribes the thumb listeners and drops the map', async () => { const dispose = vi.fn() const thumbListeners: Record void> = {} vi.mocked(photoService.onThumbReady).mockImplementation((key: string, fn: (thumb: string) => void) => { thumbListeners[key] = fn return dispose }) glMap.remove.mockImplementation(() => { throw new Error('context already lost') }) const { unmount } = render() await act(async () => {}) // A thumb arriving just before unmount leaves a frame scheduled. act(() => { thumbListeners['osm-7']?.('data:image/png;base64,CCC') }) expect(() => unmount()).not.toThrow() expect(dispose).toHaveBeenCalled() expect(glMap.remove).toHaveBeenCalled() }) it('FE-COMP-MAPVIEWGL-069: unmounting lets go of the window debug handle', async () => { const { unmount } = render() await act(async () => {}) expect((window as any).__trek_map).toBe(glMap) unmount() // Otherwise every style switch keeps its torn-down map reachable from window. expect((window as any).__trek_map).toBeUndefined() }) it('FE-COMP-MAPVIEWGL-059: pan and zoom report the visible bbox for the POI explore pill', async () => { const onViewportChange = vi.fn() render() await act(async () => {}) act(() => { mapHandler('moveend')() }) expect(onViewportChange).toHaveBeenCalledWith({ south: 0, west: 0, north: 1, east: 1 }) }) it('FE-COMP-MAPVIEWGL-060: a new POI or via set replaces the previous overlay markers', async () => { loadOnAttach() const poi: Poi = { osm_id: 'n1', name: 'Café', lat: 48.85, lng: 2.33, category: 'cafe', poi_type: 'cafe', address: null, website: null, phone: null, opening_hours: null, cuisine: null, source: 'openstreetmap', } const via: RouteVia = { lat: 48.5, lng: 2.5, tone: 'default' } const { rerender } = render() await act(async () => {}) const first = [...glMarkers.created] expect(first).toHaveLength(2) rerender( , ) await act(async () => {}) first.forEach(marker => expect(vi.mocked(marker.remove)).toHaveBeenCalled()) expect(glMarkers.created.length).toBeGreaterThan(first.length) }) it('FE-COMP-MAPVIEWGL-061: plugin markers are torn down when the trip loses them', async () => { loadOnAttach() server.use( http.get('/api/map-markers/:tripId', ({ params }) => HttpResponse.json({ markers: params.tripId === '4' ? [{ pluginId: 'ev', id: 'm1', lat: 48.5, lng: 2.5, tone: 'default' } as PluginMapMarker] : [], })), http.get('/api/map-layers/:tripId', () => HttpResponse.json({ layers: [] })), ) const { rerender } = render() await waitFor(() => expect(glMarkers.created).toHaveLength(1)) const stale = glMarkers.created[0] rerender() await waitFor(() => expect(vi.mocked(stale.remove)).toHaveBeenCalled()) }) it('FE-COMP-MAPVIEWGL-062: selecting a day-only place flies there; one without coordinates does not', async () => { const dayPlace = buildMapPlace({ id: 101, lat: 35.68, lng: 139.69 }) const broken = buildMapPlace({ id: 102, lat: null, lng: null }) const { rerender } = render( , ) await act(async () => {}) rerender() await act(async () => {}) expect(glMap.flyTo).toHaveBeenCalledWith(expect.objectContaining({ center: [139.69, 35.68], zoom: 14 })) glMap.flyTo.mockClear() rerender() await act(async () => {}) expect(glMap.flyTo).not.toHaveBeenCalled() }) it('FE-COMP-MAPVIEWGL-063: tracking without a fix yet does not attach the blue dot', async () => { geoStub.mode = 'show' geoStub.position = null render() await act(async () => {}) expect(locationMarker.update).not.toHaveBeenCalled() }) it('FE-COMP-MAPVIEWGL-064: the overlay gets the endpoint callback already at construction time', async () => { loadOnAttach() const onReservationClick = vi.fn() render() await act(async () => {}) const ctorOptions = vi.mocked(ReservationMapboxOverlay).mock.calls[0][1] as unknown as { showEndpointLabels: boolean onEndpointClick: (id: number) => void } expect(ctorOptions.showEndpointLabels).toBe(false) ctorOptions.onEndpointClick(42) expect(onReservationClick).toHaveBeenCalledWith(42) }) it('FE-COMP-MAPVIEWGL-065: unmounting mid-reconcile cancels the scheduled frame and detaches the listeners', async () => { loadOnAttach() const clusterSource = geoSource() glMap.getSource.mockImplementation((id: string) => (id === 'trip-place-clusters' ? clusterSource : null)) const { unmount } = render() unmount() await flushFrames() expect(glMap.off).toHaveBeenCalledWith('moveend', expect.any(Function)) expect(glMap.off).toHaveBeenCalledWith('zoomend', expect.any(Function)) }) it('FE-COMP-MAPVIEWGL-071: POI suggestions ride the map render clock instead of the pointer', async () => { // Same drift the planned-place pins had: a library Marker repositions on every // `move` event — one per pointer sample — while the canvas draws once a frame, so // the suggestions swam over the map during a drag and snapped back on release. loadOnAttach() glCanvasContainer.replaceChildren() glMap.project.mockReturnValue({ x: 100, y: 80 }) render( , ) await flushFrames() const layer = glCanvasContainer.firstElementChild as HTMLElement expect(layer.children.length).toBe(1) const pin = layer.firstElementChild as HTMLElement expect(pin.style.transform).toContain('translate(100px, 80px)') glMap.project.mockReturnValue({ x: 140, y: 60 }) act(() => { mapHandler('render')() }) expect(pin.style.transform).toContain('translate(140px, 60px)') }) // A via handle is a DOM element, and a pointerdown belongs to the element it happened // on. Rebuilding the handles on an unrelated render pulled that element out from under // the pointer, so a freshly placed via lost its first drag — the re-route it triggered // landed about a second later, which is exactly when somebody reaches for it. it('FE-COMP-MAPVIEWGL-VIA-001: a via handle survives a render it has nothing to do with', async () => { loadOnAttach() glCanvasContainer.replaceChildren() glMap.project.mockReturnValue({ x: 100, y: 80 }) const stored = { id: 5, day_id: 1, after_order_index: 0, sequence: 0, lat: 48.1, lng: 2.1 } const { rerender } = render( {}} />, ) await flushFrames() const layer = glCanvasContainer.firstElementChild as HTMLElement const handle = layer.firstElementChild expect(handle).toBeTruthy() // The same via in a fresh object, with a fresh callback: what every render of the // planner hands down while the day re-routes. rerender( {}} />, ) await flushFrames() expect(layer.firstElementChild).toBe(handle) }) it('FE-COMP-MAPVIEWGL-VIA-002: a via that actually moved is drawn again', async () => { loadOnAttach() glCanvasContainer.replaceChildren() glMap.project.mockReturnValue({ x: 100, y: 80 }) const stored = { id: 5, day_id: 1, after_order_index: 0, sequence: 0, lat: 48.1, lng: 2.1 } const { rerender } = render( {}} />, ) await flushFrames() const layer = glCanvasContainer.firstElementChild as HTMLElement const handle = layer.firstElementChild rerender( {}} />, ) await flushFrames() // The other half of the contract: holding the element still must not mean holding it // in the wrong place. expect(layer.firstElementChild).not.toBe(handle) }) // ── Satellite ─────────────────────────────────────────────────────────────── // // Leaflet has had the imagery for a while and swaps its whole tile layer for it. // A GL map cannot do that, because its basemap is a style with dozens of layers, // so the imagery goes on as a raster layer under everything TREK draws. it('FE-COMP-MAPVIEWGL-074: satellite adds the imagery under the first TREK layer', async () => { loadOnAttach() useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, map_base_layer: 'satellite' }, } as never) glMap.getStyle.mockReturnValue({ layers: [{ id: 'background' }, { id: 'road' }, { id: 'trip-route' }, { id: 'trip-gpx-hit' }], }) render() await flushFrames() expect(glMap.addSource).toHaveBeenCalledWith('trip-satellite', expect.objectContaining({ type: 'raster', tiles: [expect.stringContaining('arcgisonline.com')], })) // Anchored before the route, or the imagery would be painted over it. expect(glMap.addLayer).toHaveBeenCalledWith( expect.objectContaining({ id: 'trip-satellite-raster', type: 'raster' }), 'trip-route', ) }) it('FE-COMP-MAPVIEWGL-075: with the default basemap no imagery is fetched at all', async () => { loadOnAttach() useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, map_base_layer: 'default' }, } as never) render() await flushFrames() expect(glMap.addSource).not.toHaveBeenCalledWith('trip-satellite', expect.anything()) }) it('FE-COMP-MAPVIEWGL-076: switching back hides the layer instead of tearing it down', async () => { loadOnAttach() useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, map_base_layer: 'satellite' }, } as never) glMap.getStyle.mockReturnValue({ layers: [{ id: 'background' }, { id: 'trip-route' }] }) // Present from the first pass on, the way the real map reports it afterwards. glMap.getSource.mockImplementation((id: string) => (id === 'trip-satellite' ? {} : null)) glMap.getLayer.mockImplementation((id: string) => (id === 'trip-satellite-raster' ? {} : null)) const { rerender } = render() await flushFrames() act(() => { useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, map_base_layer: 'default' }, } as never) }) rerender() await flushFrames() expect(glMap.setLayoutProperty).toHaveBeenCalledWith('trip-satellite-raster', 'visibility', 'none') }) // A map is busy (isStyleLoaded() false) for as long as tiles or a setData are in flight, // which on a phone is most of the time and on the road trip stage was every tap. The // imagery used to wait for a settled style that `styledata` never announced. it('FE-COMP-MAPVIEWGL-079: a stored satellite choice is drawn even while the map is still busy', async () => { loadOnAttach() useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, map_base_layer: 'satellite' }, } as never) glMap.getStyle.mockReturnValue({ layers: [{ id: 'background' }, { id: 'trip-route' }] }) // Right after `load` the basemap tiles and the overlay sources are still loading. glMap.isStyleLoaded.mockReturnValue(false) render() await flushFrames() expect(glMap.addSource).toHaveBeenCalledWith('trip-satellite', expect.objectContaining({ type: 'raster' })) expect(glMap.addLayer).toHaveBeenCalledWith( expect.objectContaining({ id: 'trip-satellite-raster', type: 'raster' }), 'trip-route', ) }) it('FE-COMP-MAPVIEWGL-080: a tap on the switcher while a source update is in flight still shows the imagery', async () => { loadOnAttach() glMap.getStyle.mockReturnValue({ layers: [{ id: 'background' }, { id: 'trip-route' }] }) const { getByRole } = render() await flushFrames() expect(glMap.addSource).not.toHaveBeenCalledWith('trip-satellite', expect.anything()) glMap.isStyleLoaded.mockReturnValue(false) fireEvent.click(getByRole('button', { name: 'Switch to satellite view' })) await flushFrames() expect(useSettingsStore.getState().settings.map_base_layer).toBe('satellite') expect(glMap.addSource).toHaveBeenCalledWith('trip-satellite', expect.objectContaining({ type: 'raster' })) expect(glMap.addLayer).toHaveBeenCalledWith( expect.objectContaining({ id: 'trip-satellite-raster' }), 'trip-route', ) }) it('FE-COMP-MAPVIEWGL-081: imagery that is already on the map flips visibility at once, busy or not', async () => { loadOnAttach() glMap.getSource.mockImplementation((id: string) => (id === 'trip-satellite' ? {} : null)) glMap.getLayer.mockImplementation((id: string) => (id === 'trip-satellite-raster' ? {} : null)) const { getByRole } = render() await flushFrames() glMap.setLayoutProperty.mockClear() glMap.isStyleLoaded.mockReturnValue(false) fireEvent.click(getByRole('button', { name: 'Switch to satellite view' })) await flushFrames() expect(glMap.setLayoutProperty).toHaveBeenCalledWith('trip-satellite-raster', 'visibility', 'visible') // Flipping it is all a second tap takes: nothing is built a second time. expect(glMap.addSource).not.toHaveBeenCalledWith('trip-satellite', expect.anything()) expect(glMap.addLayer).not.toHaveBeenCalledWith(expect.objectContaining({ id: 'trip-satellite-raster' }), expect.anything()) }) it('FE-COMP-MAPVIEWGL-082: a style that is not in yet keeps the plain basemap until styledata brings it', async () => { loadOnAttach() useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, map_base_layer: 'satellite' }, } as never) glMap.getStyle.mockReturnValue({ layers: [{ id: 'background' }, { id: 'trip-route' }] }) // What both engines do when the style document itself has not loaded. let styleIn = false glMap.addSource.mockImplementation((id: string) => { if (id === 'trip-satellite' && !styleIn) throw new Error('Style is not done loading.') }) render() await flushFrames() expect(glMap.addLayer).not.toHaveBeenCalledWith(expect.objectContaining({ id: 'trip-satellite-raster' }), expect.anything()) styleIn = true const subscribed = glMap.on.mock.calls.filter(c => c[0] === 'styledata') const styledata = subscribed[subscribed.length - 1]?.[1] as (() => void) | undefined expect(styledata).toBeDefined() act(() => { styledata!() }) expect(glMap.addLayer).toHaveBeenCalledWith( expect.objectContaining({ id: 'trip-satellite-raster' }), 'trip-route', ) }) it('FE-COMP-MAPVIEWGL-083: the switcher sits the shared inset off the map edge, past any side panel', async () => { const { getByRole, rerender } = render() await flushFrames() // Button, then its frosted shell, then the positioned wrapper. const wrapper = () => getByRole('button', { name: 'Switch to satellite view' }).parentElement!.parentElement! // The phone's compass is placed off the same inset, so the two cannot drift apart. expect(wrapper().style.left).toBe(`${MAP_LAYER_SWITCHER_INSET}px`) rerender() await flushFrames() expect(wrapper().style.left).toBe(`${240 + MAP_LAYER_SWITCHER_INSET}px`) }) it('FE-COMP-MAPVIEWGL-084: a source left behind without its layer still gets the layer', async () => { loadOnAttach() useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, map_base_layer: 'satellite' }, } as never) glMap.getStyle.mockReturnValue({ layers: [{ id: 'background' }, { id: 'trip-route' }] }) // An earlier pass that got the source in and then failed on the layer. glMap.getSource.mockImplementation((id: string) => (id === 'trip-satellite' ? {} : null)) render() await flushFrames() // Guarded on the source, the pass would have seen it and built nothing, for good. expect(glMap.addLayer).toHaveBeenCalledWith( expect.objectContaining({ id: 'trip-satellite-raster', type: 'raster' }), 'trip-route', ) expect(glMap.addSource).not.toHaveBeenCalledWith('trip-satellite', expect.anything()) }) }) describe('MapViewGL attribution', () => { const flushFrames = () => act(async () => { await new Promise(resolve => setTimeout(resolve, 40)) }) /** The compact control as maplibre leaves it on a narrow map: collapsed class, open. */ function compactAttribution(container: HTMLElement): HTMLElement { const el = document.createElement('details') el.className = 'maplibregl-ctrl maplibregl-ctrl-attrib maplibregl-compact maplibregl-compact-show' el.setAttribute('open', '') // Inside the map container itself, where maplibre puts its controls. container.querySelectorAll('div')[1]?.appendChild(el) return el } it('FE-COMP-MAPVIEWGL-077: the credit starts as the little button, not as a ribbon', async () => { const { container } = render() await flushFrames() const attrib = compactAttribution(container) const idle = glMap.once.mock.calls.find(c => c[0] === 'idle')?.[1] as (() => void) | undefined expect(idle).toBeDefined() act(() => { idle!() }) // Both engines open theirs on a phone and only tuck it away on the first drag, so // the first thing on a screen the map fills is a two-line band across the bottom. expect(attrib.classList.contains('maplibregl-compact-show')).toBe(false) expect(attrib.hasAttribute('open')).toBe(false) // Still there, still one tap away: nothing about the credit is removed. expect(attrib.classList.contains('maplibregl-ctrl-attrib')).toBe(true) }) it('FE-COMP-MAPVIEWGL-078: a map wide enough to show the credit in full is left alone', async () => { const { container } = render() await flushFrames() const el = document.createElement('details') // No `compact-show`, which is what a map over 640px reports. el.className = 'maplibregl-ctrl maplibregl-ctrl-attrib' el.setAttribute('open', '') container.querySelectorAll('div')[1]?.appendChild(el) const idle = glMap.once.mock.calls.find(c => c[0] === 'idle')?.[1] as (() => void) | undefined act(() => { idle!() }) expect(el.hasAttribute('open')).toBe(true) }) })