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

440 lines
28 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, { mileageRangeContainsDate } 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() }));
const layout = vi.hoisted(() => ({ mobile: false }));
vi.mock('../../api/client', () => ({ api: mocks }));
vi.mock('../domain/mileageExport', () => ({ createMileageExportStream: exportMocks.createMileageExportStream }));
vi.mock('../hooks/useMobileLayout', () => ({ useMobileLayout: () => layout.mobile }));
afterEach(() => { cleanup(); vi.restoreAllMocks(); layout.mobile = false; 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('refreshes only ranges that include the current business date', () => {
expect(mileageRangeContainsDate({ dateFrom: '2026-07-13', dateTo: '2026-07-19' }, '2026-07-19')).toBe(true);
expect(mileageRangeContainsDate({ dateFrom: '2026-07-19', dateTo: '2026-07-19' }, '2026-07-19')).toBe(true);
expect(mileageRangeContainsDate({ dateFrom: '2026-07-13', dateTo: '2026-07-18' }, '2026-07-19')).toBe(false);
expect(mileageRangeContainsDate({ dateFrom: '2026-07-20', dateTo: '2026-07-20' }, '2026-07-19')).toBe(false);
});
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.getByLabelText('里程查询工作区')).toHaveClass('v2-workspace-command-bar', 'v2-mileage-command-bar');
expect(screen.getByRole('heading', { name: '车辆里程分析', level: 5 })).toBeInTheDocument();
expect(screen.getByLabelText('里程查询工作区')).toHaveTextContent('GB32960 优先');
expect(screen.getByRole('heading', { name: '里程范围', level: 5 })).toBeInTheDocument();
expect(screen.getByText('车牌、自然日与里程数据源策略')).toBeInTheDocument();
expect(view.container.querySelector('.v2-mileage-date-range.semi-datepicker')).toBeInTheDocument();
expect(screen.getByRole('group', { name: '快捷日期范围' })).toHaveClass('semi-button-group');
expect(screen.getByPlaceholderText('开始日期')).toHaveValue('2026-07-13');
expect(screen.getByPlaceholderText('结束日期')).toHaveValue('2026-07-14');
expect(screen.getAllByText('1 辆 · 2 天').some((element) => element.closest('.semi-tag'))).toBe(true);
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(screen.getByText('已绑定主车辆').closest('[role="listitem"]')).toHaveClass('is-primary', 'is-success');
expect(screen.getByText('车辆每日里程').closest('.semi-card')).toHaveClass('v2-mileage-results');
expect(view.container.querySelector('.v2-mileage-query-panel.semi-card')).toBeInTheDocument();
expect(view.container.querySelector('.v2-mileage-discovery-shell > .v2-mileage-command-bar')).toBeInTheDocument();
expect(view.container.querySelector('.v2-mileage-page > .v2-page-heading')).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-mileage-summary')).toHaveClass('v2-workspace-metric-rail', 'is-queue');
expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).toHaveAttribute('aria-label', '里程查询统计信息');
expect(view.container.querySelectorAll('.v2-mileage-summary .v2-workspace-metric-list > span')).toHaveLength(4);
expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list > span.is-primary')).toHaveTextContent('区间总里程193.3 km2 条车辆日记录');
expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).toHaveTextContent('已绑定主车辆1 辆已选择 1 辆');
expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).toHaveTextContent('统计天数2 天2026/07/13 — 07/14');
expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).toHaveTextContent('日均里程96.7 km按有效车辆日平均');
expect(view.container.querySelector('.v2-mileage-table-wrap')).toBeInTheDocument();
expect(screen.getByRole('note', { name: '里程矩阵读表说明' })).toHaveTextContent('0 km已上报、无里程增量');
expect(screen.getByRole('note', { name: '里程矩阵读表说明' })).toHaveTextContent('—无可用里程');
expect(screen.getByRole('note', { name: '里程矩阵读表说明' })).toHaveTextContent('颜色越深、里程越高');
expect(screen.getByLabelText('移动端里程矩阵读表说明')).toHaveTextContent('左右滑动日期 · 车牌与总里程固定');
expect(screen.getByLabelText('0 公里表示已上报、无里程增量')).toHaveTextContent('0 km无增量');
expect(screen.getByLabelText('横线表示无可用里程')).toHaveTextContent('—无数据');
expect(screen.getByLabelText('颜色越深、里程越高')).toHaveTextContent('色阶深色更高');
const matrixRegion = screen.getByRole('region', { name: '车辆每日里程矩阵,可横向滚动查看日期' });
expect(matrixRegion).toHaveAttribute('tabindex', '0');
const matrixScroller = matrixRegion.querySelector<HTMLElement>('.semi-table-body');
expect(matrixScroller).toBeInTheDocument();
Object.defineProperties(matrixScroller!, {
clientWidth: { configurable: true, value: 320 },
scrollWidth: { configurable: true, value: 720 },
scrollLeft: { configurable: true, value: 0, writable: true }
});
fireEvent.keyDown(matrixRegion, { key: 'ArrowRight' });
expect(matrixScroller!.scrollLeft).toBe(96);
expect(view.container.querySelector('[aria-label="2026-07-1388.7 公里,来源 GB32960"]')).toBeInTheDocument();
expect(view.container.querySelector('.v2-mileage-table.semi-table-wrapper')).toBeInTheDocument();
expect(view.container.querySelector('.v2-mileage-table-wrap > table')).not.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);
expect(screen.getByRole('button', { name: '近 7 天' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: '今天' })).toHaveAttribute('aria-pressed', 'false');
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);
});
expect(screen.getByRole('button', { name: '昨天' })).toHaveAttribute('aria-pressed', 'true');
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);
});
expect(screen.getByRole('button', { name: '今天' })).toHaveAttribute('aria-pressed', 'true');
});
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();
const end = new Date();
const start = new Date(Date.now() - 6 * 86_400_000);
const localDate = (value: Date) => new Date(value.getTime() - value.getTimezoneOffset() * 60_000).toISOString().slice(0, 10);
expect(screen.getAllByText(`${localDate(start)}${localDate(end)}`).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.querySelector('.v2-mileage-table.semi-table-wrapper')).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-table th.is-vin')).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-mileage-table th.is-plate')).toHaveClass('is-plate');
expect(view.container.querySelector('.v2-mileage-table th.is-total')).toHaveClass('is-total');
expect(view.container.querySelector('.v2-mileage-mobile-list')).not.toBeInTheDocument();
});
test('routes vertical wheel input from the mileage workspace to the table without hijacking buttons', async () => {
prepareData();
const view = renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
expect(await screen.findByText('车辆每日里程')).toBeInTheDocument();
const page = view.container.querySelector<HTMLElement>('.v2-mileage-page');
expect(page).toBeInTheDocument();
await waitFor(() => expect(view.container.querySelector('.v2-mileage-table-wrap')).toBeInTheDocument());
const scroller = view.container.querySelector<HTMLElement>('.v2-mileage-table-wrap');
expect(scroller).toBeInTheDocument();
Object.defineProperties(scroller!, {
clientHeight: { configurable: true, value: 300 },
scrollHeight: { configurable: true, value: 900 },
scrollTop: { configurable: true, value: 0, writable: true }
});
fireEvent.wheel(page!, { deltaY: 180, deltaX: 0 });
expect(scroller!.scrollTop).toBe(180);
fireEvent.wheel(screen.getByRole('button', { name: '刷新里程数据' }), { deltaY: 180, deltaX: 0 });
expect(scroller!.scrollTop).toBe(180);
fireEvent.wheel(page!, { deltaY: 120, deltaX: 240 });
expect(scroller!.scrollTop).toBe(180);
});
test('supports searching and selecting license plates before querying exact VINs', async () => {
prepareData();
mocks.vehicles.mockResolvedValue({
items: [
{ vin: 'LTEST000000000001', plate: '粤A12345', protocol: 'GB32960' },
{ vin: 'LTEST000000000001', plate: '粤A12345', protocol: 'JT808' }
],
total: 2,
limit: 12,
offset: 0
});
renderPage();
const search = screen.getByRole('textbox', { name: '搜索车牌' });
fireEvent.focus(search);
fireEvent.change(search, { target: { value: '粤A12' } });
const option = await screen.findByRole('option', { name: '粤A12345 LTEST000000000001 GB/T 32960 JT/T 808 选择' });
expect(screen.getAllByRole('option')).toHaveLength(1);
fireEvent.click(option);
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(document.querySelector('.v2-mileage-chip')).toHaveClass('semi-tag');
expect(document.querySelector('.v2-mileage-chip')).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(document.querySelectorAll('.v2-mileage-source-card.semi-card')).toHaveLength(3);
expect(screen.getByText('优先级 1').closest('.semi-tag')).toHaveClass('v2-mileage-source-priority');
expect(screen.getByText('GPS 里程')).toBeInTheDocument();
expect(screen.getByText('GPS 里程').closest('.semi-tag')).toBeInTheDocument();
expect(screen.getAllByText('仪表盘里程')).toHaveLength(3);
expect(screen.getByRole('button', { name: /数据源策略.*GB32960 优先.*3\/3/ })).toHaveAttribute('title', '当前优先:国标 GB32960');
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('uses a compact bottom sheet for mileage source strategy on mobile', async () => {
layout.mobile = true;
prepareData();
renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
fireEvent.click(screen.getByRole('button', { name: /修改里程范围/ }));
fireEvent.click(screen.getByRole('button', { name: /数据源/ }));
expect(screen.getByRole('dialog', { name: '数据源策略配置' })).toBeInTheDocument();
expect(document.querySelector('.v2-mileage-source-sidesheet')).toHaveClass('semi-sidesheet-bottom');
expect(document.querySelector('.v2-mileage-source-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(58dvh, 500px)' });
});
test('opens mobile mileage filters in a bottom task sheet without displacing the data canvas', async () => {
layout.mobile = true;
prepareData();
const view = renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
expect(await screen.findByText('车辆每日里程')).toBeInTheDocument();
expect(view.container.querySelector('.v2-mileage-mobile-discovery')).toBeInTheDocument();
expect(view.container.querySelector('.v2-mileage-query-panel')).not.toBeInTheDocument();
expect(screen.queryByRole('dialog', { name: '里程范围筛选' })).not.toBeInTheDocument();
expect(screen.queryByRole('textbox', { name: '搜索车牌' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /修改里程范围/ }));
expect(await screen.findByRole('dialog', { name: '里程范围筛选' })).toBeInTheDocument();
expect(document.querySelector('.v2-mileage-filter-sidesheet')).toHaveClass('semi-sidesheet-bottom');
expect(document.querySelector('.v2-mileage-filter-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(82dvh, 690px)' });
expect(screen.getByRole('textbox', { name: '搜索车牌' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '应用并查询' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '关闭里程范围筛选' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '关闭里程范围筛选' }));
expect(screen.getByRole('dialog', { name: '里程范围筛选' })).toHaveClass('semi-sidesheet-animation-content_hide_bottom');
expect(screen.getByText('车辆每日里程')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /修改里程范围/ })).toHaveAttribute('aria-expanded', 'false');
});
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('共 32 辆 · 每页 20 辆')).toBeInTheDocument();
expect(document.querySelector('.v2-table-pagination .semi-page-item-small')).toHaveTextContent('1/2');
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: 'Next' }));
expect((await screen.findAllByText('粤A00021')).length).toBeGreaterThan(0);
expect(screen.getByText('共 32 辆 · 每页 20 辆')).toBeInTheDocument();
expect(document.querySelector('.v2-table-pagination .semi-page-item-small')).toHaveTextContent('2/2');
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();
});