Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.test.tsx
2026-07-18 04:02:49 +08:00

330 lines
22 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 { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import type { HistoryDataResponse, HistorySeriesResponse } from '../../api/types';
import { buildMonitorPath, withMonitorReturn } from '../routing/monitorContext';
import { ROUTER_FUTURE } from '../routing/routerConfig';
import HistoryPage from './HistoryPage';
const mocks = vi.hoisted(() => ({
historyMetricCatalog: vi.fn(),
historyData: vi.fn(),
historySeries: vi.fn(),
historyExports: vi.fn(),
createHistoryExport: vi.fn(),
downloadHistoryExport: vi.fn()
}));
const auth = vi.hoisted(() => ({ role: 'admin' }));
const layout = vi.hoisted(() => ({ mobile: false }));
vi.mock('../../api/client', () => ({ api: mocks }));
vi.mock('../auth/AuthGate', () => ({ usePlatformSession: () => ({ session: { role: auth.role } }) }));
vi.mock('../hooks/useMobileLayout', () => ({ useMobileLayout: () => layout.mobile }));
afterEach(() => {
cleanup();
auth.role = 'admin';
layout.mobile = false;
Object.values(mocks).forEach((mock) => mock.mockReset());
});
const metric = { key: 'speedKmh', label: '速度', unit: 'km/h', category: 'location', valueType: 'number', defaultVisible: true };
const optionalMetric = { key: 'socPercent', label: 'SOC', unit: '%', category: 'location', valueType: 'number', defaultVisible: false };
function historyData(vin: string, plate: string, asOf: string): HistoryDataResponse {
return {
category: 'location', columns: [metric], total: 1, limit: 50, offset: 0, asOf,
summary: { resultRows: 1, vehicleCount: 1, sources: ['JT808'], queryDurationMs: 12 },
rows: [{ id: `${vin}-row`, vin, plate, protocol: 'JT808', deviceTime: '2026-07-16 04:00:00', serverTime: '2026-07-16 04:00:01', quality: 'normal', qualityReason: '坐标、速度、里程及设备/服务时间通过基础校验', evidenceId: `${vin}-evidence`, values: { speedKmh: 32 } }]
};
}
function historySeries(vin: string, plate: string, asOf: string): HistorySeriesResponse {
return {
metrics: [metric], dateFrom: '2026-07-16T00:00:00Z', dateTo: '2026-07-16T05:00:00Z', asOf,
summary: { rawPointCount: 2, bucketCount: 2, returnedPointCount: 2, seriesCount: 1, grainSeconds: 60, targetPoints: 240, expectedBucketCount: 2, missingBucketCount: 0, queryDurationMs: 8, complete: true, evidence: 'test series' },
series: [{ vin, plate, protocol: 'JT808', metric: 'speedKmh', label: '速度', unit: 'km/h', aggregation: 'avg', points: [{ time: '2026-07-16 04:00:00', value: 30, min: 30, max: 30, count: 1 }, { time: '2026-07-16 04:01:00', value: 32, min: 32, max: 32, count: 1 }] }]
};
}
function renderPage(initialEntry = '/history?keywords=OLDVIN&dateFrom=2026-07-16T00%3A00&dateTo=2026-07-16T05%3A00') {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={[initialEntry]}><HistoryPage /></MemoryRouter></QueryClientProvider>);
return { ...view, client };
}
test('preserves the exact monitor return after changing history filters', async () => {
mocks.historyMetricCatalog.mockResolvedValue({ categories: [{ key: 'location', label: '位置数据' }], metrics: [metric] });
mocks.historyData.mockResolvedValue(historyData('OLDVIN', '旧车牌', 'old-as-of'));
mocks.historySeries.mockResolvedValue(historySeries('OLDVIN', '旧车牌', 'old-as-of'));
mocks.historyExports.mockResolvedValue([]);
const monitorPath = buildMonitorPath({
mode: 'list', keyword: '粤A12345', protocol: '', status: '', selectedVin: 'OLDVIN', detailOpen: false,
viewport: { zoom: 12.5, bounds: '' }, listOffset: 0, listLimit: 50, hasViewport: true
});
renderPage(withMonitorReturn('/history?keywords=OLDVIN&dateFrom=2026-07-16T00%3A00&dateTo=2026-07-16T05%3A00', monitorPath));
const returnLink = await screen.findByRole('link', { name: /返回全局监控/ });
expect(returnLink).toHaveAttribute('href', monitorPath);
fireEvent.change(screen.getByPlaceholderText('车牌 / VIN多台用逗号分隔'), { target: { value: 'NEWVIN' } });
fireEvent.click(screen.getByRole('button', { name: '查询' }));
expect(await screen.findByRole('link', { name: /返回全局监控/ })).toHaveAttribute('href', monitorPath);
});
test('renders the shared empty state when the API returns nullable empty collections', async () => {
mocks.historyMetricCatalog.mockResolvedValue({ categories: [{ key: 'location', label: '位置数据' }], metrics: [metric] });
mocks.historyExports.mockResolvedValue([]);
mocks.historyData.mockResolvedValue({
category: 'location',
columns: null,
rows: null,
summary: null,
total: 0,
limit: 50,
offset: 0,
asOf: '2026-07-18 00:53:00'
} as unknown as HistoryDataResponse);
const view = renderPage('/history?keywords=LTESTNOEXIST00001&dateFrom=2026-07-18T00%3A00&dateTo=2026-07-18T00%3A53');
expect(await screen.findByText('当前条件没有历史记录')).toBeInTheDocument();
expect(view.container.querySelector('.v2-history-empty.v2-state-surface')).toBeInTheDocument();
expect(screen.queryByText('当前模块暂时无法显示')).not.toBeInTheDocument();
});
test('clears stale rows, charts, and evidence as soon as the history scope changes', async () => {
let resolveNewData!: (value: HistoryDataResponse) => void;
let resolveNewSeries!: (value: HistorySeriesResponse) => void;
mocks.historyMetricCatalog.mockResolvedValue({ categories: [{ key: 'location', label: '位置数据' }], metrics: [metric] });
mocks.historyExports.mockResolvedValue([]);
mocks.historyData.mockImplementation((params: URLSearchParams) => params.get('keywords') === 'OLDVIN'
? Promise.resolve(historyData('OLDVIN', '旧车牌', 'old-as-of'))
: new Promise<HistoryDataResponse>((resolve) => { resolveNewData = resolve; }));
mocks.historySeries.mockImplementation((params: URLSearchParams) => params.get('keywords') === 'OLDVIN'
? Promise.resolve(historySeries('OLDVIN', '旧车牌', 'old-as-of'))
: new Promise<HistorySeriesResponse>((resolve) => { resolveNewSeries = resolve; }));
const view = renderPage();
expect((await screen.findAllByText('旧车牌')).length).toBeGreaterThan(0);
expect(screen.queryByText('OLDVIN-evidence')).not.toBeInTheDocument();
for (const className of ['v2-history-filter-card', 'v2-history-context-card', 'v2-history-table-card']) {
expect(view.container.querySelector(`.${className}.semi-card`)).toBeInTheDocument();
}
expect(view.container.querySelector('.v2-history-context .v2-history-metrics')).toBeInTheDocument();
expect(view.container.querySelector('.v2-history-context .v2-history-summary')).toBeInTheDocument();
expect(view.container.querySelector('.v2-history-evidence')).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-history-workspace')).not.toHaveClass('is-inspector-open');
expect(screen.queryByRole('heading', { level: 5, name: '聚合趋势' })).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-history-trend')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /展开趋势/ })).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByRole('heading', { level: 5, name: '导出任务' })).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-history-data-table.semi-table-wrapper')).toBeInTheDocument();
expect(view.container.querySelector('.v2-history-table-scroll > table')).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-history-mobile-list')).not.toBeInTheDocument();
const desktopHistoryRow = screen.getByTestId('history-row-OLDVIN-row');
expect(desktopHistoryRow).toHaveAttribute('role', 'button');
expect(desktopHistoryRow).toHaveAttribute('aria-expanded', 'false');
fireEvent.keyDown(desktopHistoryRow, { key: 'Enter' });
expect(desktopHistoryRow).toHaveAttribute('aria-expanded', 'true');
expect(await screen.findByText('OLDVIN-evidence')).toBeInTheDocument();
expect(view.container.querySelector('.v2-history-evidence-descriptions.semi-descriptions')).toBeInTheDocument();
expect(view.container.querySelector('.v2-evidence-values.semi-descriptions')).toBeInTheDocument();
expect(view.container.querySelector('.v2-history-quality-tag.semi-tag')).toHaveTextContent('正常');
expect(view.container.querySelector('.v2-history-workspace')).toHaveClass('is-inspector-open');
fireEvent.click(screen.getByRole('button', { name: '关闭数据详情' }));
expect(desktopHistoryRow).toHaveAttribute('aria-expanded', 'false');
expect(view.container.querySelector('.v2-history-workspace')).not.toHaveClass('is-inspector-open');
fireEvent.click(desktopHistoryRow);
expect(desktopHistoryRow).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(screen.getByRole('button', { name: /展开趋势/ }));
await waitFor(() => expect(mocks.historySeries).toHaveBeenCalledTimes(1));
expect(screen.getByRole('heading', { level: 5, name: '聚合趋势' })).toBeInTheDocument();
expect(view.container.querySelector('.v2-history-trend')).toHaveClass('is-expanded');
fireEvent.change(screen.getByPlaceholderText('车牌 / VIN多台用逗号分隔'), { target: { value: 'NEWVIN' } });
fireEvent.click(screen.getByRole('button', { name: '查询' }));
expect(await screen.findByRole('status')).toHaveTextContent('正在加载当前筛选范围的历史数据');
expect(screen.queryByText('旧车牌')).not.toBeInTheDocument();
expect(screen.queryByText('OLDVIN-evidence')).not.toBeInTheDocument();
expect(screen.getByText('正在聚合时间序列…')).toBeInTheDocument();
await act(async () => {
resolveNewData(historyData('NEWVIN', '新车牌', 'new-as-of'));
resolveNewSeries(historySeries('NEWVIN', '新车牌', 'new-as-of'));
});
expect((await screen.findAllByText('新车牌')).length).toBeGreaterThan(0);
expect(screen.queryByText('NEWVIN-evidence')).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId('history-row-NEWVIN-row'));
expect(await screen.findByText('NEWVIN-evidence')).toBeInTheDocument();
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument());
});
test('renders only selectable Semi history cards on mobile', async () => {
layout.mobile = true;
mocks.historyMetricCatalog.mockResolvedValue({ categories: [{ key: 'location', label: '位置数据' }], metrics: [metric] });
mocks.historyData.mockResolvedValue(historyData('MOBILEVIN', '粤A移动历史', 'mobile-as-of'));
mocks.historySeries.mockResolvedValue(historySeries('MOBILEVIN', '粤A移动历史', 'mobile-as-of'));
mocks.historyExports.mockResolvedValue([]);
const view = renderPage('/history?keywords=MOBILEVIN&dateFrom=2026-07-16T00%3A00&dateTo=2026-07-16T05%3A00');
const action = await screen.findByRole('button', { name: '查看 粤A移动历史 2026-07-16 04:00:00 数据详情' });
expect(action).toHaveClass('semi-button', 'v2-history-mobile-action');
expect(action.closest('.semi-card')).toHaveClass('v2-history-mobile-card');
expect(view.container.querySelector('.v2-history-data-table')).not.toBeInTheDocument();
expect(action).toHaveAttribute('aria-pressed', 'false');
expect(action).toHaveAttribute('aria-expanded', 'false');
expect(view.container.querySelector('.v2-history-table-scroll.is-mobile-scroll')).toHaveAttribute('aria-label', '历史明细列表,可上下滚动');
expect(view.container.querySelector('.v2-history-table-scroll.is-mobile-scroll')).toHaveAttribute('tabindex', '0');
expect(screen.queryByText('MOBILEVIN-evidence')).not.toBeInTheDocument();
expect((mocks.historyData.mock.calls[0]?.[0] as URLSearchParams).get('limit')).toBe('20');
expect(screen.queryByLabelText('每页数量')).not.toBeInTheDocument();
expect(screen.queryByLabelText('表格密度')).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-history-side')).not.toBeInTheDocument();
expect(mocks.historyExports).not.toHaveBeenCalled();
fireEvent.click(action);
expect(action).toHaveAttribute('aria-pressed', 'true');
expect(action).toHaveAttribute('aria-expanded', 'true');
expect(await screen.findByRole('dialog', { name: '历史数据详情' })).toBeInTheDocument();
expect(await screen.findByText('MOBILEVIN-evidence')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '关闭历史数据详情' }));
expect(action).toHaveAttribute('aria-pressed', 'false');
expect(action).toHaveAttribute('aria-expanded', 'false');
expect(document.body.style.overflow).not.toBe('hidden');
expect(screen.queryByText('MOBILEVIN-evidence')).not.toBeInTheDocument();
const exportJobsButton = screen.getByRole('button', { name: '查看导出任务' });
fireEvent.click(exportJobsButton);
expect(await screen.findByRole('dialog', { name: '历史数据导出任务' })).toBeInTheDocument();
await waitFor(() => expect(mocks.historyExports).toHaveBeenCalledTimes(1));
expect(screen.getByText('暂无导出任务')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '关闭历史数据导出任务' }));
expect(exportJobsButton).toHaveAttribute('aria-expanded', 'false');
expect(document.body.style.overflow).not.toBe('hidden');
expect(screen.queryByText('暂无导出任务')).not.toBeInTheDocument();
});
test('releases export job state immediately after leaving the history page', async () => {
mocks.historyMetricCatalog.mockResolvedValue({ categories: [{ key: 'location', label: '位置数据' }], metrics: [metric] });
mocks.historyData.mockResolvedValue(historyData('OLDVIN', '旧车牌', 'old-as-of'));
mocks.historySeries.mockResolvedValue(historySeries('OLDVIN', '旧车牌', 'old-as-of'));
mocks.historyExports.mockResolvedValue([{ id: 'export-running', name: '运行中导出', status: 'running', progress: 30, format: 'csv', category: 'location', keywords: ['OLDVIN'], rowCount: 0, totalRows: 100, processedRows: 30, fileSizeBytes: 0, createdAt: '2026-07-16T04:00:00Z', updatedAt: '2026-07-16T04:00:01Z', evidence: 'test export' }]);
const { client, unmount } = renderPage();
fireEvent.click(await screen.findByRole('button', { name: '查看导出任务' }));
expect(await screen.findByText('运行中导出')).toBeInTheDocument();
expect(client.getQueryCache().find({ queryKey: ['history-exports'] })).toBeDefined();
unmount();
await waitFor(() => expect(client.getQueryCache().find({ queryKey: ['history-exports'] })).toBeUndefined());
});
test('opens a working column visibility panel and preserves non-hideable identity columns', async () => {
const data = historyData('OLDVIN', '旧车牌', 'old-as-of');
data.columns = [metric, optionalMetric];
data.rows[0].values.socPercent = 88;
mocks.historyMetricCatalog.mockResolvedValue({ categories: [{ key: 'location', label: '位置数据' }], metrics: [metric, optionalMetric] });
mocks.historyData.mockResolvedValue(data);
mocks.historySeries.mockResolvedValue(historySeries('OLDVIN', '旧车牌', 'old-as-of'));
mocks.historyExports.mockResolvedValue([]);
const view = renderPage();
expect(await screen.findByRole('columnheader', { name: '速度 (km/h)' })).toBeInTheDocument();
expect(screen.queryByRole('columnheader', { name: 'SOC (%)' })).not.toBeInTheDocument();
const metricScroll = view.container.querySelector('.v2-history-metric-scroll');
const manageMetrics = screen.getByRole('button', { name: '管理字段' });
expect(metricScroll).toBeInTheDocument();
expect(metricScroll).toHaveAttribute('aria-label', '当前显示字段');
expect(metricScroll?.contains(screen.getByLabelText(/已选字段 速度/))).toBe(true);
expect(metricScroll?.contains(manageMetrics)).toBe(false);
expect(manageMetrics.parentElement).toHaveClass('v2-history-metrics');
const columnSettingsButton = screen.getByRole('button', { name: '列设置' });
fireEvent.click(columnSettingsButton);
expect(screen.getByRole('dialog', { name: '列显示设置' })).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('搜索中文、状态含义或字段 key'), { target: { value: 'soc' } });
expect(screen.getByRole('checkbox', { name: /SOC/ })).toBeInTheDocument();
expect(screen.queryByRole('checkbox', { name: /速度/ })).not.toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('搜索中文、状态含义或字段 key'), { target: { value: '' } });
fireEvent.click(screen.getByRole('checkbox', { name: /速度/ }));
expect(screen.queryByRole('columnheader', { name: '速度 (km/h)' })).not.toBeInTheDocument();
const historyTable = document.querySelector<HTMLElement>('.v2-history-table-scroll table');
expect(historyTable).toBeInTheDocument();
expect(within(historyTable!).getByRole('columnheader', { name: 'VIN' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '显示全部' }));
expect(screen.getByRole('columnheader', { name: '速度 (km/h)' })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: 'SOC (%)' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '恢复默认' }));
expect(screen.getByRole('columnheader', { name: '速度 (km/h)' })).toBeInTheDocument();
expect(screen.queryByRole('columnheader', { name: 'SOC (%)' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '关闭列显示设置' }));
expect(columnSettingsButton).toHaveAttribute('aria-expanded', 'false');
expect(document.body.style.overflow).not.toBe('hidden');
});
test('keeps history readable without mounting operator-only export requests for a viewer', async () => {
auth.role = 'viewer';
mocks.historyMetricCatalog.mockResolvedValue({ categories: [{ key: 'location', label: '位置数据' }], metrics: [metric] });
mocks.historyData.mockResolvedValue(historyData('OLDVIN', '旧车牌', 'old-as-of'));
mocks.historySeries.mockResolvedValue(historySeries('OLDVIN', '旧车牌', 'old-as-of'));
const view = renderPage();
expect((await screen.findAllByText('旧车牌')).length).toBeGreaterThan(0);
expect(screen.getByText('只读').closest('.semi-tag')).toHaveClass('v2-history-permission-tag');
expect(view.container.querySelector('.v2-history-toolbar-actions')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /创建导出/ })).not.toBeInTheDocument();
expect(screen.queryByText('导出任务')).not.toBeInTheDocument();
expect(mocks.historyExports).not.toHaveBeenCalled();
expect(mocks.createHistoryExport).not.toHaveBeenCalled();
});
test('shows only the authenticated customer export workspace with owner and scope metadata', async () => {
auth.role = 'customer';
mocks.historyMetricCatalog.mockResolvedValue({ categories: [{ key: 'location', label: '位置数据' }], metrics: [metric] });
mocks.historyData.mockResolvedValue(historyData('OLDVIN', '旧车牌', 'old-as-of'));
mocks.historySeries.mockResolvedValue(historySeries('OLDVIN', '旧车牌', 'old-as-of'));
mocks.historyExports.mockResolvedValue([{
id: 'customer-export', name: '历史数据_20260716_customer-a', status: 'completed', progress: 100, format: 'csv', category: 'location',
keywords: ['OLDVIN'], vehicleVins: ['OLDVIN'], dateFrom: '2026-07-16T00:00', dateTo: '2026-07-16T05:00',
ownerName: '客户甲', ownerUsername: 'customer-a', ownerRole: 'customer', ownerUserType: 'customer',
rowCount: 10, totalRows: 10, processedRows: 10, fileSizeBytes: 1024, downloadUrl: '/api/v2/exports/customer-export/download',
createdAt: '2026-07-16T05:00:00Z', updatedAt: '2026-07-16T05:00:01Z', completedAt: '2026-07-16T05:00:01Z', evidence: 'owner scoped'
}]);
renderPage();
fireEvent.click(await screen.findByRole('button', { name: '查看导出任务' }));
expect(await screen.findByText(/customer-a · 1 辆/)).toBeInTheDocument();
expect(screen.getByRole('dialog', { name: '历史数据导出任务' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /创建导出/ })).toBeInTheDocument();
expect(mocks.historyExports).toHaveBeenCalled();
});
test('uses selected chartable fields for trends and omits empty RAW evidence UI', async () => {
const totalMileageMetric = { key: 'totalMileageKm', label: '总里程', unit: 'km', category: 'location', valueType: 'number', defaultVisible: true };
const data = historyData('OLDVIN', '旧车牌', 'old-as-of');
data.columns = [metric, totalMileageMetric];
data.rows[0].evidenceId = undefined;
data.rows[0].values.totalMileageKm = 1234;
mocks.historyMetricCatalog.mockResolvedValue({ categories: [{ key: 'location', label: '位置数据' }], metrics: [metric, totalMileageMetric] });
mocks.historyData.mockResolvedValue(data);
mocks.historySeries.mockResolvedValue(historySeries('OLDVIN', '旧车牌', 'old-as-of'));
mocks.historyExports.mockResolvedValue([]);
renderPage();
expect((await screen.findAllByText('旧车牌')).length).toBeGreaterThan(0);
expect(mocks.historySeries).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: /展开趋势/ }));
await waitFor(() => expect(mocks.historySeries).toHaveBeenCalled());
expect((mocks.historySeries.mock.calls[mocks.historySeries.mock.calls.length - 1]?.[0] as URLSearchParams).get('metrics')).toBe('speedKmh,totalMileageKm');
expect(screen.queryByText('RAW 证据')).not.toBeInTheDocument();
const mileageToggle = screen.getAllByLabelText(/已选字段 .*点击取消显示/).find((button) => button.textContent?.includes('总里程'));
expect(mileageToggle).toBeDefined();
fireEvent.click(mileageToggle!);
await waitFor(() => expect((mocks.historySeries.mock.calls[mocks.historySeries.mock.calls.length - 1]?.[0] as URLSearchParams).get('metrics')).toBe('speedKmh'));
});