440 lines
28 KiB
TypeScript
440 lines
28 KiB
TypeScript
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-13,88.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();
|
||
});
|