import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { MemoryRouter, useNavigate } from 'react-router-dom'; import type { TrackPlaybackResponse } from '../../api/types'; import TrackPage from './TrackPage'; import { buildMonitorPath, withMonitorReturn } from '../routing/monitorContext'; import { ROUTER_FUTURE } from '../routing/routerConfig'; const mocks = vi.hoisted(() => ({ trackPlayback: vi.fn(), reverseGeocode: vi.fn(), vehicles: vi.fn() })); vi.mock('../../api/client', () => ({ api: mocks })); vi.mock('../map/TrackMap', () => ({ TrackMap: ({ activeIndex, followDurationMs, points }: { activeIndex: number; followDurationMs: number; points: unknown[] }) =>
})); vi.mock('@douyinfe/semi-ui', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, Slider: ({ value, onChange, min = 0, max = 100, disabled, ...props }: { value?: number; onChange?: (value: number) => void; min?: number; max?: number; disabled?: boolean; 'aria-label'?: string; }) => onChange?.(Number(event.target.value))} />, Select: ({ value, onChange, optionList = [], ...props }: { value?: string | number; onChange?: (value: string) => void; optionList?: Array<{ value: string | number; label: React.ReactNode }>; 'aria-label'?: string; 'aria-labelledby'?: string; }) => }; }); const track = { vin: 'LTEST000000000001', plate: '粤A12345', total: 3, truncated: false, sampled: false, asOf: '2026-07-15T08:00:00Z', points: [ { vin: 'LTEST000000000001', plate: '粤A12345', protocol: 'JT808', deviceTime: '2026-07-15T00:00:00Z', serverTime: '2026-07-15T00:00:01Z', longitude: 113.1, latitude: 23.1, speedKmh: 0, totalMileageKm: 100, socPercent: 80, socAvailable: true }, { vin: 'LTEST000000000001', plate: '粤A12345', protocol: 'JT808', deviceTime: '2026-07-15T00:10:00Z', serverTime: '2026-07-15T00:10:01Z', longitude: 113.2, latitude: 23.2, speedKmh: 35, totalMileageKm: 104, socPercent: 79, socAvailable: true }, { vin: 'LTEST000000000001', plate: '粤A12345', protocol: 'JT808', deviceTime: '2026-07-15T00:20:00Z', serverTime: '2026-07-15T00:20:01Z', longitude: 113.3, latitude: 23.3, speedKmh: 10, totalMileageKm: 108, socPercent: 78, socAvailable: true } ], events: [ { index: 0, sampledIndex: 0, type: 'start', title: '开始行驶', time: '2026-07-15T00:00:00Z', speedKmh: 0, longitude: 113.1, latitude: 23.1 }, { index: 1, sampledIndex: 1, type: 'acceleration', title: '急加速', time: '2026-07-15T00:10:00Z', speedKmh: 35, longitude: 113.2, latitude: 23.2 }, { index: 2, sampledIndex: 2, type: 'end', title: '结束行驶', time: '2026-07-15T00:20:00Z', speedKmh: 10, longitude: 113.3, latitude: 23.3 } ], sources: [{ protocol: 'JT808', pointCount: 3, startTime: '2026-07-15T00:00:00Z', endTime: '2026-07-15T00:20:00Z' }], segments: [{ index: 0, type: 'moving', title: '行驶', startTime: '2026-07-15T00:00:00Z', endTime: '2026-07-15T00:20:00Z', durationSeconds: 1200, distanceKm: 8, pointCount: 3, startIndex: 0, endIndex: 2, sampledStartIndex: 0, sampledEndIndex: 2 }], stops: [{ index: 0, startTime: '2026-07-15T00:00:00Z', endTime: '2026-07-15T00:05:00Z', durationSeconds: 300, pointCount: 1, longitude: 113.1, latitude: 23.1, sampledIndex: 0, evidence: 'GPS 推断' }], summary: { startTime: '2026-07-15T00:00:00Z', endTime: '2026-07-15T00:20:00Z', distanceKm: 8, durationSeconds: 1200, averageSpeedKmh: 24, maximumSpeedKmh: 35, pointCount: 3, movingSeconds: 900, stoppedSeconds: 300, stopCount: 1, segmentCount: 1 }, coverage: { requestedStart: '', requestedEnd: '', actualStart: '', actualEnd: '', totalPoints: 3, fetchedPoints: 3, processedPoints: 3, returnedPoints: 3, complete: true, limitReasons: [], evidence: '时间窗完整' }, quality: { status: 'good', selectedProtocol: 'JT808', rawPoints: 3, validPoints: 3, alternateSourcePoints: 0, invalidCoordinatePoints: 0, duplicatePoints: 0, driftPoints: 0, sourceSwitches: 0, largeGapCount: 0, maximumGapSeconds: 0, evidence: '轨迹质量正常' } } as unknown as TrackPlaybackResponse; beforeEach(() => { Object.defineProperty(window, 'matchMedia', { configurable: true, value: vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })) }); mocks.trackPlayback.mockResolvedValue(track); mocks.reverseGeocode.mockResolvedValue({ formattedAddress: '广东省广州市测试道路' }); mocks.vehicles.mockResolvedValue({ items: [], total: 0, limit: 10, offset: 0 }); }); afterEach(() => { cleanup(); Object.values(mocks).forEach((mock) => mock.mockReset()); }); function TrackNavigationHarness() { const navigate = useNavigate(); return <>; } test('preserves the exact monitor return after querying another track range', async () => { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const monitorPath = buildMonitorPath({ mode: 'map', keyword: '粤A12345', protocol: '', status: '', selectedVin: 'LTEST000000000001', detailOpen: true, viewport: { zoom: 14, bounds: '' }, listOffset: 0, listLimit: 50, hasViewport: true }); const initialEntry = withMonitorReturn('/tracks?keyword=LTEST000000000001&dateFrom=2026-07-15T00:00&dateTo=2026-07-15T23:59', monitorPath); const view = render(); expect(view.container.querySelector('.v2-track-page')).toHaveClass('has-monitor-return'); expect(await screen.findByRole('link', { name: /返回全局监控/ })).toHaveAttribute('href', monitorPath); fireEvent.click(screen.getByRole('button', { name: '修改条件' })); fireEvent.change(screen.getByRole('combobox', { name: '数据来源' }), { target: { value: 'JT808' } }); fireEvent.click(screen.getByRole('button', { name: /查询轨迹/ })); expect(await screen.findByRole('link', { name: /返回全局监控/ })).toHaveAttribute('href', monitorPath); }); test('keeps committed track criteria authoritative to same-route navigation and browser history', async () => { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const view = render(); expect(view.container.querySelector('.v2-track-page')).not.toHaveClass('has-monitor-return'); expect((await screen.findAllByText('粤A12345')).length).toBeGreaterThan(0); expect(screen.getByTestId('track-map')).toHaveAttribute('data-point-count', '3'); expect(screen.queryByRole('textbox', { name: '搜索轨迹车辆' })).not.toBeInTheDocument(); expect(screen.getByText(/07-15 00:00.*自动来源/)).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '修改条件' })); expect(screen.getByRole('textbox', { name: '搜索轨迹车辆' })).toHaveValue('LTEST000000000001'); fireEvent.click(screen.getByRole('button', { name: '清空轨迹路由' })); expect(await screen.findByText('先选择车辆,再开始轨迹回放')).toBeInTheDocument(); expect(screen.getByTestId('track-map')).toHaveAttribute('data-point-count', '0'); expect(screen.getByRole('textbox', { name: '搜索轨迹车辆' })).toHaveValue(''); fireEvent.click(screen.getByRole('button', { name: '后退轨迹路由' })); expect((await screen.findAllByText('粤A12345')).length).toBeGreaterThan(0); expect(screen.getByTestId('track-map')).toHaveAttribute('data-point-count', '3'); expect(screen.queryByRole('textbox', { name: '搜索轨迹车辆' })).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: '修改条件' })).toHaveAttribute('aria-expanded', 'false'); fireEvent.click(screen.getByRole('button', { name: '前进轨迹路由' })); expect(await screen.findByText('先选择车辆,再开始轨迹回放')).toBeInTheDocument(); expect(screen.getByTestId('track-map')).toHaveAttribute('data-point-count', '0'); }); test('distinguishes a failed vehicle lookup from an empty result and retries in place', async () => { mocks.vehicles.mockRejectedValueOnce(new Error('车辆目录暂时不可用')).mockResolvedValueOnce({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345' }], total: 1, limit: 10, offset: 0 }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render(); fireEvent.focus(screen.getByRole('textbox', { name: '搜索轨迹车辆' })); expect(await screen.findByRole('alert')).toHaveTextContent('车辆目录暂时不可用'); expect(screen.queryByText('没有匹配车辆')).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '重试' })); expect(await screen.findByRole('option', { name: /粤A12345/ })).toBeInTheDocument(); expect(screen.queryByRole('alert')).not.toBeInTheDocument(); expect(mocks.vehicles).toHaveBeenCalledTimes(2); }); test('merges duplicate vehicle sources and keeps the active date shortcut visible', async () => { mocks.vehicles.mockResolvedValue({ items: [ { vin: 'LTEST000000000001', plate: '粤A12345', protocol: 'JT808' }, { vin: 'LTEST000000000001', plate: '粤A12345', protocol: 'GB32960' } ], total: 2, limit: 10, offset: 0 }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render(); expect(screen.getByRole('button', { name: '今天' })).toHaveAttribute('aria-pressed', 'true'); fireEvent.click(screen.getByRole('button', { name: '昨天' })); expect(screen.getByRole('button', { name: '昨天' })).toHaveAttribute('aria-pressed', 'true'); const search = screen.getByRole('textbox', { name: '搜索轨迹车辆' }); fireEvent.focus(search); fireEvent.change(search, { target: { value: '粤A12345' } }); const option = await screen.findByRole('option', { name: '粤A12345 LTEST000000000001 JT/T 808 GB/T 32960 选择' }); expect(document.querySelectorAll('.v2-track-vehicle-options [role="option"]')).toHaveLength(1); expect(option).toHaveTextContent('粤A12345LTEST000000000001JT/T 808GB/T 32960选择'); }); test('renders a map-first replay workspace and connects stop, event, and panel interactions', async () => { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const view = render(); expect((await screen.findAllByText('粤A12345')).length).toBeGreaterThan(0); expect(view.container.querySelector('.v2-track-rail-shell.semi-card')).toBeInTheDocument(); expect(screen.getByLabelText('轨迹查询任务栏')).toHaveClass('v2-workspace-command-bar', 'v2-track-query-header'); expect(screen.getByLabelText('轨迹查询任务栏')).toHaveTextContent('粤A12345'); expect(screen.getByLabelText('轨迹查询任务栏')).toHaveTextContent('3 点'); expect(view.container.querySelector('.v2-track-query-panel')).toHaveClass('is-collapsed'); expect(view.container.querySelector('.v2-track-query-summary')).not.toBeInTheDocument(); expect(view.container.querySelector('.v2-track-rail-result')).toBeInTheDocument(); expect(view.container.querySelector('.v2-track-result-header')).toHaveTextContent('轨迹证据粤A12345 · LTEST0000000000018 km'); expect(screen.getByRole('button', { name: '修改条件' })).toHaveAttribute('aria-expanded', 'false'); expect(screen.queryByRole('textbox', { name: '搜索轨迹车辆' })).not.toBeInTheDocument(); expect(view.container.querySelector('.v2-track-current-card.semi-card')).toBeInTheDocument(); expect(view.container.querySelector('.v2-track-current-coverage')).toBeInTheDocument(); expect(view.container.querySelectorAll('.v2-track-current-metrics > span')).toHaveLength(4); expect(view.container.querySelector('.v2-track-current-evidence')).toHaveTextContent('JT/T 808时间窗完整3 → 3 点报警 —'); expect(view.container.querySelector('.v2-track-current-evidence .v2-track-protocol-tag')).toHaveClass('semi-tag-cyan-light'); expect(view.container.querySelector('.v2-track-coverage-float')).not.toBeInTheDocument(); expect(screen.getByRole('toolbar', { name: '地图轨迹工具' })).toBeInTheDocument(); expect(view.container.querySelector('.v2-track-playback-dock.semi-card')).toBeInTheDocument(); expect(screen.getByTestId('track-map')).toHaveAttribute('data-active-index', '0'); expect(screen.getByText('停留 00:05:00 · 1 个点')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /停留 00:05:00/ })).toHaveClass('semi-button', 'v2-track-list-action'); expect(screen.getByRole('button', { name: /停留 00:05:00/ }).closest('.semi-list-item')).toHaveClass('v2-track-evidence-item'); expect(view.container.querySelector('.v2-track-evidence-list.semi-list')).toBeInTheDocument(); expect(screen.getByRole('slider', { name: '轨迹播放进度' })).toBeInTheDocument(); expect(view.container.querySelector('.v2-track-dock-summary')).toHaveTextContent('行程里程8 km00:20:00 · 1 次停留'); expect(view.container.querySelector('.v2-track-progress-meta')).toHaveTextContent('已暂停 · 数据点 1 / 3'); expect(screen.queryByRole('button', { name: '暂停轨迹播放' })).not.toBeInTheDocument(); await waitFor(() => expect(mocks.reverseGeocode).toHaveBeenCalledTimes(1)); expect(client.getQueryCache().findAll({ queryKey: ['track-address'] })).toHaveLength(1); fireEvent.click(screen.getByRole('tab', { name: /事件点/ })); const acceleration = screen.getByRole('button', { name: /急加速/ }); expect(acceleration).toHaveClass('semi-button', 'v2-track-list-action'); expect(acceleration.closest('.semi-list-item')).toHaveClass('v2-track-evidence-item'); fireEvent.click(acceleration); expect(screen.getByTestId('track-map')).toHaveAttribute('data-active-index', '1'); await waitFor(() => expect(mocks.reverseGeocode).toHaveBeenCalledTimes(2)); await waitFor(() => expect(client.getQueryCache().findAll({ queryKey: ['track-address'] })).toHaveLength(1)); fireEvent.click(screen.getByRole('tab', { name: '概览' })); expect(view.container.querySelectorAll('.v2-track-overview-section')).toHaveLength(3); expect(view.container.querySelectorAll('.v2-track-overview-section.semi-card')).toHaveLength(0); expect(view.container.querySelector('.v2-track-overview-descriptions.semi-descriptions')).toBeInTheDocument(); expect(view.container.querySelector('.v2-track-source-list.semi-list')).toBeInTheDocument(); expect(view.container.querySelectorAll('.v2-track-source-item.semi-list-item')).toHaveLength(1); expect(view.container.querySelector('.v2-track-source-item .v2-track-protocol-tag')).toHaveTextContent('JT/T 808'); expect(view.container.querySelector('.v2-track-source-item .v2-track-protocol-tag')).toHaveClass('semi-tag-cyan-light'); expect(screen.getByText('完整点集').closest('.semi-tag')).toBeInTheDocument(); expect(screen.getByText('通过').closest('.semi-tag')).toBeInTheDocument(); fireEvent.change(screen.getByRole('combobox', { name: '速度' }), { target: { value: '4' } }); fireEvent.click(screen.getByRole('button', { name: '开始轨迹播放' })); expect(view.container.querySelector('.v2-track-progress-meta')).toHaveTextContent(/播放中 · 数据点 [12] \/ 3/); expect(screen.getByTestId('track-map')).toHaveAttribute('data-follow-duration', '65'); fireEvent.click(screen.getByRole('button', { name: '暂停轨迹播放' })); expect(screen.getByTestId('track-map')).toHaveAttribute('data-follow-duration', '180'); fireEvent.click(screen.getByRole('button', { name: '修改条件' })); expect(screen.getByRole('textbox', { name: '搜索轨迹车辆' })).toHaveValue('LTEST000000000001'); expect(screen.getByRole('option', { name: 'GB/T 32960 · 仪表盘里程' })).toBeInTheDocument(); expect(screen.getByRole('option', { name: 'JT/T 808 · GPS 里程' })).toBeInTheDocument(); expect(screen.getByRole('option', { name: '宇通 MQTT · 仪表盘里程' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '收起条件' })).toHaveAttribute('aria-expanded', 'true'); fireEvent.click(screen.getByRole('button', { name: '收起条件' })); expect(screen.queryByRole('textbox', { name: '搜索轨迹车辆' })).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '隐藏查询与明细' })); const expandRail = screen.getByRole('button', { name: '打开轨迹详情' }); expect(expandRail).toBeInTheDocument(); expect(expandRail).toHaveTextContent('轨迹详情8 km · 1 个停留'); fireEvent.click(screen.getByRole('button', { name: '打开轨迹详情' })); expect(screen.getByRole('button', { name: '隐藏查询与明细' })).toBeInTheDocument(); await waitFor(() => expect(mocks.trackPlayback).toHaveBeenCalledTimes(1)); expect(mocks.trackPlayback.mock.calls[0][0].get('maxPoints')).toBe('1600'); expect(mocks.trackPlayback.mock.calls[0][1]).toBeInstanceOf(AbortSignal); expect(client.getQueryCache().findAll({ queryKey: ['track-playback'] })).toHaveLength(1); view.unmount(); await waitFor(() => expect(client.getQueryCache().findAll({ queryKey: ['track-playback'] })).toHaveLength(0)); await waitFor(() => expect(client.getQueryCache().findAll({ queryKey: ['track-address'] })).toHaveLength(0)); }); test('uses the shared Semi editor SideSheet for mobile track criteria and evidence', async () => { Object.defineProperty(window, 'matchMedia', { configurable: true, value: vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })) }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const view = render(); expect((await screen.findAllByText('粤A12345')).length).toBeGreaterThan(0); expect(view.container.querySelector('.v2-track-page')).toHaveClass('is-mobile-layout', 'is-rail-collapsed'); expect(view.container.querySelector('.v2-track-page > .v2-track-stage')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '打开轨迹详情' })); const sheet = await waitFor(() => { const element = document.querySelector('.v2-track-detail-sidesheet .semi-sidesheet-inner'); expect(element).toHaveAttribute('aria-label', '轨迹查询与明细'); return element; }); expect(sheet).toHaveClass('semi-sidesheet-inner'); expect(sheet).toHaveAttribute('id', 'v2-track-mobile-workbench'); expect(document.querySelector('.v2-track-detail-sidesheet')).toHaveClass('v2-workspace-editor-sidesheet', 'semi-sidesheet-bottom'); expect(document.querySelector('.v2-track-detail-sidesheet .v2-track-rail')).toBeInTheDocument(); expect(document.querySelector('.v2-track-detail-sidesheet .v2-track-query-summary')).not.toBeInTheDocument(); expect(screen.queryByLabelText('轨迹查询任务栏')).not.toBeInTheDocument(); expect(screen.queryByRole('textbox', { name: '搜索轨迹车辆' })).not.toBeInTheDocument(); expect(screen.getAllByText('07-15 00:00 – 07-15 23:59 · 自动来源')).toHaveLength(2); expect(screen.getByRole('list', { name: '轨迹查询与明细摘要' })).toHaveTextContent('行程里程8 km00:20:00停留 / 事件1 / 3地图证据点数据点3时间窗完整'); expect(screen.getByRole('button', { name: '修改轨迹查询' })).toHaveTextContent('修改查询'); expect(screen.getByRole('button', { name: /查看地图$/ })).toBeInTheDocument(); expect(sheet).toHaveStyle({ height: 'min(88dvh, 394px)' }); fireEvent.click(screen.getByRole('tab', { name: /事件点/ })); await waitFor(() => expect(sheet).toHaveStyle({ height: 'min(88dvh, 518px)' })); fireEvent.click(screen.getByRole('tab', { name: /停留点/ })); fireEvent.click(screen.getByRole('button', { name: /停留 00:05:00/ })); expect(view.container.querySelector('.v2-track-page')).toHaveClass('is-rail-collapsed'); fireEvent.click(screen.getByRole('button', { name: '打开轨迹详情' })); await screen.findByRole('dialog', { name: '轨迹查询与明细' }); fireEvent.click(screen.getByRole('button', { name: '修改轨迹查询' })); expect(screen.getByRole('textbox', { name: '搜索轨迹车辆' })).toHaveValue('LTEST000000000001'); expect(screen.getByRole('button', { name: '收起轨迹查询' })).toHaveTextContent('收起查询'); expect(sheet).toHaveStyle({ height: 'min(88dvh, 780px)' }); expect(screen.getByRole('button', { name: '关闭轨迹查询与明细' })).toBeInTheDocument(); }); test('keeps one clear mobile query entry before a vehicle is selected', async () => { Object.defineProperty(window, 'matchMedia', { configurable: true, value: vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })) }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render(); expect(await screen.findByRole('button', { name: /选择车辆/ })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: '打开轨迹详情' })).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: /选择车辆/ })); expect(await screen.findByRole('dialog', { name: '轨迹查询与明细' })).toBeInTheDocument(); expect(screen.getByRole('list', { name: '轨迹查询与明细摘要' })).toHaveTextContent('查询状态待查询请先选择车辆'); expect(screen.getByRole('textbox', { name: '搜索轨迹车辆' })).toBeInTheDocument(); }); test('coalesces rapid track scrubbing into one address lookup for the final point', async () => { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render(); expect((await screen.findAllByText('粤A12345')).length).toBeGreaterThan(0); await waitFor(() => expect(mocks.reverseGeocode).toHaveBeenCalledTimes(1)); const progress = screen.getByRole('slider', { name: '轨迹播放进度' }); expect(progress).toHaveAttribute('max', '2'); fireEvent.change(progress, { target: { value: '1' } }); fireEvent.change(progress, { target: { value: '2' } }); expect(screen.getByText('位置已变化,等待地址解析…')).toBeInTheDocument(); await waitFor(() => expect(mocks.reverseGeocode).toHaveBeenCalledTimes(2)); expect(mocks.reverseGeocode.mock.calls[1][0].get('longitude')).toBe('113.3000'); expect(mocks.reverseGeocode.mock.calls[1][0].get('latitude')).toBe('23.3000'); });