Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx
2026-07-16 02:25:04 +08:00

414 lines
15 KiB
TypeScript

import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import type { MonitorMapResponse } from '../../api/types';
import { wgs84ToGcj02, type AMapLike, type AMapMap, type AMapMassPoint } from '../../integrations/amap';
import { FleetMap } from './FleetMap';
const setData = vi.fn<(data: AMapMassPoint[]) => void>();
const setStyle = vi.fn();
const addLabels = vi.fn();
const removeLabels = vi.fn();
const clearLabels = vi.fn();
const setLabelsMap = vi.fn();
const markerSetMap = vi.fn();
const markerSetPosition = vi.fn();
const setZoomAndCenter = vi.fn();
const panTo = vi.fn();
const mapOff = vi.fn();
const mapDestroy = vi.fn();
const massOff = vi.fn();
const getZoom = vi.fn(() => 5);
const getBounds = vi.fn((): ReturnType<NonNullable<AMapMap['getBounds']>> => ({}));
const mapHandlers = new Map<string, (event: unknown) => void>();
const markerOptions: Record<string, unknown>[] = [];
const mapOptions: Record<string, unknown>[] = [];
const labelLayerOptions: Record<string, unknown>[] = [];
class TestMap {
constructor(_container: HTMLDivElement, options: Record<string, unknown>) {
mapOptions.push(options);
}
add = vi.fn();
addControl = vi.fn();
destroy = mapDestroy;
on = vi.fn((event: string, handler: (value: unknown) => void) => mapHandlers.set(event, handler));
off = mapOff;
getZoom = getZoom;
getBounds = getBounds;
setZoomAndCenter = setZoomAndCenter;
panTo = panTo;
}
class TestMassMarks {
on = vi.fn();
off = massOff;
setMap = vi.fn();
setData = setData;
setStyle = setStyle;
}
class TestScale {}
class TestToolBar {}
class TestSize {}
class TestPixel {}
class TestLabelsLayer {
constructor(options: Record<string, unknown> = {}) {
labelLayerOptions.push(options);
}
add = addLabels;
remove = removeLabels;
clear = clearLabels;
setMap = setLabelsMap;
}
class TestLabelMarker {
constructor(public options: Record<string, unknown>) {}
}
class TestMarker {
setMap = markerSetMap;
setPosition = markerSetPosition;
constructor(options: Record<string, unknown>) {
markerOptions.push(options);
}
}
const monitorMap: MonitorMapResponse = {
mode: 'clusters',
zoom: 5,
total: 12,
truncated: false,
points: [],
clusters: [{
id: 'cluster-1',
longitude: 121.1,
latitude: 30.6,
count: 12,
online: 8,
offline: 4,
driving: 3,
idle: 5,
unknown: 0
}],
asOf: '2026-07-14T01:00:00Z'
};
const pointMap: MonitorMapResponse = {
...monitorMap,
mode: 'points',
zoom: 13,
total: 2,
clusters: [],
points: [{
vin: 'LTEST000000000001',
plate: '粤A12345',
protocol: 'JT808',
protocols: ['JT808'],
longitude: 113.26,
latitude: 23.13,
speedKmh: 42,
socPercent: 80,
totalMileageKm: 1234,
lastSeen: '2026-07-14T01:00:00Z',
status: 'driving'
}, {
vin: 'LTEST000000000002',
plate: '粤B67890',
protocol: 'JT808',
protocols: ['JT808'],
longitude: 113.28,
latitude: 23.15,
speedKmh: 0,
socPercent: 72,
totalMileageKm: 2234,
lastSeen: '2026-07-14T01:00:00Z',
status: 'idle'
}]
};
function amapMock(): AMapLike {
return {
Map: TestMap as unknown as AMapLike['Map'],
Marker: TestMarker as unknown as AMapLike['Marker'],
Polyline: class {} as unknown as AMapLike['Polyline'],
Scale: TestScale,
ToolBar: TestToolBar,
Size: TestSize as unknown as AMapLike['Size'],
Pixel: TestPixel as unknown as AMapLike['Pixel'],
MassMarks: TestMassMarks as unknown as AMapLike['MassMarks'],
LabelsLayer: TestLabelsLayer as unknown as NonNullable<AMapLike['LabelsLayer']>,
LabelMarker: TestLabelMarker as unknown as NonNullable<AMapLike['LabelMarker']>
};
}
afterEach(() => {
cleanup();
delete window.__LINGNIU_APP_CONFIG__;
delete window.AMapLoader;
setData.mockReset();
setStyle.mockReset();
addLabels.mockReset();
removeLabels.mockReset();
clearLabels.mockReset();
setLabelsMap.mockReset();
markerSetMap.mockReset();
markerSetPosition.mockReset();
setZoomAndCenter.mockReset();
panTo.mockReset();
mapOff.mockReset();
mapDestroy.mockReset();
massOff.mockReset();
getZoom.mockReset();
getZoom.mockReturnValue(5);
getBounds.mockReset();
getBounds.mockReturnValue({});
mapHandlers.clear();
markerOptions.length = 0;
mapOptions.length = 0;
labelLayerOptions.length = 0;
vi.restoreAllMocks();
});
test('renders data that arrives before the delayed AMap SDK is ready', async () => {
let resolveAMap!: (value: AMapLike) => void;
const delayedAMap = new Promise<AMapLike>((resolve) => {
resolveAMap = resolve;
});
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
window.AMapLoader = { load: vi.fn(() => delayedAMap) };
render(
<FleetMap
vehicles={[]}
monitorMap={monitorMap}
onSelect={() => undefined}
/>
);
expect(setData).not.toHaveBeenCalled();
await act(async () => {
resolveAMap(amapMock());
await delayedAMap;
});
await waitFor(() => expect(setData).toHaveBeenCalledWith([
expect.objectContaining({ id: 'cluster-1', lnglat: wgs84ToGcj02(121.1, 30.6), label: '12 辆' })
]));
const clusterStyles = setStyle.mock.calls[setStyle.mock.calls.length - 1]?.[0] as Array<{ url: string }>;
expect(decodeURIComponent(clusterStyles[5].url)).toContain('>12</text>');
expect(decodeURIComponent(clusterStyles[5].url)).not.toContain('10+');
});
test('detaches AMap listeners and destroys the map on unmount', async () => {
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
window.AMapLoader = { load: vi.fn(async () => amapMock()) };
const view = render(<FleetMap vehicles={[]} monitorMap={pointMap} onSelect={() => undefined} />);
await waitFor(() => expect(setData).toHaveBeenCalled());
view.unmount();
expect(massOff).toHaveBeenCalledWith('click', expect.any(Function));
expect(mapOff).toHaveBeenCalledWith('moveend', expect.any(Function));
expect(mapOff).toHaveBeenCalledWith('zoomend', expect.any(Function));
expect(mapOff).toHaveBeenCalledWith('dragstart', expect.any(Function));
expect(mapDestroy).toHaveBeenCalledTimes(1);
});
test('skips unchanged refresh redraws and replaces only a moved vehicle label', async () => {
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
window.AMapLoader = { load: vi.fn(async () => amapMock()) };
const view = render(<FleetMap vehicles={[]} monitorMap={pointMap} onSelect={() => undefined} />);
await waitFor(() => expect(addLabels).toHaveBeenCalled());
const dataCalls = setData.mock.calls.length;
const styleCalls = setStyle.mock.calls.length;
const addCalls = addLabels.mock.calls.length;
const removeCalls = removeLabels.mock.calls.length;
const clearCalls = clearLabels.mock.calls.length;
await act(async () => {
view.rerender(<FleetMap vehicles={[]} monitorMap={{ ...pointMap, asOf: '2026-07-14T01:00:15Z' }} onSelect={() => undefined} />);
await Promise.resolve();
});
expect(setData).toHaveBeenCalledTimes(dataCalls);
expect(setStyle).toHaveBeenCalledTimes(styleCalls);
expect(addLabels).toHaveBeenCalledTimes(addCalls);
expect(removeLabels).toHaveBeenCalledTimes(removeCalls);
expect(clearLabels).toHaveBeenCalledTimes(clearCalls);
view.rerender(<FleetMap vehicles={[]} monitorMap={{
...pointMap,
asOf: '2026-07-14T01:00:30Z',
points: [{ ...pointMap.points[0], longitude: 113.27 }, pointMap.points[1]]
}} onSelect={() => undefined} />);
await waitFor(() => expect(setData).toHaveBeenCalledTimes(dataCalls + 1));
expect(setStyle).toHaveBeenCalledTimes(styleCalls);
expect(removeLabels).toHaveBeenCalledTimes(removeCalls + 1);
expect(addLabels).toHaveBeenCalledTimes(addCalls + 1);
const replacementLabels = addLabels.mock.calls[addLabels.mock.calls.length - 1]?.[0] as TestLabelMarker[];
expect(replacementLabels).toHaveLength(1);
expect((replacementLabels[0].options.text as { content: string }).content).toBe('粤A12345');
});
test('converts AMap GCJ-02 bounds back to WGS-84 before requesting monitor data', async () => {
const [west, south] = wgs84ToGcj02(113, 22);
const [east, north] = wgs84ToGcj02(114, 24);
getZoom.mockReturnValue(13);
getBounds.mockReturnValue({
getSouthWest: () => ({ getLng: () => west, getLat: () => south }),
getNorthEast: () => ({ getLng: () => east, getLat: () => north })
});
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
window.AMapLoader = { load: vi.fn(async () => amapMock()) };
const onViewportChange = vi.fn();
render(<FleetMap vehicles={[]} monitorMap={pointMap} onSelect={() => undefined} onViewportChange={onViewportChange} />);
await waitFor(() => expect(onViewportChange).toHaveBeenCalled());
const viewport = onViewportChange.mock.calls[onViewportChange.mock.calls.length - 1]?.[0] as { zoom: number; bounds: string };
expect(viewport.zoom).toBe(13);
const bounds = viewport.bounds.split(',').map(Number);
expect(Math.abs(bounds[0] - 113)).toBeLessThan(0.0005);
expect(Math.abs(bounds[1] - 22)).toBeLessThan(0.0005);
expect(Math.abs(bounds[2] - 114)).toBeLessThan(0.0005);
expect(Math.abs(bounds[3] - 24)).toBeLessThan(0.0005);
});
test('renders one selected plate and smoothly follows it until the map is dragged', async () => {
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
window.AMapLoader = { load: vi.fn(async () => amapMock()) };
const view = render(
<FleetMap
vehicles={[]}
monitorMap={pointMap}
selectedVin="LTEST000000000001"
onSelect={() => undefined}
/>
);
await waitFor(() => expect(addLabels).toHaveBeenCalled());
const renderedLabels = addLabels.mock.calls[addLabels.mock.calls.length - 1]?.[0] as TestLabelMarker[];
const selectedPlate = renderedLabels.find((marker) => (marker.options.text as { content: string }).content === '粤A12345');
const floatingPlate = renderedLabels.find((marker) => (marker.options.text as { content: string }).content === '粤B67890');
expect(selectedPlate).toBeDefined();
expect(floatingPlate).toBeDefined();
expect((selectedPlate!.options.text as { style: unknown }).style).toEqual((floatingPlate!.options.text as { style: unknown }).style);
expect(selectedPlate!.options).toEqual(expect.objectContaining({ rank: 100, zIndex: 10 }));
expect(floatingPlate!.options).toEqual(expect.objectContaining({ rank: 1, zIndex: 1 }));
expect((floatingPlate!.options.text as { style: Record<string, unknown> }).style).toEqual(expect.objectContaining({
fillColor: '#174d9f',
backgroundColor: '#eef5ff',
borderColor: '#7fb0fa',
borderWidth: 1,
borderRadius: 6,
padding: [5, 9],
fontSize: 11,
shadowColor: 'rgba(18, 104, 243, 0.18)',
shadowBlur: 14,
shadowOffsetY: 5
}));
expect(mapOptions).toContainEqual(expect.objectContaining({ mapStyle: 'amap://styles/whitesmoke' }));
await waitFor(() => expect(markerOptions).toContainEqual(expect.objectContaining({
content: expect.stringContaining('粤A12345')
})));
expect(markerOptions[markerOptions.length - 1]?.content).not.toContain('<span>');
expect(markerSetMap).toHaveBeenCalled();
expect(renderedLabels).toHaveLength(2);
expect(setZoomAndCenter).toHaveBeenCalledTimes(1);
expect(setZoomAndCenter).toHaveBeenLastCalledWith(15, wgs84ToGcj02(113.26, 23.13));
view.rerender(
<FleetMap
vehicles={[]}
monitorMap={{
...pointMap,
asOf: '2026-07-14T01:00:15Z',
points: [{ ...pointMap.points[0], longitude: 113.27, latitude: 23.14 }]
}}
selectedVin="LTEST000000000001"
onSelect={() => undefined}
/>
);
await waitFor(() => expect(markerSetPosition).toHaveBeenLastCalledWith(wgs84ToGcj02(113.27, 23.14)));
expect(panTo).toHaveBeenLastCalledWith(wgs84ToGcj02(113.27, 23.14), 650);
expect(setZoomAndCenter).toHaveBeenCalledTimes(1);
const follow = screen.getByRole('button', { name: '跟随车辆' });
expect(follow).toHaveAttribute('aria-pressed', 'true');
act(() => mapHandlers.get('dragstart')?.({}));
expect(follow).toHaveAttribute('aria-pressed', 'false');
const panCountAfterDrag = panTo.mock.calls.length;
view.rerender(
<FleetMap
vehicles={[]}
monitorMap={{
...pointMap,
asOf: '2026-07-14T01:00:30Z',
points: [{ ...pointMap.points[0], longitude: 113.29, latitude: 23.16 }, pointMap.points[1]]
}}
selectedVin="LTEST000000000001"
onSelect={() => undefined}
/>
);
await waitFor(() => expect(markerSetPosition).toHaveBeenLastCalledWith(wgs84ToGcj02(113.29, 23.16)));
expect(panTo).toHaveBeenCalledTimes(panCountAfterDrag);
fireEvent.click(follow);
await waitFor(() => expect(follow).toHaveAttribute('aria-pressed', 'true'));
expect(panTo).toHaveBeenLastCalledWith(wgs84ToGcj02(113.29, 23.16), 650);
getZoom.mockReturnValue(20);
view.rerender(
<FleetMap
vehicles={[]}
monitorMap={{ ...pointMap, asOf: '2026-07-14T01:00:45Z' }}
selectedVin="LTEST000000000002"
onSelect={() => undefined}
/>
);
await waitFor(() => expect(panTo).toHaveBeenLastCalledWith(wgs84ToGcj02(113.28, 23.15), 650));
expect(setZoomAndCenter).toHaveBeenCalledTimes(1);
const toggle = screen.getByRole('button', { name: '悬浮车牌' });
expect(toggle).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(toggle);
await waitFor(() => expect(toggle).toHaveAttribute('aria-pressed', 'false'));
expect(removeLabels).toHaveBeenCalled();
const hiddenFloatingLabels = removeLabels.mock.calls[removeLabels.mock.calls.length - 1]?.[0] as TestLabelMarker[];
expect(hiddenFloatingLabels).toHaveLength(1);
expect((hiddenFloatingLabels[0].options.text as { content: string }).content).toBe('粤A12345');
});
test('renders every nearby plate with staggered positions at maximum zoom', async () => {
getZoom.mockReturnValue(20);
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
window.AMapLoader = { load: vi.fn(async () => amapMock()) };
const crowdedMap: MonitorMapResponse = {
...pointMap,
zoom: 20,
total: 3,
points: [
pointMap.points[0],
{ ...pointMap.points[1], longitude: 113.26001, latitude: 23.13001 },
{ ...pointMap.points[1], vin: 'LTEST000000000003', plate: '粤C24680', longitude: 113.26002, latitude: 23.13002 }
]
};
const view = render(<FleetMap vehicles={[]} monitorMap={crowdedMap} onSelect={() => undefined} />);
await waitFor(() => expect(addLabels).toHaveBeenCalled());
expect(labelLayerOptions).toContainEqual(expect.objectContaining({ zooms: [19, 20], zIndex: 110, collision: false, allowCollision: true }));
const denseMarkers = addLabels.mock.calls[addLabels.mock.calls.length - 1]?.[0] as TestLabelMarker[];
expect(denseMarkers).toHaveLength(3);
const textOffsets = denseMarkers.map((marker) => JSON.stringify((marker.options.text as { offset: [number, number] }).offset));
expect(new Set(textOffsets).size).toBe(3);
expect(denseMarkers.every((marker) => marker.options.icon == null)).toBe(true);
const labelRenderCount = addLabels.mock.calls.length;
view.rerender(<FleetMap vehicles={[]} monitorMap={crowdedMap} selectedVin="LTEST000000000002" onSelect={() => undefined} />);
await waitFor(() => expect(addLabels.mock.calls.length).toBeGreaterThan(labelRenderCount));
const selectedDenseMarkers = addLabels.mock.calls[addLabels.mock.calls.length - 1]?.[0] as TestLabelMarker[];
expect(selectedDenseMarkers).toHaveLength(1);
expect(selectedDenseMarkers.every((marker) => marker.options.icon == null)).toBe(true);
expect(selectedDenseMarkers.find((marker) => marker.options.rank === 100)).toBeDefined();
});