feat: build vehicle data platform and production pipeline
This commit is contained in:
351
vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx
Normal file
351
vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
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 clearLabels = vi.fn();
|
||||
const setLabelsMap = vi.fn();
|
||||
const markerSetMap = vi.fn();
|
||||
const markerSetPosition = vi.fn();
|
||||
const setZoomAndCenter = vi.fn();
|
||||
const panTo = 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 = vi.fn();
|
||||
on = vi.fn((event: string, handler: (value: unknown) => void) => mapHandlers.set(event, handler));
|
||||
getZoom = getZoom;
|
||||
getBounds = getBounds;
|
||||
setZoomAndCenter = setZoomAndCenter;
|
||||
panTo = panTo;
|
||||
}
|
||||
|
||||
class TestMassMarks {
|
||||
on = vi.fn();
|
||||
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;
|
||||
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();
|
||||
clearLabels.mockReset();
|
||||
setLabelsMap.mockReset();
|
||||
markerSetMap.mockReset();
|
||||
markerSetPosition.mockReset();
|
||||
setZoomAndCenter.mockReset();
|
||||
panTo.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('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(clearLabels).toHaveBeenCalled();
|
||||
const selectedOnlyLabels = addLabels.mock.calls[addLabels.mock.calls.length - 1]?.[0] as TestLabelMarker[];
|
||||
expect(selectedOnlyLabels).toHaveLength(1);
|
||||
expect((selectedOnlyLabels[0].options.text as { content: string }).content).toBe('粤B67890');
|
||||
});
|
||||
|
||||
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.every((marker) => marker.options.icon == null)).toBe(true);
|
||||
expect(selectedDenseMarkers.find((marker) => marker.options.rank === 100)).toBeDefined();
|
||||
});
|
||||
Reference in New Issue
Block a user