Files
TREK/client/src/components/Map/mapZoomCeiling.test.ts
T
Maurice dce348b947 fix(map): give the Leaflet map its own zoom ceiling, so a vector basemap cannot take the planner down
Opening a trip on 4.1.0 could land on the error boundary with "Map has no maxZoom
specified", and so could the map settings tab, which left no screen from which to
pick a different basemap.

Leaflet answers getMaxZoom() from the map options first and otherwise from a
layer that brought one, and only a GridLayer ever contributes via its beforeAdd
hook. A vector basemap is a GL canvas on a plain L.Layer, so it contributes
nothing, and MapContainer set no ceiling of its own. MarkerClusterGroup.onAdd
refuses an infinite one by throwing, before any clustering, so an empty trip hit
it too.

That path opened when the default basemap became a MapLibre style. It reaches
further than an operator choosing OpenFreeMap: resolveTileUrl sends a keyless
CARTO template to the app default, which is now a vector style, so every
instance still carrying the old CARTO basemap fell into it on upgrade.

The ceiling belongs on the map rather than on the base layer, because it has to
hold whichever of the three branches renders. 19 matches what the raster and
satellite layers already carried, so nothing changes for the maps that worked.
SharedTripPage gets the same treatment: it has no cluster to throw, but it draws
the same vector basemap and its fitBounds asks for a ceiling.

mapZoomCeiling.test.ts pins Leaflet's own semantics against the real library
rather than a mock, since a mocked react-leaflet would have kept passing through
all of this.
2026-08-29 16:13:31 +02:00

85 lines
3.3 KiB
TypeScript

/**
* The zoom ceiling a Leaflet map needs before a marker cluster will attach.
*
* This is a test about Leaflet's own semantics rather than about our components,
* because that semantic is what a basemap change quietly broke: switching the
* default basemap to a vector style replaced the TileLayer with a GL canvas, the
* ceiling went with it, and MarkerClusterGroup.onAdd threw on the way in. The
* planner and the map settings preview both went to the error boundary, which
* left no screen from which to pick a different basemap.
*
* Nothing is mocked here on purpose. A mocked react-leaflet would have kept
* passing throughout.
*/
import { describe, it, expect, afterEach } from 'vitest'
import L from 'leaflet'
import 'leaflet.markercluster'
import { MAP_MAX_ZOOM } from '../../constants/mapDefaults'
/**
* A layer that is not a GridLayer, which is what a vector basemap amounts to:
* maplibre-gl-leaflet hands back an L.Layer holding a canvas, so it has no
* beforeAdd and never reaches Leaflet's _addZoomLimit.
*/
const GlCanvasLayer = L.Layer.extend({ onAdd: () => undefined, onRemove: () => undefined })
function glCanvasLayer(): L.Layer {
return new GlCanvasLayer() as L.Layer
}
/**
* `leaflet.markercluster` ships no types and we do not depend on
* `@types/leaflet.markercluster`, so the one factory this file needs is named
* here rather than pulling a package in for a single call.
*/
const markerClusterGroup = (L as unknown as { markerClusterGroup: () => L.Layer }).markerClusterGroup
const containers: HTMLElement[] = []
function mapOn(options: L.MapOptions): L.Map {
const el = document.createElement('div')
Object.defineProperty(el, 'clientWidth', { value: 800 })
Object.defineProperty(el, 'clientHeight', { value: 600 })
document.body.appendChild(el)
containers.push(el)
return L.map(el, { center: [48.85, 2.35], zoom: 12, ...options })
}
afterEach(() => {
for (const el of containers.splice(0)) el.remove()
})
describe('the zoom ceiling of a Leaflet map', () => {
it('MAPZOOM-001: a vector basemap contributes none, so the map has none', () => {
const map = mapOn({})
glCanvasLayer().addTo(map)
// Only a GridLayer feeds Leaflet's _layersMaxZoom, via its beforeAdd hook.
expect(map.getMaxZoom()).toBe(Infinity)
})
it('MAPZOOM-002: a raster basemap contributes one, which is why raster maps never broke', () => {
const map = mapOn({})
L.tileLayer('https://example.test/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map)
expect(map.getMaxZoom()).toBe(19)
})
it('MAPZOOM-003: a cluster refuses to attach to a map with no ceiling', () => {
const map = mapOn({})
glCanvasLayer().addTo(map)
// The throw is in onAdd, before any clustering, so an empty trip hits it too.
expect(() => markerClusterGroup().addTo(map)).toThrow(/maxZoom/)
})
it('MAPZOOM-004: the ceiling on the map itself carries every basemap kind', () => {
const map = mapOn({ maxZoom: MAP_MAX_ZOOM })
glCanvasLayer().addTo(map)
expect(map.getMaxZoom()).toBe(MAP_MAX_ZOOM)
expect(() => markerClusterGroup().addTo(map)).not.toThrow()
})
it('MAPZOOM-005: it does not lower the raster and satellite layers, which already sat at 19', () => {
const map = mapOn({ maxZoom: MAP_MAX_ZOOM })
L.tileLayer('https://example.test/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map)
expect(map.getMaxZoom()).toBe(19)
})
})