import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, expect, test, vi } from 'vitest'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { api } from '../../api/client'; import type { VehicleDetail, VehicleRealtimeRow } from '../../api/types'; import { buildMonitorPath, withMonitorReturn } from '../routing/monitorContext'; import { ROUTER_FUTURE } from '../routing/routerConfig'; import VehiclePage from './VehiclePage'; const fleetMapVehicles = vi.hoisted(() => vi.fn()); const layout = vi.hoisted(() => ({ mobile: false })); vi.mock('../map/FleetMap', () => ({ FleetMap: ({ vehicles }: { vehicles: VehicleRealtimeRow[] }) => { fleetMapVehicles(vehicles); return
; } })); vi.mock('../auth/AuthGate', () => ({ usePlatformSession: () => ({ session: { name: 'test-viewer', role: 'viewer', authMode: 'enforce' } }) })); vi.mock('../hooks/useMobileLayout', () => ({ useMobileLayout: () => layout.mobile })); const initialRealtime = { vin: 'LTEST000000000001', plate: '粤A12345', phone: '', oem: '', protocols: ['JT808'], sourceStatus: [], sourceCount: 1, onlineSourceCount: 1, online: true, bindingStatus: 'bound', primaryProtocol: 'JT808', longitude: 113.26, latitude: 23.13, speedKmh: 20, socPercent: 80, totalMileageKm: 1000, lastSeen: '2026-07-16 10:00:00', reportIntervalMs: 30_000 } satisfies VehicleRealtimeRow; const detail = { vin: initialRealtime.vin, lookupKey: initialRealtime.vin, lookupResolved: true, realtimeSummary: initialRealtime, sources: ['JT808'], sourceStatus: [], realtime: [], history: { items: [], total: 0, limit: 20, offset: 0 }, raw: { items: [], total: 0, limit: 20, offset: 0 }, mileage: { items: [], total: 0, limit: 20, offset: 0 }, quality: { items: [], total: 0, limit: 20, offset: 0 } } satisfies VehicleDetail; afterEach(() => { cleanup(); layout.mobile = false; vi.restoreAllMocks(); fleetMapVehicles.mockReset(); }); test('polls lightweight single-vehicle realtime data and passes its report interval to the map', async () => { const movedRealtime = { ...initialRealtime, longitude: 113.28, latitude: 23.15, lastSeen: '2026-07-16 10:00:30' }; vi.spyOn(api, 'vehicleDetail').mockResolvedValue(detail); const realtimeSpy = vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [movedRealtime], total: 1, limit: 1, offset: 0 }); vi.spyOn(api, 'latestTelemetry').mockResolvedValue({ values: [], categories: [], scannedFrames: 0 } as never); const sourceEvidence = vi.spyOn(api, 'vehicleSourceEvidence').mockResolvedValue({ vin: initialRealtime.vin, plate: initialRealtime.plate, mileageDate: '2026-07-16', recommendedLocationProtocol: 'JT808', recommendedLocationLabel: 'JT808', locationConflict: false, locationSources: [], mileageSources: [], comparison: { locationMaxDistanceM: 0, totalMileageDeltaKm: 0, dailyMileageDeltaKm: 0, reportTimeDeltaSeconds: 0 }, asOf: '2026-07-16T10:00:00+08:00' }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const view = render(} />); await waitFor(() => expect(realtimeSpy).toHaveBeenCalledTimes(1)); const realtimeQuery = client.getQueryCache().find({ queryKey: ['vehicle-detail-realtime', initialRealtime.vin] }); expect((realtimeQuery?.options as { refetchInterval?: number }).refetchInterval).toBe(10_000); const params = realtimeSpy.mock.calls[0][0]; expect(params).toBeDefined(); expect(params!.get('keywords')).toBe(initialRealtime.vin); expect(params!.get('limit')).toBe('1'); await waitFor(() => expect(fleetMapVehicles).toHaveBeenLastCalledWith([ expect.objectContaining({ longitude: 113.28, latitude: 23.15, reportIntervalMs: 30_000 }) ])); expect(view.container.querySelector('.v2-identity-band')).toHaveClass('semi-card'); expect(screen.getByRole('heading', { name: '最新上报', level: 5 })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: '实时位置', level: 5 })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: '最近事件', level: 5 })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: '实时遥测', level: 5 })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: '车辆主档', level: 5 })).toBeInTheDocument(); expect(screen.getByRole('navigation', { name: '单车详情导航' })).toBeInTheDocument(); const telemetryPanel = view.container.querySelector('#vehicle-telemetry-panel')!; const scrollIntoView = vi.fn(); Object.defineProperty(telemetryPanel, 'scrollIntoView', { configurable: true, value: scrollIntoView }); fireEvent.click(screen.getByRole('button', { name: '跳转到实时遥测' })); expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' }); expect(telemetryPanel).toHaveFocus(); expect(view.container.querySelector('.v2-record-descriptions.semi-descriptions')).toBeInTheDocument(); expect(screen.getByText('待维护').closest('.semi-tag')).toBeInTheDocument(); expect(sourceEvidence).not.toHaveBeenCalled(); const locationSource = screen.getByRole('button', { name: '查看全部位置来源' }); expect(locationSource).toHaveClass('semi-button', 'v2-map-source-link'); expect(locationSource.closest('.semi-card')).toHaveClass('v2-single-map-card'); const totalMileageSource = screen.getByRole('button', { name: '查看总里程全部来源' }); expect(totalMileageSource).toHaveClass('semi-button', 'v2-live-source-link'); expect(totalMileageSource.closest('.semi-card')).toHaveClass('v2-live-overview'); fireEvent.click(locationSource); await waitFor(() => expect(sourceEvidence).toHaveBeenCalledTimes(1)); }); test('uses the shared Semi empty recovery surface when a vehicle cannot be resolved', async () => { vi.spyOn(api, 'vehicleDetail').mockResolvedValue({ ...detail, lookupResolved: false }); vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [], total: 0, limit: 1, offset: 0 }); vi.spyOn(api, 'latestTelemetry').mockResolvedValue({ values: [], categories: [], scannedFrames: 0 } as never); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const view = render(} />); expect(await screen.findByText('未找到车辆')).toBeInTheDocument(); expect(view.container.querySelector('.v2-not-found.semi-card .semi-empty')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /重新查询/ })).toHaveClass('semi-button-primary'); }); test('shows deduplicated Semi vehicle candidates and opens the selected VIN', async () => { const workspace = document.createElement('main'); workspace.className = 'v2-content'; workspace.scrollTop = 320; const scrollTo = vi.fn(); Object.defineProperty(workspace, 'scrollTo', { configurable: true, value: scrollTo }); document.body.appendChild(workspace); const candidate = { vin: initialRealtime.vin, plate: initialRealtime.plate, phone: '13800000000', oem: '测试品牌', protocol: 'JT808', online: true, lastSeen: initialRealtime.lastSeen, locationText: '广东省广州市', bindingScore: 100 }; const vehicles = vi.spyOn(api, 'vehicles').mockResolvedValue({ items: [candidate, { ...candidate }], total: 2, limit: 8, offset: 0 }); vi.spyOn(api, 'vehicleDetail').mockResolvedValue(detail); vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [initialRealtime], total: 1, limit: 1, offset: 0 }); vi.spyOn(api, 'latestTelemetry').mockResolvedValue({ values: [], categories: [], scannedFrames: 0 } as never); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const view = render(} />} />); const capabilitySummary = screen.getByLabelText('可查询的数据范围'); expect(capabilitySummary).toHaveTextContent('统一身份'); expect(capabilitySummary).toHaveTextContent('实时状态'); expect(capabilitySummary).toHaveTextContent('来源证据'); const input = screen.getByRole('textbox', { name: '搜索车辆' }); expect(input).toHaveAttribute('aria-expanded', 'false'); expect(input).toHaveAttribute('aria-controls', 'v2-vehicle-search-options'); expect(view.container.querySelector('.v2-vehicle-search-options')).not.toBeInTheDocument(); await waitFor(() => expect(vehicles).toHaveBeenCalledTimes(1)); expect(screen.getByRole('heading', { name: '最近上报车辆', level: 5 })).toBeInTheDocument(); expect(await screen.findByText('当前显示 1 辆')).toBeInTheDocument(); expect(screen.getAllByRole('listitem', { name: `打开 ${initialRealtime.plate} 车辆档案` })).toHaveLength(1); fireEvent.focus(input); expect(input).toHaveAttribute('aria-expanded', 'true'); fireEvent.change(input, { target: { value: initialRealtime.plate } }); await waitFor(() => expect(vehicles).toHaveBeenCalledTimes(2)); expect(screen.getByRole('heading', { name: '匹配车辆快捷入口', level: 5 })).toBeInTheDocument(); const option = await screen.findByRole('option', { name: `${initialRealtime.plate} ${initialRealtime.vin} JT808 选择` }); expect(view.container.querySelector('#v2-vehicle-search-options.v2-vehicle-candidate-list')).toBeInTheDocument(); expect(view.container.querySelector('#v2-vehicle-search-options')).toHaveAttribute('aria-busy', 'false'); expect(screen.getAllByRole('option')).toHaveLength(1); fireEvent.mouseDown(option); fireEvent.click(option); expect(await screen.findByText(initialRealtime.vin)).toBeInTheDocument(); expect(workspace.scrollTop).toBe(0); expect(scrollTo).toHaveBeenCalledWith({ top: 0, left: 0, behavior: 'auto' }); expect(screen.getByRole('group', { name: '车辆快捷操作' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '切换车辆' })).toHaveClass('semi-button', 'semi-button-primary'); workspace.remove(); }); test('keeps the monitor return on the vehicle page and its nested investigation actions', async () => { vi.spyOn(api, 'vehicleDetail').mockResolvedValue(detail); vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [initialRealtime], total: 1, limit: 1, offset: 0 }); vi.spyOn(api, 'latestTelemetry').mockResolvedValue({ values: [], categories: [], scannedFrames: 0 } as never); const addressSpy = vi.spyOn(api, 'reverseGeocode').mockResolvedValue({ provider: 'amap', longitude: 113.26, latitude: 23.13, formattedAddress: '广东省广州市测试道路' }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const monitorPath = buildMonitorPath({ mode: 'map', keyword: '', protocol: '', status: '', selectedVin: 'LTEST000000000001', detailOpen: true, viewport: { zoom: 13, bounds: '113.1,23.0,113.4,23.3' }, listOffset: 0, listLimit: 50, hasViewport: true }); const initialEntry = withMonitorReturn(`/vehicles/${initialRealtime.vin}`, monitorPath); render(} />); expect(await screen.findByRole('link', { name: /返回全局监控/ })).toHaveAttribute('href', monitorPath); const actionGroup = screen.getByRole('group', { name: '车辆快捷操作' }); expect(actionGroup).toHaveTextContent('切换车辆'); expect(actionGroup).toHaveTextContent('轨迹回放'); expect(actionGroup).toHaveTextContent('历史数据'); expect(actionGroup).toHaveTextContent('里程查询'); expect(actionGroup).toHaveTextContent('告警事件'); expect(withMonitorReturn(`/tracks?vin=${encodeURIComponent(initialRealtime.vin)}`, monitorPath)).toContain(`monitorReturn=${encodeURIComponent(monitorPath)}`); expect(withMonitorReturn(`/history?vin=${encodeURIComponent(initialRealtime.vin)}`, monitorPath)).toContain(`monitorReturn=${encodeURIComponent(monitorPath)}`); expect(withMonitorReturn(`/statistics?vins=${encodeURIComponent(initialRealtime.vin)}`, monitorPath)).toContain(`monitorReturn=${encodeURIComponent(monitorPath)}`); const liveOverview = screen.getByText('最新上报').closest('.semi-card'); const archive = screen.getByRole('heading', { name: '车辆主档', level: 5 }).closest('.semi-card'); expect(liveOverview).not.toBeNull(); expect(archive).not.toBeNull(); expect(liveOverview).toHaveClass('v2-record-card', 'v2-live-overview'); expect(archive).toHaveClass('v2-record-card', 'v2-archive-card'); expect(liveOverview!.compareDocumentPosition(archive!)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); expect(addressSpy).not.toHaveBeenCalled(); const addressAction = screen.getByRole('button', { name: '解析当前位置' }); expect(addressAction).toHaveClass('semi-button', 'v2-current-address-action'); fireEvent.click(addressAction); expect(await screen.findByText('广东省广州市测试道路')).toBeInTheDocument(); expect(addressSpy).toHaveBeenCalledTimes(1); }); test('shows unavailable live fields as dashes instead of fabricated zeroes', async () => { const unavailable = { ...initialRealtime, speedAvailable: false, speedKmh: 0, socAvailable: false, socPercent: 0, mileageAvailable: false, totalMileageKm: 0, todayMileageAvailable: false, todayMileageKm: 0 }; vi.spyOn(api, 'vehicleDetail').mockResolvedValue({ ...detail, realtimeSummary: unavailable, mileage: { items: [{ vin: unavailable.vin, plate: unavailable.plate, date: '2026-07-15', startMileageKm: 900, endMileageKm: 988.8, dailyMileageKm: 88.8, source: 'JT808' }], total: 1, limit: 20, offset: 0 } }); vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [unavailable], total: 1, limit: 1, offset: 0 }); vi.spyOn(api, 'latestTelemetry').mockResolvedValue({ values: [], categories: [], scannedFrames: 0 } as never); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render(} />); const liveOverview = (await screen.findByText('最新上报')).closest('.semi-card')!; for (const label of ['速度', 'SOC', '总里程', '当日里程']) { const metric = Array.from(liveOverview.querySelectorAll('.v2-live-grid > div')).find((item) => item.querySelector('small')?.textContent === label); expect(metric).toHaveTextContent('—'); expect(metric).not.toHaveTextContent('88.8'); expect(metric).not.toHaveTextContent(/^0/); } }); test('keeps protocol telemetry independent and explains mileage semantics', async () => { vi.spyOn(api, 'vehicleDetail').mockResolvedValue({ ...detail, sources: ['GB32960', 'JT808'] }); vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [{ ...initialRealtime, protocols: ['GB32960', 'JT808'] }], total: 1, limit: 1, offset: 0 }); vi.spyOn(api, 'latestTelemetry').mockResolvedValue({ vin: initialRealtime.vin, categories: [{ key: 'vehicle', label: '整车数据', count: 2 }], values: [ { key: 'total_mileage_km', sourceField: 'gb32960.vehicle.total_mileage_km', label: '仪表盘总里程', unit: 'km', category: 'vehicle', valueType: 'numeric', value: 1200, protocol: 'GB32960', frameId: 'gb', deviceTime: '', serverTime: '2026-07-16 10:00:00', quality: 'good', qualityReason: '正常', freshnessSeconds: 1 }, { key: 'total_mileage_km', sourceField: 'jt808.location.total_mileage_km', label: 'GPS 总里程', unit: 'km', category: 'vehicle', valueType: 'numeric', value: 1180, protocol: 'JT808', frameId: 'jt', deviceTime: '', serverTime: '2026-07-16 10:00:00', quality: 'good', qualityReason: '正常', freshnessSeconds: 1 } ], asOf: '2026-07-16T10:00:00+08:00', staleAfterSeconds: 300, scannedFrames: 2, evidence: 'test' }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render(} />); expect(await screen.findByRole('tab', { name: /GB\/T 32960/, selected: true })).toBeInTheDocument(); expect(document.querySelector('.v2-telemetry-table.semi-table-wrapper')).toBeInTheDocument(); expect(document.querySelectorAll('.v2-telemetry-table [role="columnheader"]')).toHaveLength(5); expect(document.querySelector('.v2-telemetry-mobile-list')).not.toBeInTheDocument(); expect(document.querySelector('.v2-event-list.semi-list')).toBeInTheDocument(); expect(document.querySelector('.v2-event-empty.semi-empty')).toBeInTheDocument(); expect(screen.getByText('仪表盘总里程')).toBeInTheDocument(); expect(screen.queryByText('GPS 总里程')).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('tab', { name: /JT\/T 808/ })); expect(screen.getByRole('tab', { name: /JT\/T 808/, selected: true })).toBeInTheDocument(); expect(screen.getByText('GPS 总里程')).toBeInTheDocument(); expect(screen.queryByText('仪表盘总里程')).not.toBeInTheDocument(); }); test('uses compact Semi telemetry evidence cards on mobile', async () => { layout.mobile = true; vi.spyOn(api, 'vehicleDetail').mockResolvedValue(detail); vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [initialRealtime], total: 1, limit: 1, offset: 0 }); vi.spyOn(api, 'latestTelemetry').mockResolvedValue({ vin: initialRealtime.vin, categories: [{ key: 'vehicle', label: '整车数据', count: 1 }], values: [{ key: 'speed_kmh', sourceField: 'jt808.location.speed_kmh', label: 'GPS 速度', unit: 'km/h', category: 'vehicle', valueType: 'numeric', value: 36, protocol: 'JT808', frameId: 'jt-mobile', deviceTime: '2026-07-16 10:00:00', serverTime: '2026-07-16 10:00:01', quality: 'good', qualityReason: '正常', freshnessSeconds: 1 }], asOf: '2026-07-16T10:00:01+08:00', staleAfterSeconds: 300, scannedFrames: 1, evidence: 'test' }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render(} />); expect(await screen.findByText('GPS 速度')).toBeInTheDocument(); expect(document.querySelector('.v2-telemetry-table')).not.toBeInTheDocument(); expect(document.querySelector('.v2-telemetry-mobile-list.semi-list')).toBeInTheDocument(); expect(document.querySelector('.v2-telemetry-mobile-item.semi-list-item')).toHaveTextContent('36km/h正常'); });