import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { VehicleRealtimeRow } from '../../api/types';
import { api } from '../../api/client';
import MonitorPage from './MonitorPage';
import { ROUTER_FUTURE } from '../routing/routerConfig';
const vehicles = [{
vin: 'LTEST000000000001', plate: '粤A12345', protocols: ['JT808'], primaryProtocol: 'JT808',
locationAvailable: true, longitude: 113.26, latitude: 23.13, speedAvailable: true, speedKmh: 42,
socAvailable: false, socPercent: 0, mileageAvailable: true, totalMileageKm: 1234, todayMileageAvailable: true, todayMileageKm: 18.6,
lastSeen: '2026-07-14T01:00:00Z', online: true, sourceCount: 1, onlineSourceCount: 1
}, {
vin: 'LTEST000000000002', plate: '粤B67890', protocols: ['JT808'], primaryProtocol: 'JT808',
locationAvailable: true, longitude: 113.28, latitude: 23.15, speedAvailable: true, speedKmh: 0,
socAvailable: false, socPercent: 0, mileageAvailable: true, totalMileageKm: 2234, todayMileageAvailable: false, todayMileageKm: 0,
lastSeen: '2026-07-14T01:00:00Z', online: true, sourceCount: 1, onlineSourceCount: 1
}] as VehicleRealtimeRow[];
const monitorMap = { clusters: [], points: [], total: 2 };
const fleetMapRenderSpy = vi.hoisted(() => vi.fn());
const vehicleCardArgsSpy = vi.hoisted(() => vi.fn());
const monitorDataArgsSpy = vi.hoisted(() => vi.fn());
const qrToDataURLSpy = vi.hoisted(() => vi.fn());
const monitorQueryFlags = vi.hoisted(() => ({ isLoading: false, isFetching: false, isPlaceholderData: false }));
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
vi.mock('../map/FleetMap', () => ({
FleetMap: ({ selectedVin, onSelectVin, initialViewport }: { selectedVin?: string; onSelectVin?: (vin: string) => void; initialViewport?: { zoom: number; bounds: string } }) => {
fleetMapRenderSpy(selectedVin, initialViewport);
return
;
}
}));
vi.mock('qrcode', () => ({ default: { toDataURL: qrToDataURLSpy } }));
vi.mock('../hooks/useMonitorData', () => ({
MAX_MONITOR_SEARCH_TERMS: 100,
MONITOR_REFRESH: { selected: 10_000, fleet: 15_000, summary: 30_000 },
parseMonitorSearchTerms: (value: string) => Array.from(new Set(value.split(/[\s,,、;;]+/).map((item) => item.trim().toLocaleUpperCase()).filter(Boolean))).slice(0, 100),
monitorQueryParams: (filters: { keyword: string; protocol: string; status: string }, limit: number) => {
const params = new URLSearchParams({ limit: String(limit) });
const terms = Array.from(new Set(filters.keyword.split(/[\s,,、;;]+/).map((item) => item.trim().toLocaleUpperCase()).filter(Boolean))).slice(0, 100);
if (terms.length === 1) params.set('keyword', terms[0]);
if (terms.length > 1) params.set('keywords', terms.join(','));
if (filters.protocol) params.set('protocol', filters.protocol);
if (filters.status) params.set('status', filters.status);
return params;
},
monitorFilterScope: (filters: { keyword: string; protocol: string; status: string }) => {
const params = new URLSearchParams();
if (filters.keyword) params.set('keyword', filters.keyword);
if (filters.protocol) params.set('protocol', filters.protocol);
if (filters.status) params.set('status', filters.status);
return params.toString();
},
useMonitorData: (...args: unknown[]) => {
monitorDataArgsSpy(...args);
return {
summary: { data: { totalVehicles: 2, locationVehicles: 2, noLocationVehicles: 0, onlineVehicles: 2, offlineVehicles: 0, drivingVehicles: 1, idleVehicles: 1, frameToday: 10 } },
vehicles: { data: { items: vehicles, total: 2 }, isError: false, ...monitorQueryFlags },
map: { data: monitorMap, isPlaceholderData: monitorQueryFlags.isPlaceholderData },
selectedVehicle: { data: { items: [] } }
};
},
useMonitorVehicleCard: (...args: unknown[]) => {
vehicleCardArgsSpy(...args);
return { detail: {}, activeAlerts: {}, address: {} };
}
}));
afterEach(() => {
cleanup();
monitorQueryFlags.isLoading = false;
monitorQueryFlags.isFetching = false;
monitorQueryFlags.isPlaceholderData = false;
vehicleCardArgsSpy.mockClear();
monitorDataArgsSpy.mockClear();
qrToDataURLSpy.mockReset();
if (originalClipboard) Object.defineProperty(navigator, 'clipboard', originalClipboard);
else Reflect.deleteProperty(navigator, 'clipboard');
vi.restoreAllMocks();
});
test('starts without a selection and supports expand, collapse, reselection, and clear', () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render();
const workspace = view.container.querySelector('.v2-monitor-workspace')!;
const firstVehicle = screen.getByRole('button', { name: /粤A12345 LTEST000000000001/ });
const secondVehicle = screen.getByRole('button', { name: /粤B67890 LTEST000000000002/ });
expect(workspace).not.toHaveClass('is-detail-open');
expect(workspace).not.toHaveClass('is-detail-collapsed');
expect(firstVehicle).not.toHaveClass('is-selected');
expect(screen.queryByRole('button', { name: '取消选择车辆' })).not.toBeInTheDocument();
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-selected-vin', '');
expect(screen.getByText('车辆总数')).toBeInTheDocument();
expect(screen.getAllByText('无实时位置').length).toBeGreaterThanOrEqual(2);
expect(screen.getByRole('status', { name: '搜索和筛选条件修改后自动生效' })).toHaveTextContent('实时筛选');
expect(screen.queryByRole('button', { name: '筛选' })).not.toBeInTheDocument();
expect(screen.queryByText('接入车辆')).not.toBeInTheDocument();
fireEvent.click(firstVehicle);
expect(workspace).toHaveClass('is-detail-open');
expect(firstVehicle).toHaveClass('is-selected');
expect(screen.getByRole('button', { name: '取消选择车辆' })).toBeInTheDocument();
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-selected-vin', 'LTEST000000000001');
expect(vehicleCardArgsSpy).toHaveBeenLastCalledWith('LTEST000000000001', vehicles[0], true);
expect(screen.getByText('SOC').parentElement).toHaveTextContent('SOC—');
const mapRendersAfterSelection = fleetMapRenderSpy.mock.calls.length;
const cardCallsAfterSelection = vehicleCardArgsSpy.mock.calls.length;
fireEvent.click(screen.getByRole('button', { name: '收起车辆详情' }));
expect(workspace).toHaveClass('is-detail-collapsed');
expect(firstVehicle).toHaveClass('is-selected');
expect(screen.getByRole('button', { name: '展开车辆详情' })).toBeInTheDocument();
expect(fleetMapRenderSpy).toHaveBeenCalledTimes(mapRendersAfterSelection);
expect(vehicleCardArgsSpy).toHaveBeenCalledTimes(cardCallsAfterSelection);
fireEvent.click(secondVehicle);
expect(workspace).toHaveClass('is-detail-open');
expect(secondVehicle).toHaveClass('is-selected');
expect(screen.queryByRole('button', { name: '展开车辆详情' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '取消选择车辆' }));
expect(workspace).not.toHaveClass('is-detail-open');
expect(workspace).not.toHaveClass('is-detail-collapsed');
expect(secondVehicle).not.toHaveClass('is-selected');
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-selected-vin', '');
fireEvent.click(screen.getByRole('button', { name: '选择地图车辆' }));
expect(workspace).toHaveClass('is-detail-open');
expect(secondVehicle).toHaveClass('is-selected');
});
test('restores URL-backed monitor context and carries it into every vehicle workflow', () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const entry = '/monitor?keyword=%E7%B2%A4A12345&protocol=JT808&status=online&selectedVin=LTEST000000000001&detail=collapsed&zoom=13&bounds=113.100000%2C23.000000%2C113.400000%2C23.300000';
render();
expect(screen.getByRole('textbox', { name: '搜索车辆' })).toHaveValue('粤A12345');
expect(screen.getByRole('combobox', { name: '协议' })).toHaveValue('JT808');
expect(screen.getByRole('combobox', { name: '在线状态' })).toHaveValue('online');
expect(screen.getByRole('button', { name: '展开车辆详情' })).toBeInTheDocument();
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-initial-zoom', '13');
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-initial-bounds', '113.100000,23.000000,113.400000,23.300000');
fireEvent.click(screen.getByRole('button', { name: '展开车辆详情' }));
for (const name of ['单车详情', '轨迹回放', '历史数据', '里程查询']) {
const target = new URL(screen.getByRole('link', { name }).getAttribute('href')!, 'https://vehicle-platform.invalid');
const monitorReturn = target.searchParams.get('monitorReturn');
expect(monitorReturn).toContain('/monitor?');
expect(monitorReturn).toContain('selectedVin=LTEST000000000001');
expect(monitorReturn).toContain('zoom=13');
}
});
test('switches to a lightweight realtime list and resolves addresses only on demand', async () => {
const row = vehicles[0];
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [row], total: 1, limit: 50, offset: 0 });
const reverseGeocode = vi.spyOn(api, 'reverseGeocode').mockResolvedValue({ provider: 'AMap', longitude: 113.26, latitude: 23.13, formattedAddress: '广东省广州市天河区测试路' });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render();
fireEvent.click(screen.getByRole('button', { name: /列表/ }));
expect(await screen.findByText('车辆实时列表')).toBeInTheDocument();
expect(screen.getByText('覆盖全部授权车辆;实时字段缺失时显示“—”,文字地址按需解析')).toBeInTheDocument();
expect(await screen.findAllByText('粤A12345')).toHaveLength(1);
expect(screen.getAllByText('42')).toHaveLength(1);
expect(screen.getAllByText(/1,234/)).toHaveLength(1);
expect(screen.getAllByText(/18\.6/)).toHaveLength(1);
expect(screen.getAllByText(/113\.260000/)).toHaveLength(1);
expect(reverseGeocode).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '解析粤A12345位置' }));
expect(await screen.findByText('广东省广州市天河区测试路')).toBeInTheDocument();
expect(screen.getByText(/解析于 \d{2}:\d{2}/)).toBeInTheDocument();
expect(reverseGeocode).toHaveBeenCalledTimes(1);
expect(queryClient.getQueryCache().findAll({ queryKey: ['monitor', 'list-address'] })).toHaveLength(1);
expect(screen.queryByText('综合状态')).not.toBeInTheDocument();
expect(screen.queryByText('三路正常')).not.toBeInTheDocument();
expect(screen.queryByTestId('fleet-map')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /地图/ }));
await waitFor(() => expect(queryClient.getQueryCache().findAll({ queryKey: ['monitor', 'list-address'] })).toHaveLength(0));
view.unmount();
});
test('keeps authorized vehicles without realtime coordinates in the list without geocoding them', async () => {
const row = {
...vehicles[0],
vin: 'LNOLOCATION0000001',
plate: '粤A无定位',
protocols: [],
primaryProtocol: '',
locationAvailable: false,
longitude: 0,
latitude: 0,
speedAvailable: false,
speedKmh: 0,
mileageAvailable: false,
totalMileageKm: 0,
todayMileageAvailable: false,
todayMileageKm: 0,
lastSeen: '',
online: false,
sourceCount: 0,
onlineSourceCount: 0
} satisfies VehicleRealtimeRow;
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [row], total: 1, limit: 50, offset: 0 });
const reverseGeocode = vi.spyOn(api, 'reverseGeocode');
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render();
fireEvent.click(screen.getByRole('button', { name: /列表/ }));
expect(await screen.findByText('粤A无定位')).toBeInTheDocument();
expect(screen.getByText('暂无实时位置')).toBeInTheDocument();
expect(screen.getByText('从未上报')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /解析粤A无定位位置/ })).not.toBeInTheDocument();
expect(reverseGeocode).not.toHaveBeenCalled();
});
test('pauses selected-vehicle polling in list mode and resumes it when returning to the map', () => {
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: vehicles, total: 2, limit: 50, offset: 0 });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render();
fireEvent.click(screen.getByRole('button', { name: /粤A12345 LTEST000000000001/ }));
expect(monitorDataArgsSpy).toHaveBeenLastCalledWith(expect.any(Object), expect.any(Object), 'LTEST000000000001', true, true);
fireEvent.click(screen.getByRole('button', { name: /列表/ }));
expect(monitorDataArgsSpy).toHaveBeenLastCalledWith(expect.any(Object), expect.any(Object), '', false, false);
fireEvent.click(screen.getByRole('button', { name: /地图/ }));
expect(monitorDataArgsSpy).toHaveBeenLastCalledWith(expect.any(Object), expect.any(Object), 'LTEST000000000001', true, true);
});
test('loads the QR generator only after opening the mobile entry', async () => {
qrToDataURLSpy.mockResolvedValue('data:image/png;base64,vehicle-monitor');
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render();
expect(qrToDataURLSpy).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: /手机端/ }));
expect(await screen.findByRole('img', { name: '全局监控手机端二维码' })).toHaveAttribute('src', 'data:image/png;base64,vehicle-monitor');
expect(qrToDataURLSpy).toHaveBeenCalledTimes(1);
expect(qrToDataURLSpy.mock.calls[0][0]).toMatch(/\/monitor$/);
expect(qrToDataURLSpy.mock.calls[0][0]).not.toContain('token');
});
test('closes the mobile entry with Escape and reports clipboard success', async () => {
qrToDataURLSpy.mockResolvedValue('data:image/png;base64,vehicle-monitor');
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render();
fireEvent.click(screen.getByRole('button', { name: /手机端/ }));
expect(await screen.findByRole('img', { name: '全局监控手机端二维码' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '复制访问地址' }));
expect(await screen.findByRole('button', { name: '已复制访问地址' })).toBeInTheDocument();
expect(writeText).toHaveBeenCalledWith(expect.stringMatching(/\/monitor$/));
fireEvent.keyDown(window, { key: 'Escape' });
expect(screen.queryByRole('dialog', { name: '手机端入口' })).not.toBeInTheDocument();
});
test('shows a retry action when on-demand QR generation fails', async () => {
qrToDataURLSpy.mockRejectedValueOnce(new Error('canvas unavailable')).mockResolvedValue('data:image/png;base64,recovered');
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render();
fireEvent.click(screen.getByRole('button', { name: /手机端/ }));
expect(await screen.findByRole('alert')).toHaveTextContent('二维码生成失败');
fireEvent.click(screen.getByRole('button', { name: '重新生成' }));
expect(await screen.findByRole('img', { name: '全局监控手机端二维码' })).toHaveAttribute('src', 'data:image/png;base64,recovered');
expect(qrToDataURLSpy).toHaveBeenCalledTimes(2);
});
test('mounts only the mobile list representation and removes its viewport listener', async () => {
const addEventListener = vi.fn();
const removeEventListener = vi.fn();
vi.spyOn(window, 'matchMedia').mockReturnValue({
matches: true,
media: '(max-width: 760px)',
onchange: null,
addEventListener,
removeEventListener,
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(() => false)
} as unknown as MediaQueryList);
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [vehicles[0]], total: 1, limit: 50, offset: 0 });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render();
fireEvent.click(screen.getByRole('button', { name: /列表/ }));
expect(await screen.findByText('车辆实时列表')).toBeInTheDocument();
await waitFor(() => expect(view.container.querySelectorAll('.v2-monitor-mobile-cards article')).toHaveLength(1));
expect(view.container.querySelector('.v2-monitor-table-scroll')).not.toBeInTheDocument();
expect(addEventListener).toHaveBeenCalledWith('change', expect.any(Function));
view.unmount();
expect(removeEventListener).toHaveBeenCalledWith('change', expect.any(Function));
});
test('pastes, deduplicates, and submits multiple plates as one batch search', async () => {
const vehicleRealtime = vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: vehicles, total: 2, limit: 50, offset: 0 });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render();
const input = screen.getByRole('textbox', { name: '搜索车辆' });
fireEvent.paste(input, { clipboardData: { getData: () => '粤a12345\n粤B67890,粤A12345' } });
expect(input).toHaveValue('粤A12345,粤B67890');
expect(screen.getAllByText('已找到 2/2')).toHaveLength(3);
fireEvent.click(screen.getByRole('button', { name: /列表/ }));
await waitFor(() => {
const params = vehicleRealtime.mock.calls[vehicleRealtime.mock.calls.length - 1]?.[0];
expect(params?.get('keywords')).toBe('粤A12345,粤B67890');
expect(params?.has('keyword')).toBe(false);
});
});
test('opens an explicit batch editor and applies copied plate rows', async () => {
const vehicleRealtime = vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: vehicles, total: 2, limit: 50, offset: 0 });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render();
fireEvent.click(screen.getByRole('button', { name: '批量' }));
const editor = screen.getByRole('textbox', { name: /每行一个车牌/ });
fireEvent.change(editor, { target: { value: '粤a12345\n粤B67890\n粤A12345' } });
expect(screen.getByText('2', { selector: '.v2-batch-search-summary strong' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '应用搜索(2)' }));
expect(screen.queryByRole('dialog', { name: '批量搜索车辆' })).not.toBeInTheDocument();
expect(screen.getByRole('textbox', { name: '搜索车辆' })).toHaveValue('粤A12345,粤B67890');
fireEvent.click(screen.getByRole('button', { name: /列表/ }));
await waitFor(() => {
const params = vehicleRealtime.mock.calls[vehicleRealtime.mock.calls.length - 1]?.[0];
expect(params?.get('keywords')).toBe('粤A12345,粤B67890');
});
});
test('hides stale fleet rows while a pasted batch scope is still loading', () => {
monitorQueryFlags.isLoading = true;
monitorQueryFlags.isPlaceholderData = true;
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render();
fireEvent.paste(screen.getByRole('textbox', { name: '搜索车辆' }), { clipboardData: { getData: () => '粤A12345\n粤B67890' } });
expect(screen.getByText('正在查找 2 辆车')).toBeInTheDocument();
expect(screen.getByText('查询中')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /粤A12345 LTEST000000000001/ })).not.toBeInTheDocument();
});
test('keeps same-filter fleet rows visible while a viewport placeholder refreshes', () => {
monitorQueryFlags.isPlaceholderData = true;
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render();
expect(screen.getByRole('button', { name: /粤A12345 LTEST000000000001/ })).toBeInTheDocument();
expect(screen.queryByText('加载车辆')).not.toBeInTheDocument();
expect(screen.getByTestId('fleet-map')).toBeInTheDocument();
});
test('reports pasted plates that have no exact realtime match', () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render();
fireEvent.paste(screen.getByRole('textbox', { name: '搜索车辆' }), { clipboardData: { getData: () => '粤A12345\n粤Z99999' } });
expect(screen.getByText('已找到 1/2')).toBeInTheDocument();
expect(screen.getAllByText('已找到 1/2 · 未找到 1 辆')).toHaveLength(2);
expect(screen.getAllByTitle('未找到:粤Z99999')).toHaveLength(2);
});