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, { formatHistoryDateTime } 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('renders protocol timestamps in Asia/Shanghai while preserving local values', () => { expect(formatHistoryDateTime('2026-07-18T02:33:00Z')).toBe('2026-07-18 10:33:00'); expect(formatHistoryDateTime('2026-07-18T02:33:00+08:00')).toBe('2026-07-18 02:33:00'); expect(formatHistoryDateTime('2026-07-18 02:33:00')).toBe('2026-07-18 02:33:00'); }); 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-summary-rail', 'v2-history-table-card']) { expect(view.container.querySelector(`.${className}.semi-card`)).toBeInTheDocument(); } expect(view.container.querySelector('.v2-history-discovery-shell')).toContainElement(screen.getByLabelText('历史数据查询操作')); expect(screen.getByLabelText('历史数据查询操作')).toHaveTextContent('遥测证据检索'); expect(screen.getByLabelText('历史数据查询操作')).toHaveTextContent('上海时区'); expect(view.container.querySelector('.v2-history-summary-rail')).toHaveAttribute('aria-label', '历史数据查询统计信息'); expect(view.container.querySelector('.v2-history-summary-secondary.semi-card-group')).toBeInTheDocument(); expect(view.container.querySelectorAll('.v2-history-summary-card.semi-card')).toHaveLength(4); expect(view.container.querySelector('.v2-history-summary-card.is-primary .v2-history-summary-value')).toHaveTextContent('1'); expect(screen.getByText('1 / 1 字段').closest('.semi-tag')).toBeInTheDocument(); expect(view.container.querySelector('.v2-history-result-actions.v2-workspace-panel-actions')).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(); const desktopHistoryScroll = view.container.querySelector('.v2-history-table-scroll'); expect(desktopHistoryScroll).toHaveAttribute('tabindex', '0'); expect(desktopHistoryScroll).toHaveAttribute('aria-label', '历史明细表格,可使用左右方向键浏览业务字段'); const desktopTableScroller = desktopHistoryScroll?.querySelector('.semi-table-body'); expect(desktopTableScroller).toBeInTheDocument(); const scrollBy = vi.fn(); Object.defineProperty(desktopTableScroller!, 'scrollBy', { configurable: true, value: scrollBy }); fireEvent.keyDown(desktopHistoryScroll!, { key: 'ArrowRight' }); expect(scrollBy).toHaveBeenCalledWith({ left: 260, behavior: 'smooth' }); fireEvent.keyDown(desktopHistoryScroll!, { key: 'ArrowLeft' }); expect(scrollBy).toHaveBeenCalledWith({ left: -260, behavior: 'smooth' }); expect(screen.getByRole('columnheader', { name: '设备时间' })).toHaveClass('semi-table-cell-fixed-left', 'v2-history-device-time-column'); expect(screen.getByRole('columnheader', { name: '车牌' })).toHaveClass('semi-table-cell-fixed-left', 'v2-history-plate-column'); expect(view.container.querySelector('th.v2-history-selection-column')).toHaveClass('semi-table-cell-fixed-left'); 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-history-evidence-status')).toHaveTextContent('证据判读正常'); 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-protocol-tag.semi-tag')).toHaveTextContent('JT/T 808'); 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'); expect((await screen.findByText('覆盖 100.0%')).closest('.semi-tag')).toBeInTheDocument(); expect((await screen.findByText('时间窗完整')).closest('.semi-tag')).toBeInTheDocument(); 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('uses one focused Semi bottom SideSheet for the complete mobile history query', 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([]); renderPage('/history'); await screen.findByText('选择车辆开始查询'); const trigger = screen.getByRole('button', { name: '修改查询范围:请选择车辆' }); expect(trigger).toHaveAttribute('aria-expanded', 'false'); expect(screen.queryByRole('button', { name: '展开趋势' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: '打开历史数据工具' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Previous' })).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '选择车辆并查询' })); expect(trigger).toHaveAttribute('aria-expanded', 'true'); const dialog = await screen.findByRole('dialog', { name: '历史查询范围' }); expect(document.querySelector('.v2-history-filter-sidesheet')).toHaveClass('semi-sidesheet-bottom'); expect(document.querySelector('.v2-history-filter-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(82dvh, 700px)' }); expect(within(dialog).getByPlaceholderText('车牌 / VIN,多台用逗号分隔')).toBeInTheDocument(); expect(within(dialog).getByLabelText('开始时间')).toBeInTheDocument(); expect(within(dialog).getByLabelText('结束时间')).toBeInTheDocument(); expect(within(dialog).getByRole('combobox', { name: '数据类型' })).toBeInTheDocument(); expect(within(dialog).getByRole('combobox', { name: '数据来源' })).toBeInTheDocument(); fireEvent.click(within(dialog).getByRole('button', { name: '重置条件' })); expect(screen.getByRole('dialog', { name: '历史查询范围' })).toBeInTheDocument(); fireEvent.change(within(dialog).getByPlaceholderText('车牌 / VIN,多台用逗号分隔'), { target: { value: 'MOBILEVIN' } }); fireEvent.click(within(dialog).getByRole('button', { name: '应用并查询' })); await waitFor(() => { const calls = mocks.historyData.mock.calls; expect((calls[calls.length - 1]?.[0] as URLSearchParams | undefined)?.get('keywords')).toBe('MOBILEVIN'); }); expect(screen.getByRole('button', { name: '修改查询范围:1 辆 · 位置数据' })).toHaveAttribute('aria-expanded', 'false'); expect(screen.getByRole('dialog', { name: '历史查询范围' })).toHaveClass('semi-sidesheet-animation-content_hide_bottom'); }); 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(action.closest('.v2-history-mobile-card')?.querySelector('.v2-history-protocol-tag.semi-tag')).toHaveTextContent('JT/T 808'); expect(view.container.querySelector('.v2-history-metric-scroll')).not.toBeInTheDocument(); expect(view.container.querySelector('.v2-history-summary-rail')).toBeInTheDocument(); expect(view.container.querySelector('.v2-history-result-actions')).toHaveClass('is-mobile-actions'); 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(); expect(screen.getByRole('button', { name: '展开趋势' })).toHaveTextContent('趋势'); expect(screen.getByRole('button', { name: '展开趋势' })).toHaveClass('v2-workspace-mobile-tool-button', 'is-muted'); expect(screen.getByRole('button', { name: '打开历史数据工具' })).toHaveTextContent('工具'); expect(screen.getByRole('button', { name: '打开历史数据工具' })).toHaveClass('v2-workspace-mobile-tool-button', 'is-muted'); expect(screen.queryByText('坐标、速度、里程及设备/服务时间通过基础校验')).not.toBeInTheDocument(); fireEvent.click(action); expect(action).toHaveAttribute('aria-pressed', 'true'); expect(action).toHaveAttribute('aria-expanded', 'true'); expect(await screen.findByRole('dialog', { name: '历史数据详情' })).toBeInTheDocument(); expect(document.querySelector('.v2-history-detail-sidesheet')).toHaveClass('semi-sidesheet-bottom'); expect(document.querySelector('.v2-history-detail-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(82dvh, 720px)' }); 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 toolsButton = screen.getByRole('button', { name: '打开历史数据工具' }); fireEvent.click(toolsButton); await waitFor(() => expect(toolsButton).toHaveAttribute('aria-expanded', 'true')); fireEvent.click(screen.getByRole('menuitem', { name: /导出任务/ })); expect(await screen.findByRole('dialog', { name: '历史数据导出任务' })).toBeInTheDocument(); expect(document.querySelector('.v2-history-export-sidesheet')).toHaveClass('semi-sidesheet-bottom'); expect(document.querySelector('.v2-history-export-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(76dvh, 680px)' }); await waitFor(() => expect(mocks.historyExports).toHaveBeenCalledTimes(1)); expect(screen.getByText('暂无导出任务')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '关闭历史数据导出任务' })); expect(toolsButton).toHaveAttribute('aria-expanded', 'false'); expect(document.body.style.overflow).not.toBe('hidden'); expect(screen.queryByText('暂无导出任务')).not.toBeInTheDocument(); fireEvent.click(toolsButton); fireEvent.click(screen.getByRole('menuitem', { name: /字段设置/ })); expect(await screen.findByRole('dialog', { name: '列显示设置' })).toBeInTheDocument(); expect(document.querySelector('.v2-history-column-sidesheet')).toHaveClass('semi-sidesheet-bottom'); expect(document.querySelector('.v2-history-column-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(68dvh, 580px)' }); fireEvent.click(screen.getByRole('button', { name: '关闭列显示设置' })); expect(toolsButton).toHaveAttribute('aria-expanded', 'false'); }); 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(view.container.querySelector('.v2-history-time')).toHaveTextContent('2026-07-16 04:00:00'); expect(screen.queryByRole('columnheader', { name: 'SOC (%)' })).not.toBeInTheDocument(); expect(view.container.querySelector('.v2-history-metric-scroll')).not.toBeInTheDocument(); expect(screen.getByText('1 / 2 字段').closest('.semi-tag')).toBeInTheDocument(); 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(); fireEvent.click(screen.getByRole('button', { name: '列设置' })); fireEvent.click(screen.getByRole('checkbox', { name: '总里程 (km)' })); await waitFor(() => expect((mocks.historySeries.mock.calls[mocks.historySeries.mock.calls.length - 1]?.[0] as URLSearchParams).get('metrics')).toBe('speedKmh')); });