/** * 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 }) => (
{children}
), 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( , ) // 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( , ) // 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( , ) expect(markerClusterGroupSpy).not.toHaveBeenCalled() expect(fakeAddLayer).not.toHaveBeenCalled() }) it('invokes onSelectLocation when a location marker is clicked', () => { const onSelectLocation = vi.fn() render( , ) // 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( , ) expect(markerClickHandlers.length).toBeGreaterThan(0) markerClickHandlers[0]() expect(onSelectPoo).toHaveBeenCalledOnce() expect(onSelectPoo).toHaveBeenCalledWith(pooRecord) }) })