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(); 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((resolve) => { resolveNewData = resolve; })); mocks.historySeries.mockImplementation((params: URLSearchParams) => params.get('keywords') === 'OLDVIN' ? Promise.resolve(historySeries('OLDVIN', '旧车牌', 'old-as-of')) : new Promise((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('.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')); });