119 lines
4.1 KiB
TypeScript
119 lines
4.1 KiB
TypeScript
|
|
/**
|
||
|
|
* HeatLayers regression test — post-walkthrough fix.
|
||
|
|
*
|
||
|
|
* Bug: the heat layer's `setLatLngs` was called BEFORE the layer was added to the
|
||
|
|
* map. A leaflet.heat layer that is not on a map has a null `_map`, and
|
||
|
|
* `setLatLngs -> redraw` dereferences `_map._animating`, throwing
|
||
|
|
* "Cannot read properties of null (reading '_animating')" and white-screening
|
||
|
|
* the whole SPA right after login.
|
||
|
|
*
|
||
|
|
* This test exercises the REAL HeatLayers code path (not a wholesale RecordsMap
|
||
|
|
* mock) and asserts the layer is added to the map BEFORE setLatLngs is called.
|
||
|
|
* Against the old code (setLatLngs first), the ordering assertion fails.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||
|
|
import { render } from '@testing-library/react'
|
||
|
|
|
||
|
|
const { callLog, setLatLngsSpy, mapAddLayerSpy } = vi.hoisted(() => {
|
||
|
|
const callLog: string[] = []
|
||
|
|
const setLatLngsSpy = vi.fn((_pts: unknown) => {
|
||
|
|
callLog.push('setLatLngs')
|
||
|
|
})
|
||
|
|
const mapAddLayerSpy = vi.fn((_layer: unknown) => {
|
||
|
|
callLog.push('addLayer')
|
||
|
|
})
|
||
|
|
return { callLog, setLatLngsSpy, mapAddLayerSpy }
|
||
|
|
})
|
||
|
|
|
||
|
|
// Mock leaflet. heatLayer returns a fake layer whose setLatLngs logs call order;
|
||
|
|
// Icon/DivIcon/marker exist because RecordsMap.tsx runs icon setup at module load.
|
||
|
|
vi.mock('leaflet', () => {
|
||
|
|
class FakeIcon {
|
||
|
|
constructor(_opts: unknown) {}
|
||
|
|
static Default = { prototype: {}, mergeOptions: vi.fn() }
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
Icon: FakeIcon,
|
||
|
|
DivIcon: vi.fn(function FakeDivIcon(_opts: unknown) {
|
||
|
|
return {}
|
||
|
|
}),
|
||
|
|
heatLayer: vi.fn(() => ({ setLatLngs: setLatLngsSpy, setOptions: vi.fn(), addTo: vi.fn() })),
|
||
|
|
markerClusterGroup: vi.fn(() => ({ addLayer: vi.fn(), addTo: vi.fn(), clearLayers: vi.fn() })),
|
||
|
|
marker: vi.fn(() => ({ bindTooltip: vi.fn().mockReturnThis(), on: vi.fn().mockReturnThis() })),
|
||
|
|
default: {},
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
vi.mock('leaflet.heat', () => ({}))
|
||
|
|
vi.mock('leaflet.markercluster', () => ({}))
|
||
|
|
vi.mock('leaflet/dist/images/marker-icon-2x.png', () => ({ default: 'marker-icon-2x.png' }))
|
||
|
|
vi.mock('leaflet/dist/images/marker-icon.png', () => ({ default: 'marker-icon.png' }))
|
||
|
|
vi.mock('leaflet/dist/images/marker-shadow.png', () => ({ default: 'marker-shadow.png' }))
|
||
|
|
vi.mock('leaflet/dist/leaflet.css', () => ({}))
|
||
|
|
vi.mock('leaflet.markercluster/dist/MarkerCluster.css', () => ({}))
|
||
|
|
vi.mock('leaflet.markercluster/dist/MarkerCluster.Default.css', () => ({}))
|
||
|
|
|
||
|
|
// useMap returns a fake map; hasLayer=false so addLayer is exercised.
|
||
|
|
vi.mock('react-leaflet', () => ({
|
||
|
|
MapContainer: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||
|
|
TileLayer: () => null,
|
||
|
|
useMap: () => ({
|
||
|
|
addLayer: mapAddLayerSpy,
|
||
|
|
removeLayer: vi.fn(),
|
||
|
|
hasLayer: () => false,
|
||
|
|
getSize: () => ({ x: 800, y: 600 }),
|
||
|
|
latLngToContainerPoint: () => ({ x: 100, y: 100 }),
|
||
|
|
on: vi.fn(),
|
||
|
|
off: vi.fn(),
|
||
|
|
}),
|
||
|
|
}))
|
||
|
|
|
||
|
|
import { HeatLayers } from './RecordsMap'
|
||
|
|
import type { HeatPoint } from './mapUtils'
|
||
|
|
|
||
|
|
const heatPoints: HeatPoint[] = [
|
||
|
|
[39.9, 116.4, 1],
|
||
|
|
[39.91, 116.41, 1],
|
||
|
|
]
|
||
|
|
|
||
|
|
describe('HeatLayers (real code path — regression for null _map crash)', () => {
|
||
|
|
beforeEach(() => {
|
||
|
|
vi.clearAllMocks()
|
||
|
|
callLog.length = 0
|
||
|
|
})
|
||
|
|
|
||
|
|
it('adds the heat layer to the map BEFORE calling setLatLngs', () => {
|
||
|
|
render(
|
||
|
|
<HeatLayers
|
||
|
|
locationHeatPoints={heatPoints}
|
||
|
|
pooHeatPoints={[]}
|
||
|
|
showLocationHeat={true}
|
||
|
|
showPooHeat={false}
|
||
|
|
/>,
|
||
|
|
)
|
||
|
|
|
||
|
|
// Data was applied...
|
||
|
|
expect(setLatLngsSpy).toHaveBeenCalledWith(heatPoints)
|
||
|
|
// ...and the layer was added to the map first. The old buggy order
|
||
|
|
// (setLatLngs before addLayer) makes this fail.
|
||
|
|
expect(callLog).toEqual(['addLayer', 'setLatLngs'])
|
||
|
|
expect(callLog.indexOf('addLayer')).toBeLessThan(callLog.indexOf('setLatLngs'))
|
||
|
|
})
|
||
|
|
|
||
|
|
it('does not call setLatLngs while the layer is hidden (off the map)', () => {
|
||
|
|
render(
|
||
|
|
<HeatLayers
|
||
|
|
locationHeatPoints={heatPoints}
|
||
|
|
pooHeatPoints={heatPoints}
|
||
|
|
showLocationHeat={false}
|
||
|
|
showPooHeat={false}
|
||
|
|
/>,
|
||
|
|
)
|
||
|
|
|
||
|
|
// Hidden layers are never on the map, so setLatLngs must not run on them.
|
||
|
|
expect(setLatLngsSpy).not.toHaveBeenCalled()
|
||
|
|
expect(mapAddLayerSpy).not.toHaveBeenCalled()
|
||
|
|
})
|
||
|
|
})
|