/**
* VectorBasemap unit tests.
*
* The component and its imperative twin both hang a MapLibre style into Leaflet's
* tile pane. Nothing here renders WebGL — maplibre-gl and the bridge are mocked;
* what is under test is the wiring: which style goes in, what happens on a style
* change, and what is left behind on teardown.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, waitFor } from '@testing-library/react'
import type * as L from 'leaflet'
import { maplibreGL as bridge } from '@maplibre/maplibre-gl-leaflet'
import { hasWebGL } from '../../utils/webgl'
import {
VectorBasemap,
attachVectorBasemap,
detachBasemapLayer,
hideLabelLayers,
restyleBasemap,
type BasemapLayer,
type GlLeafletLayer,
} from './VectorBasemap'
import { OFM_POSITRON, OFM_DARK, OFM_ATTRIBUTION, RASTER_FALLBACK_TILE_URL } from '../../constants/mapDefaults'
const addAttribution = vi.fn()
/** Only the two things the component touches; casting keeps the mock honest about that. */
const asMap = (m: object) => m as unknown as L.Map
const leafletMap = asMap({ attributionControl: { addAttribution } })
/** A map built without an attribution control, like the atlas. */
const bareMap = asMap({})
// TileLayer as well as useMap: the component renders one when the browser has no
// WebGL, and jsdom is exactly such a browser.
vi.mock('react-leaflet', () => ({
useMap: () => leafletMap,
TileLayer: ({ url }: { url: string }) =>
,
}))
// jsdom has no WebGL, so without this every case below would take the raster
// branch. The suite is about the GL wiring; the raster branch has its own cases.
vi.mock('../../utils/webgl', () => ({ hasWebGL: vi.fn(() => true), resetWebGLProbe: vi.fn() }))
vi.mock('./engines/maplibre', () => ({ default: { __engine: 'maplibre' } }))
vi.mock('@maplibre/maplibre-gl-leaflet', () => {
const maplibreGL = vi.fn(() => {
// One GL map per layer, kept stable: a fresh object per call would hand the
// assertions a different spy than the code just used.
const gl = {
setStyle: vi.fn(),
on: vi.fn(),
isStyleLoaded: vi.fn(() => false),
getStyle: vi.fn(() => ({
layers: [
{ id: 'water', type: 'fill' },
{ id: 'boundary', type: 'line' },
{ id: 'place-city', type: 'symbol' },
{ id: 'country-label', type: 'symbol' },
],
})),
setLayoutProperty: vi.fn(),
}
const layer: Record = { remove: vi.fn(), getMaplibreMap: vi.fn(() => gl) }
layer.addTo = vi.fn(() => layer)
return layer
})
return { maplibreGL, default: maplibreGL }
})
const maplibreGL = vi.mocked(bridge as unknown as ReturnType)
type Spy = ReturnType
/** The GL map behind a mocked layer, typed as spies so assertions can reach them. */
interface MockGl {
setStyle: Spy
on: Spy
isStyleLoaded: Spy
getStyle: Spy
setLayoutProperty: Spy
}
/** The layer the last maplibreGL() call produced, and its GL map. */
function lastLayer(): { layer: GlLeafletLayer; gl: MockGl } {
const results = maplibreGL.mock.results
const raw = results[results.length - 1]!.value as { getMaplibreMap: () => MockGl }
return { layer: raw as unknown as GlLeafletLayer, gl: raw.getMaplibreMap() }
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('VectorBasemap', () => {
it('FE-COMP-VECBM-001: hangs the style into the map, non-interactive', async () => {
render()
await waitFor(() => expect(maplibreGL).toHaveBeenCalledTimes(1))
expect(maplibreGL.mock.calls[0]![0]).toMatchObject({
style: OFM_POSITRON,
// Leaflet keeps handling the gestures; a second interactive map underneath
// would fight it for the wheel and the drag.
interactive: false,
attributionControl: false,
})
expect(lastLayer().layer.addTo).toHaveBeenCalledWith(leafletMap)
})
it('FE-COMP-VECBM-002: credits OpenFreeMap, not just OpenStreetMap', async () => {
// The data is OSM, but the rendering and the hosting are not, and the licence
// asks for both.
render()
await waitFor(() => expect(addAttribution).toHaveBeenCalledWith(OFM_ATTRIBUTION))
})
it('FE-COMP-VECBM-003: a theme switch restyles in place instead of rebuilding', async () => {
const { rerender } = render()
await waitFor(() => expect(maplibreGL).toHaveBeenCalledTimes(1))
const { gl } = lastLayer()
rerender()
// Swapping the layer would leak a WebGL context per toggle, and browsers cap
// those in the low teens: a dozen theme switches and the map goes blank.
expect(gl.setStyle).toHaveBeenCalledWith(OFM_DARK)
expect(maplibreGL).toHaveBeenCalledTimes(1)
})
it('FE-COMP-VECBM-004: takes the layer off the map on unmount', async () => {
const { unmount } = render()
await waitFor(() => expect(maplibreGL).toHaveBeenCalledTimes(1))
const { layer } = lastLayer()
unmount()
expect(layer.remove).toHaveBeenCalled()
})
})
describe('attachVectorBasemap', () => {
it('FE-COMP-VECBM-005: hands the caller the layer it attached', async () => {
const ref: { current: GlLeafletLayer | null } = { current: null }
const map = { id: 'imperative' }
await attachVectorBasemap(asMap(map), OFM_DARK, ref, () => false)
expect(maplibreGL.mock.calls[0]![0]).toMatchObject({ style: OFM_DARK, interactive: false })
expect(ref.current).toBe(lastLayer().layer)
expect(lastLayer().layer.addTo).toHaveBeenCalledWith(map)
})
it('FE-COMP-VECBM-006: a map torn down mid-load gets no layer', async () => {
// maplibre-gl only ever arrives through a dynamic import, so there is always a
// gap between "the map wants a basemap" and "the basemap is here". Leaving the
// layer attached to a dead map keeps its WebGL context alive for good.
const ref: { current: GlLeafletLayer | null } = { current: null }
await attachVectorBasemap(bareMap, OFM_DARK, ref, () => true)
expect(maplibreGL).not.toHaveBeenCalled()
expect(ref.current).toBeNull()
})
it('FE-COMP-VECBM-007: credits OpenFreeMap on a map that has an attribution control', async () => {
// The raster layer it replaces carried the credit, so dropping it silently
// would leave the journey map crediting nobody.
const ref: { current: GlLeafletLayer | null } = { current: null }
await attachVectorBasemap(leafletMap, OFM_POSITRON, ref, () => false)
expect(addAttribution).toHaveBeenCalledWith(OFM_ATTRIBUTION)
})
it('FE-COMP-VECBM-008: stays quiet on a map built without one', async () => {
// The atlas turns the control off on purpose; asking for it there would throw.
const ref: { current: GlLeafletLayer | null } = { current: null }
await expect(attachVectorBasemap(bareMap, OFM_POSITRON, ref, () => false)).resolves.toBeUndefined()
expect(addAttribution).not.toHaveBeenCalled()
})
it('FE-COMP-VECBM-009: hides the labels when asked to', async () => {
const ref: { current: GlLeafletLayer | null } = { current: null }
await attachVectorBasemap(bareMap, OFM_POSITRON, ref, () => false, { hideLabels: true })
const { gl } = lastLayer()
expect(gl.on).toHaveBeenCalledWith('style.load', expect.any(Function))
})
})
describe('hideLabelLayers', () => {
it('FE-COMP-VECBM-010: hides every symbol layer and nothing else', async () => {
const ref: { current: GlLeafletLayer | null } = { current: null }
await attachVectorBasemap(bareMap, OFM_POSITRON, ref, () => false)
const { layer, gl } = lastLayer()
gl.isStyleLoaded.mockReturnValue(true)
hideLabelLayers(layer)
// The atlas draws country names into its own fills, so basemap labels repeat
// them. Borders and coastlines are line/fill layers and have to survive.
expect(gl.setLayoutProperty).toHaveBeenCalledWith('place-city', 'visibility', 'none')
expect(gl.setLayoutProperty).toHaveBeenCalledWith('country-label', 'visibility', 'none')
expect(gl.setLayoutProperty).toHaveBeenCalledTimes(2)
})
it('FE-COMP-VECBM-011: re-hides them after every restyle', async () => {
const ref: { current: GlLeafletLayer | null } = { current: null }
await attachVectorBasemap(bareMap, OFM_POSITRON, ref, () => false)
const { layer, gl } = lastLayer()
hideLabelLayers(layer)
expect(gl.setLayoutProperty).not.toHaveBeenCalled() // style not loaded yet
// setStyle discards the layout properties along with the old style, so the
// theme toggle would bring the labels back without this.
const onStyleLoad = gl.on.mock.calls.find((c: unknown[]) => c[0] === 'style.load')![1] as () => void
onStyleLoad()
expect(gl.setLayoutProperty).toHaveBeenCalledTimes(2)
})
it('FE-COMP-VECBM-012: does nothing when the GL map is not up yet', () => {
const layer = { getMaplibreMap: () => undefined } as unknown as GlLeafletLayer
expect(() => hideLabelLayers(layer)).not.toThrow()
})
})
/**
* A browser that will not give MapLibre a WebGL context (#2288).
*
* Hardware acceleration off, a blocklisted driver, a VM or a remote desktop all
* land here. Before the probe, the throw came out of an async effect nobody could
* catch, and the layer it left behind took the page down on the way out.
*/
describe('VectorBasemap without WebGL', () => {
const noWebGL = vi.mocked(hasWebGL)
/** A map real Leaflet will accept, which the raster stand-in needs. */
const rasterMap = () => asMap({ addLayer: vi.fn(), attributionControl: { addAttribution } })
beforeEach(() => {
noWebGL.mockReturnValue(false)
})
it('FE-COMP-VECBM-013: draws raster tiles and never asks for the maplibre chunk', async () => {
// Not loading it is half the point: about a megabyte for a browser that could
// never have drawn it.
const { findByTestId } = render()
const tiles = await findByTestId('raster-basemap')
expect(tiles.getAttribute('data-url')).toBe(RASTER_FALLBACK_TILE_URL)
expect(maplibreGL).not.toHaveBeenCalled()
})
it('FE-COMP-VECBM-014: unmounting is quiet, with nothing to take off the map', () => {
const { unmount } = render()
// The crash in #2288 came from teardown, not from the failed attach.
expect(() => unmount()).not.toThrow()
})
it('FE-COMP-VECBM-015: the imperative maps get the raster stand-in in their ref', async () => {
const ref: { current: BasemapLayer | null } = { current: null }
const map = rasterMap()
await attachVectorBasemap(map, OFM_POSITRON, ref, () => false)
expect(maplibreGL).not.toHaveBeenCalled()
expect(ref.current).not.toBeNull()
// OpenStreetMap rather than OpenFreeMap: different tiles, different credit.
expect(addAttribution).toHaveBeenCalledWith(expect.stringContaining('openstreetmap.org/copyright'))
})
it('FE-COMP-VECBM-016: a map torn down mid-probe still gets nothing', async () => {
const ref: { current: BasemapLayer | null } = { current: null }
await attachVectorBasemap(rasterMap(), OFM_POSITRON, ref, () => true)
expect(ref.current).toBeNull()
})
it('FE-COMP-VECBM-017: the raster stand-in ignores a restyle and a label sweep', async () => {
const ref: { current: BasemapLayer | null } = { current: null }
await attachVectorBasemap(rasterMap(), OFM_POSITRON, ref, () => false)
// Both are GL-only operations. Called on raster tiles they must do nothing
// rather than reach for a maplibre map that is not there.
expect(() => restyleBasemap(ref.current, OFM_DARK)).not.toThrow()
expect(() => hideLabelLayers(ref.current!)).not.toThrow()
})
})
describe('detachBasemapLayer', () => {
it('FE-COMP-VECBM-018: a GL layer whose context never came off cleanly anyway', () => {
// This is the crash from #2288 in one line: maplibre-gl-leaflet's onRemove
// calls this._glMap.remove() without asking whether there is one, and after a
// refused context there is not. It ran inside a React cleanup, so the throw
// reached a boundary and replaced the page.
const remove = vi.fn(function (this: { _glMap?: { remove: () => void } }) {
this._glMap!.remove()
})
const halfBuilt = { getMaplibreMap: () => undefined, remove } as unknown as GlLeafletLayer
expect(() => detachBasemapLayer(halfBuilt)).not.toThrow()
expect(remove).toHaveBeenCalled()
})
it('FE-COMP-VECBM-019: a layer that throws for any other reason is swallowed too', () => {
// Unmounting a map is never allowed to fail because its basemap did.
const layer = { remove: vi.fn(() => { throw new Error('boom') }) } as unknown as BasemapLayer
expect(() => detachBasemapLayer(layer)).not.toThrow()
})
it('FE-COMP-VECBM-020: nothing to detach is not an error', () => {
expect(() => detachBasemapLayer(null)).not.toThrow()
})
})