Files
TREK/client/src/components/Map/MapViewGL.test.tsx
T
Maurice 094622f06d fix(map,mcp): review follow-ups for the issue-fix batch
- mapbox-gl (unlike maplibre) still emits the map contextmenu after a
  right-button rotate/pitch drag on Windows - guard it with the pressed
  position so ending a rotate can't open the Add-Place form (#1398)
- a long-press whose fire was deduped (or that never yields a click) no
  longer leaves suppressNextClick armed to swallow a later real tap
- MCP_SSE_KEEPALIVE=0 keeps the open-stream-counts-as-activity
  guarantee: the touch interval survives, only the pings stop (#1414)
- swagger-ui-dist ships @scarf/scarf install-time analytics - disabled
  via scarfSettings in the root package.json, TREK sends no telemetry
- Budget wiki currency list: 47 incl. KGS (#1400)
2026-07-05 00:23:38 +02:00

443 lines
17 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. 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(),
}))
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' }))
})
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(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} />)
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(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} />)
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(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} />)
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(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} />)
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(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} />)
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(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} onMapClick={onMapClick} />)
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(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} />)
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(<MapViewGL places={places} fitKey={1} onMarkerClick={vi.fn()} />)
await act(async () => {})
const markerCall = (mapboxgl.Marker as unknown as ReturnType<typeof vi.fn>).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()
})
})