From 66d85604e8cfec2102393a3bcb19ea8a2999192f Mon Sep 17 00:00:00 2001 From: AdaBliss Date: Sat, 29 Aug 2026 03:20:16 -0700 Subject: [PATCH] Test: Implement Component: Fleet Manager Vehicle Tracking (Live Map) Updates Add component tests for the fleet manager live map covering marker clustering, live driver updates and empty states. Clustering is now zoom-aware: the grid cell size doubles for every zoom level the manager zooms out, so nearby drivers merge into one cluster marker instead of rendering as overlapping pins, and split apart again on zoom in. Previously the cell size was a fixed 0.05 degrees regardless of zoom, so 'clusters when zoomed out' was not observable behaviour. - clustering: add cellSizeForZoom() with clamping and non-finite guards - FleetMapClient: track zoom via useMapEvents and re-bucket on zoomend - tests: 19 FleetMapClient cases (mocked react-leaflet/leaflet) and 7 cellSizeForZoom cases --- components/fleet/FleetMapClient.tsx | 85 +++-- .../fleet/__tests__/FleetMapClient.test.tsx | 344 ++++++++++++++++++ components/fleet/__tests__/clustering.test.ts | 57 ++- components/fleet/clustering.ts | 29 +- 4 files changed, 489 insertions(+), 26 deletions(-) create mode 100644 components/fleet/__tests__/FleetMapClient.test.tsx diff --git a/components/fleet/FleetMapClient.tsx b/components/fleet/FleetMapClient.tsx index 1af2058..2e3f1fd 100644 --- a/components/fleet/FleetMapClient.tsx +++ b/components/fleet/FleetMapClient.tsx @@ -1,10 +1,16 @@ 'use client'; -import { useEffect, useMemo } from 'react'; -import { MapContainer, TileLayer, CircleMarker, Tooltip } from 'react-leaflet'; +import { useEffect, useMemo, useState } from 'react'; +import { + MapContainer, + TileLayer, + CircleMarker, + Tooltip, + useMapEvents, +} from 'react-leaflet'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; -import { clusterDrivers } from './clustering'; +import { cellSizeForZoom, clusterDrivers } from './clustering'; import type { Driver } from '@/types/fleet'; interface FleetMapClientProps { @@ -12,6 +18,7 @@ interface FleetMapClientProps { } const DEFAULT_CENTER: [number, number] = [9.082, 8.6753]; // Nigeria centroid +const INITIAL_ZOOM = 6; function pickCenter(drivers: Driver[]): [number, number] { if (drivers.length === 0) return DEFAULT_CENTER; @@ -29,30 +36,32 @@ function radiusFor(count: number): number { return 24; } -export default function FleetMapClient({ drivers }: FleetMapClientProps) { - const clusters = useMemo(() => clusterDrivers(drivers), [drivers]); - const center = useMemo(() => pickCenter(drivers), [drivers]); +/** + * Renders one marker per cluster and re-buckets whenever the map zoom changes. + * + * Lives inside `MapContainer` because `useMapEvents` needs the Leaflet map from + * context. Zooming out widens the clustering grid, so nearby drivers merge into + * a single marker instead of overlapping pins. + */ +function DriverClusterLayer({ drivers }: FleetMapClientProps) { + const [zoomFromEvent, setZoomFromEvent] = useState(null); - // Ensure Leaflet picks up the CSS-loaded marker icons in environments - // (Next.js bundles) where the default detection misfires. Without this - // the default markers can render broken even when CSS is imported. - useEffect(() => { - const proto = L.Icon.Default.prototype as { _getIconUrl?: unknown }; - if (proto._getIconUrl) delete proto._getIconUrl; - }, []); + const map = useMapEvents({ + zoomend: () => setZoomFromEvent(map.getZoom()), + }); + + // Before the first `zoomend` the map may already sit at a zoom other than + // INITIAL_ZOOM (a restored view, or `fitBounds`), so read the live value and + // let subsequent events take over. + const zoom = zoomFromEvent ?? map?.getZoom?.() ?? INITIAL_ZOOM; + + const clusters = useMemo( + () => clusterDrivers(drivers, cellSizeForZoom(zoom)), + [drivers, zoom], + ); return ( - - + <> {clusters.map((cluster) => { const count = cluster.drivers.length; const isCluster = count > 1; @@ -78,6 +87,34 @@ export default function FleetMapClient({ drivers }: FleetMapClientProps) { ); })} + + ); +} + +export default function FleetMapClient({ drivers }: FleetMapClientProps) { + const center = useMemo(() => pickCenter(drivers), [drivers]); + + // Ensure Leaflet picks up the CSS-loaded marker icons in environments + // (Next.js bundles) where the default detection misfires. Without this + // the default markers can render broken even when CSS is imported. + useEffect(() => { + const proto = L.Icon.Default.prototype as { _getIconUrl?: unknown }; + if (proto._getIconUrl) delete proto._getIconUrl; + }, []); + + return ( + + + ); } diff --git a/components/fleet/__tests__/FleetMapClient.test.tsx b/components/fleet/__tests__/FleetMapClient.test.tsx new file mode 100644 index 0000000..fd0d12b --- /dev/null +++ b/components/fleet/__tests__/FleetMapClient.test.tsx @@ -0,0 +1,344 @@ +/** + * FleetMapClient component tests. + * + * Covers the fleet manager live map: marker clustering as the manager zooms + * out, re-clustering when driver telemetry arrives, and the empty/degenerate + * data paths. + * + * Leaflet is mocked wholesale - jsdom has no layout engine, so the real + * MapContainer cannot mount. The stubs expose the props the component passes + * down (center, radius, colour) as data attributes so the assertions describe + * what a fleet manager would see on the map. + */ + +import { render, screen, act } from '@testing-library/react'; +import FleetMapClient from '@/components/fleet/FleetMapClient'; +import { cellSizeForZoom } from '@/components/fleet/clustering'; +import type { Driver } from '@/types/fleet'; + +// Captured across renders so tests can drive Leaflet's `zoomend` event. +const mockMapState = { zoom: 6 }; +let mockZoomHandler: (() => void) | undefined; + +jest.mock('leaflet/dist/leaflet.css', () => ({})); + +jest.mock('leaflet', () => ({ + __esModule: true, + default: { + Icon: { Default: { prototype: { _getIconUrl: () => 'icon.png' } } }, + }, +})); + +jest.mock('react-leaflet', () => { + const React = require('react'); + return { + __esModule: true, + MapContainer: ({ children, center, zoom, ...rest }: any) => + React.createElement( + 'div', + { + 'data-testid': 'map-container', + 'data-center': center.join(','), + 'data-zoom': String(zoom), + 'aria-label': rest['aria-label'], + }, + children, + ), + TileLayer: ({ url }: any) => + React.createElement('div', { + 'data-testid': 'tile-layer', + 'data-url': url, + }), + CircleMarker: ({ children, center, radius, pathOptions }: any) => + React.createElement( + 'div', + { + 'data-testid': 'cluster-marker', + 'data-center': center.join(','), + 'data-radius': String(radius), + 'data-color': pathOptions.color, + }, + children, + ), + Tooltip: ({ children }: any) => + React.createElement('div', { 'data-testid': 'marker-tooltip' }, children), + useMapEvents: (handlers: { zoomend?: () => void }) => { + mockZoomHandler = handlers.zoomend; + return { getZoom: () => mockMapState.zoom }; + }, + }; +}); + +function driver(id: string, lat: number, lng: number): Driver { + return { + id, + name: `Driver ${id}`, + phone: '+2348000000000', + vehicleType: 'Van', + vehiclePlate: `PLATE-${id}`, + status: 'active', + rating: 4.5, + activeDeliveries: 1, + completedDeliveries: 10, + location: { lat, lng, updatedAt: '2026-04-25T00:00:00Z' }, + }; +} + +/** Simulates the manager zooming the map, mirroring Leaflet's `zoomend`. */ +function zoomTo(level: number) { + mockMapState.zoom = level; + act(() => { + mockZoomHandler?.(); + }); +} + +function markers() { + return screen.queryAllByTestId('cluster-marker'); +} + +function tooltipTexts() { + return screen.queryAllByTestId('marker-tooltip').map((el) => el.textContent); +} + +// Two drivers a few km apart: distinct at street zoom, one pin at country zoom. +const LAGOS_A = driver('a', 6.51, 3.41); +const LAGOS_B = driver('b', 6.59, 3.49); +// A third, far away in Abuja, that must never merge with the Lagos pair. +const ABUJA = driver('c', 9.06, 7.49); + +describe('FleetMapClient', () => { + beforeEach(() => { + mockMapState.zoom = 6; + mockZoomHandler = undefined; + }); + + describe('base rendering', () => { + it('renders the map shell and OpenStreetMap tiles', () => { + render(); + + expect(screen.getByTestId('map-container')).toBeInTheDocument(); + expect(screen.getByTestId('tile-layer')).toHaveAttribute( + 'data-url', + 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + ); + }); + + it('centres the map on the mean driver position', () => { + render(); + + const [lat, lng] = screen + .getByTestId('map-container') + .getAttribute('data-center')! + .split(',') + .map(Number); + + expect(lat).toBeCloseTo(6.55, 5); + expect(lng).toBeCloseTo(3.45, 5); + }); + + it('falls back to the default centre when there are no drivers', () => { + render(); + + expect(screen.getByTestId('map-container')).toHaveAttribute( + 'data-center', + '9.082,8.6753', + ); + }); + + it('renders one marker per driver when they are far apart', () => { + render(); + + expect(markers()).toHaveLength(2); + expect(tooltipTexts()).toEqual( + expect.arrayContaining(['Driver a', 'Driver c']), + ); + }); + }); + + describe('clustering on zoom', () => { + it('keeps nearby drivers separate while zoomed in', () => { + render(); + + zoomTo(13); + + expect(markers()).toHaveLength(2); + expect(tooltipTexts()).toEqual( + expect.arrayContaining(['Driver a', 'Driver b']), + ); + }); + + it('merges nearby drivers into one cluster when zoomed out', () => { + render(); + + zoomTo(13); + expect(markers()).toHaveLength(2); + + zoomTo(3); + + const merged = markers(); + expect(merged).toHaveLength(1); + expect(merged[0]).toHaveTextContent('2 drivers'); + }); + + it('splits a cluster back apart when the manager zooms in again', () => { + render(); + + zoomTo(3); + expect(markers()).toHaveLength(1); + + zoomTo(14); + + expect(markers()).toHaveLength(2); + expect(tooltipTexts()).not.toContain('2 drivers'); + }); + + it('never merges drivers in different cities at a regional zoom', () => { + render(); + + zoomTo(3); + + const rendered = markers(); + expect(rendered).toHaveLength(2); + expect(tooltipTexts()).toEqual( + expect.arrayContaining(['2 drivers', 'Driver c']), + ); + }); + + it('grows the marker radius and switches colour for clusters', () => { + render(); + + zoomTo(3); + + const [cluster, single] = markers().sort( + (a, b) => + Number(b.getAttribute('data-radius')) - + Number(a.getAttribute('data-radius')), + ); + + expect(cluster).toHaveAttribute('data-radius', '12'); + expect(cluster).toHaveAttribute('data-color', '#1d4ed8'); + expect(single).toHaveAttribute('data-radius', '8'); + expect(single).toHaveAttribute('data-color', '#10b981'); + }); + + it('positions a cluster marker at the centroid of its drivers', () => { + render(); + + zoomTo(3); + + const [lat, lng] = markers()[0] + .getAttribute('data-center')! + .split(',') + .map(Number); + + expect(lat).toBeCloseTo(6.55, 5); + expect(lng).toBeCloseTo(3.45, 5); + }); + + it('adopts the map instance zoom on mount instead of assuming the initial one', () => { + mockMapState.zoom = 3; + + render(); + + expect(markers()).toHaveLength(1); + expect(markers()[0]).toHaveTextContent('2 drivers'); + }); + }); + + describe('live driver updates', () => { + it('re-clusters when a driver location update arrives', () => { + const { rerender } = render( + , + ); + expect(markers()).toHaveLength(2); + + // The Abuja driver's telemetry moves them next to the Lagos driver. + rerender( + , + ); + + expect(markers()).toHaveLength(1); + expect(markers()[0]).toHaveTextContent('2 drivers'); + }); + + it('adds a marker when a new driver comes online', () => { + const { rerender } = render(); + expect(markers()).toHaveLength(1); + + rerender(); + + expect(markers()).toHaveLength(2); + }); + + it('removes markers when every driver goes offline', () => { + const { rerender } = render( + , + ); + + rerender(); + + expect(markers()).toHaveLength(0); + expect(screen.getByTestId('map-container')).toBeInTheDocument(); + }); + + it('keeps the cluster stable when an unrelated driver field changes', () => { + const { rerender } = render( + , + ); + zoomTo(3); + expect(markers()).toHaveLength(1); + + rerender( + , + ); + + expect(markers()).toHaveLength(1); + expect(markers()[0]).toHaveTextContent('2 drivers'); + }); + }); + + describe('empty and edge cases', () => { + it('renders no markers for an empty fleet', () => { + render(); + + expect(markers()).toHaveLength(0); + expect(screen.getByTestId('tile-layer')).toBeInTheDocument(); + }); + + it('renders a single marker for drivers sharing an exact position', () => { + const shared = { lat: 6.51, lng: 3.41, updatedAt: '2026-04-25T00:00:00Z' }; + render( + , + ); + + expect(markers()).toHaveLength(1); + expect(markers()[0]).toHaveTextContent('2 drivers'); + }); + + it('survives a NaN zoom by falling back to the default cell size', () => { + render(); + + zoomTo(Number.NaN); + + expect(cellSizeForZoom(Number.NaN)).toBe(0.05); + expect(markers()).toHaveLength(3); + }); + + it('keeps drivers near the equator and prime meridian separate when zoomed in', () => { + render( + , + ); + + zoomTo(12); + + expect(markers()).toHaveLength(2); + }); + }); +}); diff --git a/components/fleet/__tests__/clustering.test.ts b/components/fleet/__tests__/clustering.test.ts index 3142901..eea4f6a 100644 --- a/components/fleet/__tests__/clustering.test.ts +++ b/components/fleet/__tests__/clustering.test.ts @@ -1,4 +1,9 @@ -import { clusterDrivers } from '@/components/fleet/clustering'; +import { + cellSizeForZoom, + clusterDrivers, + DEFAULT_CLUSTER_CELL_SIZE, + REFERENCE_ZOOM, +} from '@/components/fleet/clustering'; import type { Driver } from '@/types/fleet'; function driver(id: string, lat: number, lng: number): Driver { @@ -57,3 +62,53 @@ describe('clusterDrivers', () => { expect(cluster.lng).toBeCloseTo(3.42, 3); }); }); + +describe('cellSizeForZoom', () => { + it('returns the default cell size at the reference zoom', () => { + expect(cellSizeForZoom(REFERENCE_ZOOM)).toBe(DEFAULT_CLUSTER_CELL_SIZE); + }); + + it('doubles the cell size for every zoom level the user zooms out', () => { + expect(cellSizeForZoom(REFERENCE_ZOOM - 1)).toBeCloseTo( + DEFAULT_CLUSTER_CELL_SIZE * 2, + 6, + ); + expect(cellSizeForZoom(REFERENCE_ZOOM - 3)).toBeCloseTo( + DEFAULT_CLUSTER_CELL_SIZE * 8, + 6, + ); + }); + + it('halves the cell size for every zoom level the user zooms in', () => { + expect(cellSizeForZoom(REFERENCE_ZOOM + 1)).toBeCloseTo( + DEFAULT_CLUSTER_CELL_SIZE / 2, + 6, + ); + }); + + it('is monotonically decreasing as zoom increases', () => { + const sizes = [0, 3, 6, 9, 12, 18].map(cellSizeForZoom); + const sorted = [...sizes].sort((a, b) => b - a); + expect(sizes).toEqual(sorted); + }); + + it('clamps extreme zoom levels to a usable range', () => { + expect(cellSizeForZoom(-100)).toBeLessThanOrEqual(45); + expect(cellSizeForZoom(-100)).toBeGreaterThan(0); + expect(cellSizeForZoom(100)).toBeGreaterThanOrEqual(0.001); + }); + + it('falls back to the default for a non-finite zoom', () => { + expect(cellSizeForZoom(Number.NaN)).toBe(DEFAULT_CLUSTER_CELL_SIZE); + expect(cellSizeForZoom(Number.POSITIVE_INFINITY)).toBe( + DEFAULT_CLUSTER_CELL_SIZE, + ); + }); + + it('merges drivers when zoomed out that stay separate when zoomed in', () => { + const drivers = [driver('a', 6.51, 3.41), driver('b', 6.59, 3.49)]; + + expect(clusterDrivers(drivers, cellSizeForZoom(13))).toHaveLength(2); + expect(clusterDrivers(drivers, cellSizeForZoom(3))).toHaveLength(1); + }); +}); diff --git a/components/fleet/clustering.ts b/components/fleet/clustering.ts index 0eaaf34..528d437 100644 --- a/components/fleet/clustering.ts +++ b/components/fleet/clustering.ts @@ -17,7 +17,7 @@ export interface MapCluster { */ export function clusterDrivers( drivers: Driver[], - cellSize = 0.05, + cellSize: number = DEFAULT_CLUSTER_CELL_SIZE, ): MapCluster[] { const buckets: Record = {}; @@ -44,3 +44,30 @@ export function clusterDrivers( } return clusters; } + +/** + * Cell size used at {@link REFERENCE_ZOOM}, matching the historical default. + */ +export const DEFAULT_CLUSTER_CELL_SIZE = 0.05; + +/** Leaflet zoom level at which {@link DEFAULT_CLUSTER_CELL_SIZE} applies. */ +export const REFERENCE_ZOOM = 6; + +const MIN_CELL_SIZE = 0.001; +const MAX_CELL_SIZE = 45; + +/** + * Grid cell size (in degrees) to use for a given Leaflet zoom level. + * + * One Leaflet zoom step halves the ground distance covered by a pixel, so the + * bucket has to double in size for every level the user zooms out. That keeps + * the on-screen distance between merged pins roughly constant: zooming out + * collapses neighbouring drivers into a single cluster, zooming in splits them + * back apart. The result is clamped so extreme zooms cannot degenerate into + * one bucket per driver or a single bucket for the whole world. + */ +export function cellSizeForZoom(zoom: number): number { + if (!Number.isFinite(zoom)) return DEFAULT_CLUSTER_CELL_SIZE; + const size = DEFAULT_CLUSTER_CELL_SIZE * 2 ** (REFERENCE_ZOOM - zoom); + return Math.min(MAX_CELL_SIZE, Math.max(MIN_CELL_SIZE, size)); +}