Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.test.tsx
2026-07-16 17:08:54 +08:00

294 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import StatisticsPage from './StatisticsPage';
import { buildMonitorPath, withMonitorReturn } from '../routing/monitorContext';
import { ROUTER_FUTURE } from '../routing/routerConfig';
const mocks = vi.hoisted(() => ({ mileageStatistics: vi.fn(), dailyMileage: vi.fn(), vehicles: vi.fn(), vehicleCoverage: vi.fn() }));
const exportMocks = vi.hoisted(() => ({ createMileageExportStream: vi.fn(), appendRows: vi.fn(), finish: vi.fn(), dispose: vi.fn() }));
vi.mock('../../api/client', () => ({ api: mocks }));
vi.mock('../domain/mileageExport', () => ({ createMileageExportStream: exportMocks.createMileageExportStream }));
afterEach(() => { cleanup(); vi.restoreAllMocks(); window.localStorage.clear(); Object.values(mocks).forEach((mock) => mock.mockReset()); Object.values(exportMocks).forEach((mock) => mock.mockReset()); });
function renderPage(initialEntry = '/statistics') {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={[initialEntry]}><StatisticsPage /></MemoryRouter></QueryClientProvider>);
}
function prepareData() {
exportMocks.finish.mockResolvedValue(undefined);
exportMocks.createMileageExportStream.mockReturnValue({ appendRows: exportMocks.appendRows, finish: exportMocks.finish, dispose: exportMocks.dispose });
mocks.mileageStatistics.mockResolvedValue({
dateFrom: '2026-07-01', dateTo: '2026-07-14', vehicleCount: 1, recordCount: 2, sourceCount: 2,
periodMileageKm: 193.3, fleetLatestMileageKm: 119925, averageMileagePerVin: 193.3, averageDailyMileageKm: 96.65,
trend: [], ranking: [{ vin: 'LTEST000000000001', plate: '粤A12345', mileageKm: 193.3, latestMileageKm: 119925, activeDays: 2 }],
asOf: '2026-07-14 13:20:00', evidence: 'production mileage evidence'
});
mocks.dailyMileage.mockResolvedValue({ items: [
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 119731.7, endMileageKm: 119820.4, dailyMileageKm: 88.7, source: 'GB32960' },
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-14', startMileageKm: 119820.4, endMileageKm: 119925, dailyMileageKm: 104.6, source: 'GB32960' }
], total: 2, limit: 10000, offset: 0 });
mocks.vehicles.mockResolvedValue({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345', phone: '', oem: '', protocol: 'GB32960', online: true, lastSeen: '', locationText: '', bindingScore: 100 }], total: 1, limit: 12, offset: 0 });
mocks.vehicleCoverage.mockResolvedValue({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345' }], total: 1, limit: 20, offset: 0 });
}
test('renders only the desktop matrix with dates as columns and a period total', async () => {
prepareData();
const view = renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
expect(await screen.findByText('车辆每日里程')).toBeInTheDocument();
expect((await screen.findAllByText('193.3 km')).length).toBeGreaterThan(0);
expect(screen.getAllByText('区间总里程').length).toBeGreaterThan(1);
expect(screen.getAllByText('104.6 km').length).toBeGreaterThan(0);
expect(screen.getAllByText('88.7 km').length).toBeGreaterThan(0);
expect(screen.getAllByText('7/13').length).toBeGreaterThan(0);
expect(screen.getAllByText('7/14').length).toBeGreaterThan(0);
expect(screen.getAllByText('粤A12345').length).toBeGreaterThan(0);
expect(screen.queryByText('当日起始里程')).not.toBeInTheDocument();
expect(screen.queryByText('数据来源')).not.toBeInTheDocument();
expect(screen.queryByRole('img', { name: '每日行驶里程趋势图' })).not.toBeInTheDocument();
expect(screen.queryByText('车辆里程排名')).not.toBeInTheDocument();
expect(screen.getByText('已绑定主车辆')).toBeInTheDocument();
expect(screen.getByText('已选择 1 辆')).toBeInTheDocument();
expect(view.container.querySelector('.v2-mileage-table-wrap')).toBeInTheDocument();
expect(view.container.querySelector('.v2-mileage-mobile-list')).not.toBeInTheDocument();
await waitFor(() => expect(mocks.dailyMileage).toHaveBeenCalledTimes(1));
expect(mocks.dailyMileage.mock.calls[0][0].get('limit')).toBe('10000');
expect(mocks.dailyMileage.mock.calls[0][0].get('protocols')).toBe('GB32960,JT808,YUTONG_MQTT');
});
test('defaults to seven days and supports today and yesterday shortcuts', async () => {
prepareData();
renderPage();
const localDate = (value: Date) => new Date(value.getTime() - value.getTimezoneOffset() * 60_000).toISOString().slice(0, 10);
const today = localDate(new Date());
const yesterday = localDate(new Date(Date.now() - 86_400_000));
const sevenDaysAgo = localDate(new Date(Date.now() - 6 * 86_400_000));
await waitFor(() => expect(mocks.mileageStatistics).toHaveBeenCalled());
expect(mocks.mileageStatistics.mock.calls[0][0].get('dateFrom')).toBe(sevenDaysAgo);
expect(mocks.mileageStatistics.mock.calls[0][0].get('dateTo')).toBe(today);
fireEvent.click(screen.getByRole('button', { name: '昨天' }));
await waitFor(() => {
const params = mocks.mileageStatistics.mock.calls[mocks.mileageStatistics.mock.calls.length - 1]?.[0];
expect(params.get('dateFrom')).toBe(yesterday);
expect(params.get('dateTo')).toBe(yesterday);
});
fireEvent.click(screen.getByRole('button', { name: '今天' }));
await waitFor(() => {
const params = mocks.mileageStatistics.mock.calls[mocks.mileageStatistics.mock.calls.length - 1]?.[0];
expect(params.get('dateFrom')).toBe(today);
expect(params.get('dateTo')).toBe(today);
});
});
test('preserves the exact monitor return after a mileage date shortcut', async () => {
prepareData();
const monitorPath = buildMonitorPath({
mode: 'list', keyword: '', protocol: '', status: '', selectedVin: 'LTEST000000000001', detailOpen: false,
viewport: { zoom: 5, bounds: '' }, listOffset: 40, listLimit: 20, hasViewport: false
});
renderPage(withMonitorReturn('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14', monitorPath));
expect(await screen.findByRole('link', { name: /返回全局监控/ })).toHaveAttribute('href', monitorPath);
fireEvent.click(screen.getByRole('button', { name: '今天' }));
expect(await screen.findByRole('link', { name: /返回全局监控/ })).toHaveAttribute('href', monitorPath);
});
test('rejects overlong mileage ranges before querying or rendering a huge matrix', async () => {
prepareData();
const view = renderPage('/statistics?vins=LTEST000000000001&dateFrom=2025-01-01&dateTo=2026-07-14');
expect(await screen.findByRole('alert')).toHaveTextContent('单次最多查询 366 个自然日');
expect(mocks.mileageStatistics).not.toHaveBeenCalled();
expect(mocks.dailyMileage).not.toHaveBeenCalled();
expect(view.container.querySelectorAll('.v2-mileage-table th.is-date')).toHaveLength(0);
expect(screen.getByRole('button', { name: '导出 Excel' })).toBeDisabled();
});
test('removes the previous mileage scope while a new date range is loading', async () => {
prepareData();
renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
expect((await screen.findAllByText('193.3 km')).length).toBeGreaterThan(0);
expect(screen.getAllByText('104.6 km').length).toBeGreaterThan(0);
await waitFor(() => expect(mocks.dailyMileage).toHaveBeenCalledTimes(1));
mocks.mileageStatistics.mockImplementation(() => new Promise(() => undefined));
mocks.dailyMileage.mockImplementation(() => new Promise(() => undefined));
fireEvent.click(screen.getByRole('button', { name: '近 7 天' }));
expect(await screen.findByText('正在查询里程')).toBeInTheDocument();
expect(screen.getAllByText('正在按新筛选范围查询').length).toBeGreaterThan(0);
expect(screen.queryByText('193.3 km')).not.toBeInTheDocument();
expect(screen.queryByText('104.6 km')).not.toBeInTheDocument();
expect(screen.queryByText('88.7 km')).not.toBeInTheDocument();
expect(screen.getAllByText('2026-07-10 至 2026-07-16').length).toBeGreaterThan(0);
});
test('uses one responsive mileage matrix without viewport listeners', async () => {
prepareData();
const view = renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
expect(await screen.findByText('车辆每日里程')).toBeInTheDocument();
await waitFor(() => expect(view.container.querySelectorAll('.v2-mileage-table tbody tr')).toHaveLength(1));
expect(view.container.querySelector('.v2-mileage-table-wrap')).toBeInTheDocument();
expect(view.container.querySelectorAll('.v2-mileage-table th.is-date')).toHaveLength(2);
expect(view.container.querySelector('.v2-mileage-table th.is-plate')).toHaveTextContent('车牌');
expect(view.container.querySelector('.v2-mileage-table th.is-total')).toHaveTextContent('区间总里程');
expect(view.container.querySelector('.v2-mileage-mobile-list')).not.toBeInTheDocument();
});
test('supports searching and selecting license plates before querying exact VINs', async () => {
prepareData();
renderPage();
const search = screen.getByRole('textbox', { name: '搜索车牌' });
fireEvent.focus(search);
fireEvent.change(search, { target: { value: '粤A12' } });
expect(await screen.findByRole('option', { name: /粤A12345/ })).toBeInTheDocument();
fireEvent.click(screen.getByRole('option', { name: /粤A12345/ }));
fireEvent.click(screen.getByRole('button', { name: '查询' }));
await waitFor(() => {
const calls = mocks.mileageStatistics.mock.calls;
expect(calls[calls.length - 1]?.[0].get('vins')).toBe('LTEST000000000001');
});
expect(screen.getByTitle('LTEST000000000001')).toHaveTextContent('粤A12345');
});
test('shows and recovers from a failed license plate candidate query', async () => {
prepareData();
mocks.vehicles.mockRejectedValueOnce(new Error('车辆目录查询超时')).mockResolvedValueOnce({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345' }], total: 1, limit: 12, offset: 0 });
renderPage();
fireEvent.focus(screen.getByRole('textbox', { name: '搜索车牌' }));
expect(await screen.findByRole('alert')).toHaveTextContent('车辆目录查询超时');
expect(screen.queryByText('没有匹配的车牌')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '重试' }));
expect(await screen.findByRole('option', { name: /粤A12345/ })).toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(mocks.vehicles).toHaveBeenCalledTimes(2);
});
test('lets users disable mileage sources and persists the source priority', async () => {
prepareData();
renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
fireEvent.click(screen.getByRole('button', { name: /数据源/ }));
expect(screen.getByRole('dialog', { name: '数据源策略配置' })).toBeInTheDocument();
expect(screen.getByText('GPS 里程')).toBeInTheDocument();
expect(screen.getAllByText('仪表盘里程')).toHaveLength(2);
fireEvent.click(screen.getByRole('switch', { name: '禁用 国标 GB32960' }));
fireEvent.click(screen.getByRole('button', { name: '上移 宇通 MQTT' }));
fireEvent.click(screen.getByRole('button', { name: '查询' }));
await waitFor(() => {
const calls = mocks.dailyMileage.mock.calls;
expect(calls[calls.length - 1]?.[0].get('protocols')).toBe('YUTONG_MQTT,JT808');
});
expect(window.localStorage.getItem('vehicle-platform:mileage-source-strategy')).toContain('YUTONG_MQTT');
expect(screen.getByText('来源优先级YUTONG_MQTT JT808')).toBeInTheDocument();
});
test('paginates all unique vehicles when no license plate is selected', async () => {
prepareData();
const firstPage = Array.from({ length: 20 }, (_, index) => ({ vin: `VIN${String(index + 1).padStart(14, '0')}`, plate: `粤A${String(index + 1).padStart(5, '0')}` }));
const secondPage = Array.from({ length: 12 }, (_, index) => ({ vin: `VIN${String(index + 21).padStart(14, '0')}`, plate: `粤A${String(index + 21).padStart(5, '0')}` }));
mocks.vehicleCoverage.mockImplementation((params: URLSearchParams) => Promise.resolve({
items: params.get('offset') === '20' ? secondPage : firstPage,
total: 32,
limit: 20,
offset: Number(params.get('offset') ?? 0)
}));
mocks.dailyMileage.mockResolvedValue({ items: [], total: 0, limit: 10000, offset: 0 });
renderPage('/statistics?dateFrom=2026-07-13&dateTo=2026-07-14');
expect((await screen.findAllByText('粤A00001')).length).toBeGreaterThan(0);
expect(screen.getByText('第 1 / 2 页 · 共 32 辆 · 每页 20 辆')).toBeInTheDocument();
expect(screen.getByText('档案口径 · 1 辆有里程')).toBeInTheDocument();
expect(mocks.vehicleCoverage.mock.calls[0][0].get('bindingStatus')).toBe('bound');
await waitFor(() => expect(mocks.dailyMileage.mock.calls[mocks.dailyMileage.mock.calls.length - 1]?.[0].get('vins')).toContain('VIN00000000000001'));
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
expect((await screen.findAllByText('粤A00021')).length).toBeGreaterThan(0);
expect(screen.getByText('第 2 / 2 页 · 共 32 辆 · 每页 20 辆')).toBeInTheDocument();
await waitFor(() => expect(mocks.vehicleCoverage.mock.calls[mocks.vehicleCoverage.mock.calls.length - 1]?.[0].get('offset')).toBe('20'));
});
test('exports the full fleet mileage with one paginated query instead of VIN batches', async () => {
prepareData();
const exportRows = [
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 119731.7, endMileageKm: 119820.4, dailyMileageKm: 88.7, source: 'GB32960' },
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-14', startMileageKm: 119820.4, endMileageKm: 119925, dailyMileageKm: 104.6, source: 'GB32960' }
];
mocks.dailyMileage.mockImplementation((params: URLSearchParams) => {
if (params.get('vehicleScope') !== 'bound') return Promise.resolve({ items: exportRows, total: 2, limit: 10000, offset: 0 });
const offset = Number(params.get('offset') ?? 0);
return Promise.resolve({ items: exportRows.slice(offset, offset + 1), total: 2, limit: 1, offset });
});
renderPage('/statistics?dateFrom=2026-07-13&dateTo=2026-07-14');
await waitFor(() => expect(screen.getByRole('button', { name: /导出 Excel/ })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: /导出 Excel/ }));
await waitFor(() => expect(exportMocks.finish).toHaveBeenCalledTimes(1));
const exportMileageCall = mocks.dailyMileage.mock.calls[mocks.dailyMileage.mock.calls.length - 1][0] as URLSearchParams;
expect(exportMileageCall.get('vins')).toBeNull();
expect(exportMileageCall.get('vehicleScope')).toBe('bound');
expect(exportMocks.appendRows).toHaveBeenCalledTimes(2);
expect(exportMocks.appendRows.mock.calls.map(([rows]) => rows.map((row: { date: string }) => row.date))).toEqual([['2026-07-13'], ['2026-07-14']]);
expect(exportMocks.finish.mock.calls[0][0]).toHaveLength(1);
expect(mocks.vehicleCoverage.mock.calls[mocks.vehicleCoverage.mock.calls.length - 1][1]).toBeInstanceOf(AbortSignal);
expect(mocks.dailyMileage.mock.calls[mocks.dailyMileage.mock.calls.length - 1][1]).toBeInstanceOf(AbortSignal);
expect(exportMocks.createMileageExportStream.mock.calls[0][1]).toBeInstanceOf(AbortSignal);
expect(screen.getByText(/已导出 1 辆车/)).toBeInTheDocument();
});
test('shows export progress and lets the user cancel the active request', async () => {
prepareData();
let exportSignal: AbortSignal | undefined;
mocks.vehicleCoverage.mockImplementation((params: URLSearchParams, signal?: AbortSignal) => {
if (params.get('limit') !== String(2_000)) {
return Promise.resolve({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345' }], total: 1, limit: 20, offset: 0 });
}
exportSignal = signal;
return new Promise((_resolve, reject) => {
signal?.addEventListener('abort', () => reject(new DOMException('cancelled', 'AbortError')), { once: true });
});
});
renderPage('/statistics?dateFrom=2026-07-13&dateTo=2026-07-14');
await waitFor(() => expect(screen.getByRole('button', { name: '导出 Excel' })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: '导出 Excel' }));
expect(await screen.findByRole('progressbar', { name: '正在读取车辆档案' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '取消导出' })).toBeEnabled();
fireEvent.click(screen.getByRole('button', { name: '取消导出' }));
await waitFor(() => expect(exportSignal?.aborted).toBe(true));
expect(await screen.findByText(/已取消导出/)).toBeInTheDocument();
expect(exportMocks.createMileageExportStream).not.toHaveBeenCalled();
});
test('aborts an active export when the mileage route unmounts', async () => {
prepareData();
let exportSignal: AbortSignal | undefined;
mocks.vehicleCoverage.mockImplementation((params: URLSearchParams, signal?: AbortSignal) => {
if (params.get('limit') !== String(2_000)) {
return Promise.resolve({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345' }], total: 1, limit: 20, offset: 0 });
}
exportSignal = signal;
return new Promise(() => undefined);
});
const view = renderPage('/statistics?dateFrom=2026-07-13&dateTo=2026-07-14');
await waitFor(() => expect(screen.getByRole('button', { name: '导出 Excel' })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: '导出 Excel' }));
expect(await screen.findByRole('progressbar', { name: '正在读取车辆档案' })).toBeInTheDocument();
view.unmount();
expect(exportSignal?.aborted).toBe(true);
expect(exportMocks.createMileageExportStream).not.toHaveBeenCalled();
});