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

417 lines
29 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, { 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(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={[initialEntry]}><HistoryPage /></MemoryRouter></QueryClientProvider>);
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<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-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<HTMLElement>('.v2-history-table-scroll');
expect(desktopHistoryScroll).toHaveAttribute('tabindex', '0');
expect(desktopHistoryScroll).toHaveAttribute('aria-label', '历史明细表格,可使用左右方向键浏览业务字段');
const desktopTableScroller = desktopHistoryScroll?.querySelector<HTMLElement>('.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<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();
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'));
});