32d93bba2a
- self-contained RecordsMap (only module importing leaflet/react-leaflet/ leaflet.heat/leaflet.markercluster); OSM tiles, swappable behind clean props - heatmap layers for location + poo (primary); time-range selector fetches only the window (locations server-filtered; poo client-filtered) - toggleable scatter layer with marker clustering; point-select reuses T10's edit/delete modals + hooks; query-key prefixes refresh map on mutation - pure map logic isolated + unit-tested; leaflet mocked in component tests - responsive layout; typed client only
247 lines
8.5 KiB
TypeScript
247 lines
8.5 KiB
TypeScript
/**
|
|
* ScatterLayer unit test — M2-T09 REWORK 1.
|
|
*
|
|
* This test exercises the REAL ScatterLayer code path (not a wholesale RecordsMap mock).
|
|
* It verifies that ScatterLayer uses the imported leaflet namespace (L.markerClusterGroup)
|
|
* rather than window.L / globalThis.L, which would silently fail in Vite ESM bundles.
|
|
*
|
|
* The test:
|
|
* - mocks react-leaflet's useMap() to return a fake map object
|
|
* - provides a mock markerClusterGroup spy via the leaflet module mock
|
|
* - renders ScatterLayer with some points
|
|
* - asserts that L.markerClusterGroup was called (i.e. the import path is used)
|
|
* - asserts that addLayer was called for each point
|
|
* - asserts that clicking a marker invokes onSelectLocation / onSelectPoo
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { render } from '@testing-library/react'
|
|
import type { ReactNode } from 'react'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Use vi.hoisted() to define mocks that are referenced inside vi.mock factories.
|
|
// vi.mock() factories are hoisted to the top of the file, so any variables they
|
|
// reference must also be hoisted.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const { markerClusterGroupSpy, fakeAddLayer, fakeMapAddLayer, markerClickHandlers } =
|
|
vi.hoisted(() => {
|
|
const clickHandlers: Array<() => void> = []
|
|
const fakeAddLayer = vi.fn()
|
|
const fakeCluster = {
|
|
addLayer: fakeAddLayer,
|
|
addTo: vi.fn(),
|
|
clearLayers: vi.fn(),
|
|
}
|
|
const markerClusterGroupSpy = vi.fn(() => fakeCluster)
|
|
const fakeMapAddLayer = vi.fn()
|
|
return { markerClusterGroupSpy, fakeAddLayer, fakeMapAddLayer, markerClickHandlers: clickHandlers }
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mock leaflet BEFORE importing ScatterLayer.
|
|
// We use the hoisted spy so vi.mock factory can reference it safely.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
vi.mock('leaflet', () => {
|
|
const markerClusterGroupSpy_ = markerClusterGroupSpy
|
|
const markerClickHandlers_ = markerClickHandlers
|
|
|
|
// Icon must be a real constructor (used as `new Icon(...)`)
|
|
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: vi.fn(), addTo: vi.fn() })),
|
|
markerClusterGroup: markerClusterGroupSpy_,
|
|
marker: vi.fn((_latlng: unknown, _opts: unknown) => {
|
|
return {
|
|
bindTooltip: vi.fn().mockReturnThis(),
|
|
on: vi.fn((event: string, handler: () => void) => {
|
|
if (event === 'click') {
|
|
markerClickHandlers_.push(handler)
|
|
}
|
|
return { bindTooltip: vi.fn().mockReturnThis(), on: vi.fn().mockReturnThis() }
|
|
}),
|
|
}
|
|
}),
|
|
// `import * as L from 'leaflet'` in RecordsMap.tsx resolves to this module.
|
|
// Vitest's module mock exposes all named exports as the namespace object,
|
|
// so markerClusterGroup at the top level IS accessible as L.markerClusterGroup.
|
|
default: {
|
|
markerClusterGroup: markerClusterGroupSpy_,
|
|
},
|
|
}
|
|
})
|
|
|
|
vi.mock('leaflet.heat', () => ({}))
|
|
vi.mock('leaflet.markercluster', () => ({}))
|
|
|
|
// Mock image imports
|
|
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' }))
|
|
|
|
// Mock CSS imports
|
|
vi.mock('leaflet/dist/leaflet.css', () => ({}))
|
|
vi.mock('leaflet.markercluster/dist/MarkerCluster.css', () => ({}))
|
|
vi.mock('leaflet.markercluster/dist/MarkerCluster.Default.css', () => ({}))
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mock react-leaflet: MapContainer renders children, useMap returns fake map.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
vi.mock('react-leaflet', () => ({
|
|
MapContainer: ({ children }: { children: ReactNode }) => (
|
|
<div data-testid="map-container">{children}</div>
|
|
),
|
|
TileLayer: () => null,
|
|
useMap: () => ({
|
|
addLayer: fakeMapAddLayer,
|
|
removeLayer: vi.fn(),
|
|
hasLayer: vi.fn(() => false),
|
|
}),
|
|
}))
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Import ScatterLayer AFTER mocks are set up.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
import { ScatterLayer } from './RecordsMap'
|
|
import type { LocationMapPoint, PooMapPoint } from './mapUtils'
|
|
import type { LocationRecord, PooRecord } from '../records'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test data
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const locationRecord: LocationRecord = {
|
|
person: 'alice',
|
|
datetime: '2026-01-15T10:00:00Z',
|
|
latitude: 39.9,
|
|
longitude: 116.4,
|
|
altitude: null,
|
|
}
|
|
const locationPoints: LocationMapPoint[] = [
|
|
{ lat: 39.9, lng: 116.4, record: locationRecord },
|
|
]
|
|
|
|
const pooRecord: PooRecord = {
|
|
timestamp: '2026-01-20T09:00:00Z',
|
|
status: 'done',
|
|
latitude: 39.91,
|
|
longitude: 116.41,
|
|
}
|
|
const pooPoints: PooMapPoint[] = [
|
|
{ lat: 39.91, lng: 116.41, record: pooRecord },
|
|
]
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('ScatterLayer (real code path — not mocked RecordsMap)', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
markerClickHandlers.length = 0
|
|
})
|
|
|
|
it('calls L.markerClusterGroup (imported namespace) when showScatter=true', () => {
|
|
render(
|
|
<ScatterLayer
|
|
locationScatterPoints={locationPoints}
|
|
pooScatterPoints={[]}
|
|
showScatter={true}
|
|
onSelectLocation={vi.fn()}
|
|
onSelectPoo={vi.fn()}
|
|
/>,
|
|
)
|
|
|
|
// KEY assertion: markerClusterGroup was called via the IMPORTED namespace.
|
|
// With the old window.L / globalThis.L approach, this spy would never be
|
|
// invoked because window.L is undefined in Vite ESM bundles.
|
|
expect(markerClusterGroupSpy).toHaveBeenCalledOnce()
|
|
expect(markerClusterGroupSpy).toHaveBeenCalledWith({
|
|
maxClusterRadius: 50,
|
|
showCoverageOnHover: false,
|
|
})
|
|
})
|
|
|
|
it('calls cluster group addLayer for each location and poo scatter point', () => {
|
|
render(
|
|
<ScatterLayer
|
|
locationScatterPoints={locationPoints}
|
|
pooScatterPoints={pooPoints}
|
|
showScatter={true}
|
|
onSelectLocation={vi.fn()}
|
|
onSelectPoo={vi.fn()}
|
|
/>,
|
|
)
|
|
|
|
// One addLayer call per point (1 location + 1 poo = 2).
|
|
expect(fakeAddLayer).toHaveBeenCalledTimes(2)
|
|
// The cluster group itself must be added to the map.
|
|
const fakeCluster = markerClusterGroupSpy.mock.results[0]?.value
|
|
expect(fakeMapAddLayer).toHaveBeenCalledWith(fakeCluster)
|
|
})
|
|
|
|
it('does NOT create cluster group when showScatter=false', () => {
|
|
render(
|
|
<ScatterLayer
|
|
locationScatterPoints={locationPoints}
|
|
pooScatterPoints={pooPoints}
|
|
showScatter={false}
|
|
onSelectLocation={vi.fn()}
|
|
onSelectPoo={vi.fn()}
|
|
/>,
|
|
)
|
|
|
|
expect(markerClusterGroupSpy).not.toHaveBeenCalled()
|
|
expect(fakeAddLayer).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('invokes onSelectLocation when a location marker is clicked', () => {
|
|
const onSelectLocation = vi.fn()
|
|
|
|
render(
|
|
<ScatterLayer
|
|
locationScatterPoints={locationPoints}
|
|
pooScatterPoints={[]}
|
|
showScatter={true}
|
|
onSelectLocation={onSelectLocation}
|
|
onSelectPoo={vi.fn()}
|
|
/>,
|
|
)
|
|
|
|
// At least one marker click handler should have been registered.
|
|
expect(markerClickHandlers.length).toBeGreaterThan(0)
|
|
// Simulate click on the first (location) marker.
|
|
markerClickHandlers[0]()
|
|
expect(onSelectLocation).toHaveBeenCalledOnce()
|
|
expect(onSelectLocation).toHaveBeenCalledWith(locationRecord)
|
|
})
|
|
|
|
it('invokes onSelectPoo when a poo marker is clicked', () => {
|
|
const onSelectPoo = vi.fn()
|
|
|
|
render(
|
|
<ScatterLayer
|
|
locationScatterPoints={[]}
|
|
pooScatterPoints={pooPoints}
|
|
showScatter={true}
|
|
onSelectLocation={vi.fn()}
|
|
onSelectPoo={onSelectPoo}
|
|
/>,
|
|
)
|
|
|
|
expect(markerClickHandlers.length).toBeGreaterThan(0)
|
|
markerClickHandlers[0]()
|
|
expect(onSelectPoo).toHaveBeenCalledOnce()
|
|
expect(onSelectPoo).toHaveBeenCalledWith(pooRecord)
|
|
})
|
|
})
|