Files
TREK/client/src/components/Map/MapViewGL.test.tsx
T
Maurice a54503f26d feat(map): group GL place markers into clusters on zoom-out (#1385)
MapLibre/Mapbox showed every place as its own rich HTML marker with no
grouping when zoomed out, unlike the Leaflet map. Feed the place points
through a clustered GeoJSON source: clustered points render as a dark
count bubble (click to zoom in and expand) while the rich HTML photo
markers are only drawn for the points the source reports as unclustered.
Always on, matching the Leaflet MarkerClusterGroup.
2026-06-30 19:12:43 +02:00

269 lines
8.5 KiB
TypeScript

import React from 'react'
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
import { render } from '../../../tests/helpers/render'
import { act } from '@testing-library/react'
import { resetAllStores } from '../../../tests/helpers/store'
import { buildPlace } from '../../../tests/helpers/factories'
import { useSettingsStore } from '../../store/settingsStore'
// Stable fake map so fitBounds call counts survive re-renders.
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(() => document.createElement('div')),
getLayer: vi.fn().mockReturnValue(null),
queryRenderedFeatures: vi.fn().mockReturnValue([]),
querySourceFeatures: vi.fn().mockReturnValue([]),
easeTo: vi.fn(),
}))
vi.mock('mapbox-gl', () => ({
default: {
accessToken: '',
Map: vi.fn(function () {
return glMap
}),
Marker: vi.fn(function () {
return {
setLngLat: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
remove: vi.fn(),
getElement: vi.fn(() => document.createElement('div')),
}
}),
LngLatBounds: vi.fn(function () {
return { extend: vi.fn().mockReturnThis() }
}),
NavigationControl: vi.fn(),
Popup: vi.fn(function () {
return {
setLngLat: vi.fn().mockReturnThis(),
setHTML: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
remove: vi.fn(),
}
}),
},
}))
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
vi.mock('maplibre-gl', () => ({
default: {
Map: vi.fn(function () {
return glMap
}),
Marker: vi.fn(function () {
return {
setLngLat: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
remove: vi.fn(),
getElement: vi.fn(() => document.createElement('div')),
}
}),
LngLatBounds: vi.fn(function () {
return { extend: vi.fn().mockReturnThis() }
}),
NavigationControl: vi.fn(),
Popup: vi.fn(function () {
return {
setLngLat: vi.fn().mockReturnThis(),
setHTML: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
remove: vi.fn(),
}
}),
},
}))
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(),
}))
vi.mock('./locationMarkerMapbox', () => ({
attachLocationMarker: vi.fn(() => ({ update: vi.fn() })),
}))
vi.mock('./reservationsMapbox', () => ({
ReservationMapboxOverlay: vi.fn(function () {
return { update: vi.fn(), destroy: vi.fn() }
}),
}))
vi.mock('../../hooks/useGeolocation', () => ({
useGeolocation: vi.fn(() => ({
position: null,
mode: 'off',
error: null,
cycleMode: vi.fn(),
setMode: vi.fn(),
})),
}))
vi.mock('../../services/photoService', () => ({
getCached: vi.fn(() => null),
isLoading: vi.fn(() => false),
fetchPhoto: vi.fn(),
onThumbReady: vi.fn(() => () => {}),
getAllThumbs: vi.fn(() => ({})),
}))
import { MapViewGL } from './MapViewGL'
function buildMapPlace(overrides: Record<string, any> = {}) {
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.getSource.mockReturnValue(null)
glMap.getLayer.mockReturnValue(null)
glMap.queryRenderedFeatures.mockReturnValue([])
glMap.querySourceFeatures.mockReturnValue([])
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()
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(
<MapViewGL places={places} fitKey={1} selectedPlaceId={null} hasInspector={false} />,
)
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(
<MapViewGL places={places} fitKey={1} selectedPlaceId={1} hasInspector={true} />,
)
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(
<MapViewGL places={places} fitKey={1} selectedPlaceId={1} hasInspector={true} />,
)
await act(async () => {})
const after_initial = glMap.fitBounds.mock.calls.length
// Closing inspector (X button) clears selectedPlaceId → hasInspector=false → new paddingOpts.
rerender(
<MapViewGL places={places} fitKey={1} selectedPlaceId={null} hasInspector={false} />,
)
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(<MapViewGL places={places} fitKey={1} />)
await act(async () => {})
const after_first = glMap.fitBounds.mock.calls.length
rerender(<MapViewGL places={places} fitKey={2} />)
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(<MapViewGL places={places} fitKey={1} glProvider="maplibre-gl" />)
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-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(<MapViewGL places={[buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 })]} fitKey={1} glProvider="maplibre-gl" />)
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' }))
})
})