Files
home-automation/frontend/src/map/RecordsMap.scatter.test.tsx
T
tliu93 32d93bba2a M2-T09: build data visualization UI (heatmap map as home page)
- 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
2026-06-13 15:20:50 +02:00

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)
})
})