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, 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 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(), 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 })), 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, 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) 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: 30, clusterMaxZoom: 10, })) expect(glMap.addLayer).toHaveBeenCalledWith(expect.objectContaining({ id: 'trip-place-clusters-circle' })) expect(glMap.addLayer).toHaveBeenCalledWith(expect.objectContaining({ id: 'trip-place-clusters-count' })) }) 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(