Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.test.tsx
2026-07-20 07:58:45 +08:00

641 lines
41 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 { 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 <div data-testid="fleet-map" />;
}
}));
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, asOf: '2026-07-16T02:00:00Z' } 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(<QueryClientProvider client={client}><MemoryRouter initialEntries={[`/vehicles/${initialRealtime.vin}`]} future={ROUTER_FUTURE}><Routes><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
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 })
]));
const commandCard = view.container.querySelector('.v2-identity-band');
expect(commandCard).toHaveClass('semi-card', 'v2-vehicle-command-card', 'v2-live-overview');
expect(screen.getByLabelText('粤A12345 车辆档案')).toHaveClass('v2-vehicle-record-v3');
expect(screen.getByLabelText('单车任务栏')).toHaveClass('v2-workspace-command-bar', 'v2-vehicle-record-command');
expect(screen.getByLabelText('单车任务栏')).toHaveTextContent('粤A12345');
expect(screen.getByLabelText('单车任务栏')).toHaveTextContent('LTEST000000000001');
expect(screen.getByRole('list', { name: '车辆实时指标' })).toHaveClass('v2-workspace-metric-list');
expect(commandCard?.querySelector('.v2-vehicle-live-metric-rail')).toHaveClass('semi-card', 'v2-workspace-metric-rail', 'is-queue');
expect(commandCard?.querySelectorAll('.v2-workspace-metric-list > [role="listitem"]')).toHaveLength(5);
expect(commandCard?.querySelector('.v2-workspace-metric-list > span:first-child')).toHaveTextContent('速度');
expect(commandCard?.querySelector('.v2-workspace-metric-list > span:nth-child(2)')).toHaveTextContent('SOC');
expect(commandCard?.querySelector('.v2-workspace-metric-list > span:nth-child(4)')).toHaveTextContent('当日里程');
expect(commandCard?.querySelector('.v2-vehicle-live-source-context')).toHaveTextContent('推荐来源JT/T 808');
expect(commandCard?.querySelector('.v2-live-primary-protocol')).toHaveClass('v2-protocol-tag', 'semi-tag-cyan-light');
expect(commandCard?.querySelector('.v2-identity-sources .v2-protocol-tag')).toHaveClass('semi-tag-cyan-light');
expect(screen.getByLabelText('粤A12345 车辆档案')).toHaveFocus();
expect(screen.getByText('扫描 0 帧 · 上海时间 10:00:00')).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('heading', { name: '车辆主档', level: 5 })).toBeInTheDocument();
expect(screen.getByRole('navigation', { name: '单车详情导航' })).toBeInTheDocument();
expect(view.container.querySelector('.v2-vehicle-record-page > .v2-source-evidence')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '来源证据' })).toBeInTheDocument();
const telemetryPanel = view.container.querySelector<HTMLElement>('#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-workspace-metric-action');
expect(totalMileageSource.closest('.semi-card')).toHaveClass('v2-vehicle-live-metric-rail');
expect(totalMileageSource.closest('.v2-live-overview')).toBe(commandCard);
fireEvent.click(screen.getByRole('button', { name: '来源证据' }));
await waitFor(() => expect(sourceEvidence).toHaveBeenCalledTimes(1));
});
test('holds the vehicle identity at the top through late layout shifts and yields to deliberate scrolling', 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 client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const workspace = document.createElement('main');
workspace.className = 'v2-content';
document.body.appendChild(workspace);
render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[`/vehicles/${initialRealtime.vin}`]} future={ROUTER_FUTURE}><Routes><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>, { container: workspace });
await screen.findByLabelText('粤A12345 车辆档案');
await new Promise((resolve) => window.setTimeout(resolve, 420));
workspace.scrollTop = 312;
fireEvent.scroll(workspace);
await waitFor(() => expect(workspace.scrollTop).toBe(0));
fireEvent.wheel(workspace);
workspace.scrollTop = 188;
fireEvent.scroll(workspace);
await new Promise((resolve) => window.setTimeout(resolve, 32));
expect(workspace.scrollTop).toBe(188);
workspace.remove();
});
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(<QueryClientProvider client={client}><MemoryRouter initialEntries={['/vehicles/UNKNOWN']} future={ROUTER_FUTURE}><Routes><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
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: '测试品牌',
protocols: ['JT808'],
missingProtocols: [],
sourceStatus: [],
sourceCount: 1,
onlineSourceCount: 1,
online: true,
lastSeen: initialRealtime.lastSeen,
locationText: '广东省广州市',
bindingScore: 100,
bindingStatus: 'bound'
};
const vehicles = vi.spyOn(api, 'vehicleCoverage').mockResolvedValue({
items: [candidate, { ...candidate }],
total: 2,
limit: 12,
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(<QueryClientProvider client={client}><MemoryRouter initialEntries={['/vehicles']} future={ROUTER_FUTURE}><Routes><Route path="/vehicles" element={<VehiclePage />} /><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
expect(await screen.findByRole('heading', { name: '查找车辆', level: 5 })).toBeInTheDocument();
expect(screen.getByLabelText('查车操作')).toHaveClass('v2-workspace-command-bar', 'v2-vehicle-command-bar');
expect(screen.getByRole('tablist', { name: '车辆目录视图' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: '全部车辆' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('heading', { name: '车辆定位', level: 5 }).closest('.semi-card')).toHaveClass('v2-vehicle-query-panel', 'v2-workspace-filter-panel');
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));
const initialQuery = vehicles.mock.calls[0]?.[0];
expect(initialQuery).toBeDefined();
expect(initialQuery!.get('limit')).toBe('10');
expect(screen.getByRole('heading', { name: '授权车辆目录', level: 5 })).toBeInTheDocument();
const directoryMetrics = screen.getByRole('list', { name: '车辆目录摘要' });
expect(directoryMetrics.closest('.v2-vehicle-directory-metric-rail')).toHaveClass('semi-card', 'v2-workspace-metric-rail', 'is-queue');
expect(Array.from(directoryMetrics.querySelectorAll(':scope > [role="listitem"] > small')).map((item) => item.textContent)).toEqual(['授权车辆', '本页在线', '本页多源']);
expect(directoryMetrics.querySelector(':scope > [role="listitem"]:nth-child(1)')).toHaveTextContent('授权车辆2辆账号范围');
expect(directoryMetrics.querySelector(':scope > [role="listitem"]:nth-child(2)')).toHaveTextContent('本页在线1辆本页 1 辆');
expect(directoryMetrics.querySelector(':scope > [role="listitem"]:nth-child(3)')).toHaveTextContent('本页多源0辆2+ 协议来源');
expect(screen.getByText('第 1 / 1 页').closest('.semi-tag')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '刷新车辆目录' })).toHaveClass('semi-button');
expect(view.container.querySelector('.v2-vehicle-directory-table.semi-table-wrapper')).toBeInTheDocument();
expect(view.container.querySelector('.v2-vehicle-search-page')).toHaveClass('v2-vehicle-directory-v3');
expect(view.container.querySelector('.v2-vehicle-directory-profile')).toHaveTextContent('测试品牌13800000000');
expect(view.container.querySelector('.v2-vehicle-directory-protocols .v2-protocol-tag')).toHaveClass('semi-tag-cyan-light');
expect(view.container.querySelector('.v2-vehicle-discovery-shell')).toContainElement(screen.getByLabelText('查车操作'));
expect(screen.getByTestId(`vehicle-directory-row-${initialRealtime.vin}`)).toHaveAttribute('role', 'button');
expect(screen.getByTestId(`vehicle-directory-row-${initialRealtime.vin}`)).toHaveClass('is-online');
expect(screen.getByTestId(`vehicle-directory-row-${initialRealtime.vin}`)).toHaveAttribute('aria-label', `打开 ${initialRealtime.plate} 车辆档案`);
expect(screen.getByTestId(`vehicle-directory-row-${initialRealtime.vin}`).querySelector('button')).not.toBeInTheDocument();
expect(screen.getByTestId(`vehicle-directory-row-${initialRealtime.vin}`)).toHaveTextContent('查看档案');
fireEvent.focus(input);
expect(input).toHaveAttribute('aria-expanded', 'false');
expect(view.container.querySelector('.v2-vehicle-search-options')).not.toBeInTheDocument();
fireEvent.change(input, { target: { value: initialRealtime.plate } });
await waitFor(() => expect(vehicles).toHaveBeenCalledTimes(2));
expect(input).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('heading', { name: '匹配车辆', level: 5 })).toBeInTheDocument();
const option = await screen.findByRole('option', { name: `${initialRealtime.plate} ${initialRealtime.vin} JT/T 808 选择` });
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 mobile vehicle directory primary and applies search from a Semi bottom sheet', async () => {
layout.mobile = true;
const items = Array.from({ length: 10 }, (_, index) => ({
vin: `LTEST${String(index).padStart(12, '0')}`,
plate: `粤A${String(index).padStart(5, '0')}`,
phone: '',
oem: '测试品牌',
protocols: [index % 2 ? 'JT808' : 'GB32960'],
missingProtocols: [],
sourceStatus: [],
sourceCount: 1,
onlineSourceCount: index < 6 ? 1 : 0,
online: index < 6,
lastSeen: `2026-07-16 10:00:${String(index).padStart(2, '0')}`,
locationText: '广东省广州市',
bindingScore: 100,
bindingStatus: 'bound'
}));
vi.spyOn(api, 'vehicleCoverage').mockResolvedValue({ items, total: 10, limit: 8, offset: 0 });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render(<QueryClientProvider client={client}><MemoryRouter initialEntries={['/vehicles']} future={ROUTER_FUTURE}><Routes><Route path="/vehicles" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
const mobileDirectoryMetrics = await screen.findByRole('list', { name: '车辆目录摘要' });
await waitFor(() => expect(mobileDirectoryMetrics).toHaveTextContent('本页在线6辆本页 8 辆'));
expect(view.container.querySelectorAll('.v2-vehicle-recent-grid [role="listitem"]')).toHaveLength(8);
expect(screen.getAllByRole('button', { name: /打开 .* 车辆档案/ })).toHaveLength(8);
expect(screen.getAllByText(/GB\/T 32960|JT\/T 808/).length).toBeGreaterThan(0);
expect(view.container.querySelector('.v2-vehicle-directory-table')).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-vehicle-query-panel')).not.toBeInTheDocument();
expect(screen.queryByLabelText('查车操作')).not.toBeInTheDocument();
expect(screen.getByRole('tablist', { name: '车辆目录视图' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: '全部车辆' })).toHaveAttribute('aria-selected', 'true');
const toggle = screen.getByRole('button', { name: '打开车辆搜索10 辆授权车辆' });
expect(toggle).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(toggle);
const input = screen.getByRole('textbox', { name: '搜索车辆' });
expect(input).toBeVisible();
expect(document.querySelector('.v2-vehicle-mobile-filter-sidesheet')).toBeInTheDocument();
expect(screen.getByRole('list', { name: '车辆搜索摘要' })).toHaveTextContent('授权范围10 辆当前账号可见当前条件全部车辆浏览完整目录快速结果待输入最多展示 6 辆');
expect(screen.getAllByRole('button', { name: /打开 .* 车辆档案/ })).toHaveLength(8);
fireEvent.change(input, { target: { value: items[0].plate } });
expect(await screen.findByRole('listbox')).toHaveAttribute('id', 'v2-vehicle-mobile-search-options');
expect(screen.getByRole('list', { name: '车辆搜索摘要' })).toHaveTextContent(`当前条件${items[0].plate}车牌 / VIN / 终端`);
expect(screen.getAllByRole('button', { name: /打开 .* 车辆档案/ })).toHaveLength(8);
fireEvent.click(screen.getByRole('button', { name: '应用搜索' }));
await waitFor(() => expect(screen.getByRole('button', { name: `打开车辆搜索:${items[0].plate} · 10 辆匹配` })).toHaveAttribute('aria-expanded', 'false'));
expect(screen.getByRole('heading', { name: '匹配车辆', level: 5 })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: `打开车辆搜索:${items[0].plate} · 10 辆匹配` }));
fireEvent.change(screen.getByRole('textbox', { name: '搜索车辆' }), { target: { value: items[1].plate } });
fireEvent.click(screen.getByRole('button', { name: '关闭车辆搜索' }));
await waitFor(() => expect(screen.getByRole('button', { name: `打开车辆搜索:${items[0].plate} · 10 辆匹配` })).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: `打开车辆搜索:${items[0].plate} · 10 辆匹配` }));
fireEvent.click(screen.getByRole('button', { name: '重置范围' }));
expect(screen.getByRole('dialog', { name: '车辆搜索' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: `关闭车辆搜索:${items[0].plate} · 10 辆匹配` })).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(screen.getByRole('button', { name: '应用搜索' }));
await waitFor(() => expect(screen.getByRole('button', { name: '打开车辆搜索10 辆授权车辆' })).toBeInTheDocument());
expect(screen.getByRole('heading', { name: '授权车辆', level: 5 })).toBeInTheDocument();
});
test('filters the authorized directory with one consistent Semi view switcher', async () => {
const candidate = {
vin: initialRealtime.vin,
plate: initialRealtime.plate,
phone: '',
oem: '测试品牌',
protocols: ['JT808', 'GB32960'],
missingProtocols: [],
sourceStatus: [],
sourceCount: 2,
onlineSourceCount: 2,
online: true,
lastSeen: initialRealtime.lastSeen,
locationText: '广东省广州市',
bindingScore: 100,
bindingStatus: 'bound'
};
const vehicles = vi.spyOn(api, 'vehicleCoverage').mockResolvedValue({ items: [candidate], total: 1, limit: 10, offset: 0 });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter initialEntries={['/vehicles']} future={ROUTER_FUTURE}><Routes><Route path="/vehicles" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
await screen.findByText(initialRealtime.plate);
const viewTabs = screen.getByRole('tablist', { name: '车辆目录视图' });
expect(viewTabs).toHaveTextContent('全部车辆当前在线当前离线多源车辆');
fireEvent.click(screen.getByRole('tab', { name: '当前在线' }));
await waitFor(() => expect(vehicles.mock.calls[vehicles.mock.calls.length - 1]?.[0]?.get('online')).toBe('online'));
expect(screen.getByRole('tab', { name: '当前在线' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('heading', { name: '当前在线', level: 5 })).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: '当前离线' }));
await waitFor(() => expect(vehicles.mock.calls[vehicles.mock.calls.length - 1]?.[0]?.get('online')).toBe('offline'));
fireEvent.click(screen.getByRole('tab', { name: '多源车辆' }));
await waitFor(() => expect(vehicles.mock.calls[vehicles.mock.calls.length - 1]?.[0]?.get('coverage')).toBe('multi'));
fireEvent.click(screen.getByRole('tab', { name: '全部车辆' }));
expect(screen.getByRole('tab', { name: '全部车辆' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('heading', { name: '授权车辆目录', level: 5 })).toBeInTheDocument();
expect(vehicles.mock.calls[0]?.[0]?.has('online')).toBe(false);
expect(vehicles.mock.calls[0]?.[0]?.has('coverage')).toBe(false);
});
test('keeps a partial vehicle query in the directory instead of navigating to an invalid detail route', async () => {
const candidate = {
vin: initialRealtime.vin,
plate: initialRealtime.plate,
phone: '13800000000',
oem: '测试品牌',
protocols: ['JT808'],
missingProtocols: [],
sourceStatus: [],
sourceCount: 1,
onlineSourceCount: 1,
online: true,
lastSeen: initialRealtime.lastSeen,
locationText: '广东省广州市',
bindingScore: 100,
bindingStatus: 'bound'
};
vi.spyOn(api, 'vehicleCoverage').mockResolvedValue({ items: [candidate], total: 1, limit: 10, offset: 0 });
const vehicleDetail = vi.spyOn(api, 'vehicleDetail');
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render(<QueryClientProvider client={client}><MemoryRouter initialEntries={['/vehicles']} future={ROUTER_FUTURE}><Routes><Route path="/vehicles" element={<VehiclePage />} /><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
await screen.findByRole('heading', { name: '授权车辆目录', level: 5 });
const input = screen.getByRole('textbox', { name: '搜索车辆' });
fireEvent.change(input, { target: { value: '粤A' } });
expect(await screen.findByRole('listbox')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /查询车辆/ }));
expect(view.container.querySelector('.v2-vehicle-search-options')).not.toBeInTheDocument();
expect(screen.getByRole('heading', { name: '匹配车辆', level: 5 })).toBeInTheDocument();
expect(vehicleDetail).not.toHaveBeenCalled();
});
test('paginates the complete desktop authorized vehicle directory', async () => {
const firstPage = Array.from({ length: 10 }, (_, index) => ({
vin: `LTEST${String(index).padStart(12, '0')}`,
plate: `粤A${String(index).padStart(5, '0')}`,
phone: '',
oem: '测试品牌',
protocols: [index % 2 ? 'JT808' : 'GB32960'],
missingProtocols: [],
sourceStatus: [],
sourceCount: 1,
onlineSourceCount: index < 6 ? 1 : 0,
online: index < 6,
lastSeen: `2026-07-16 10:00:${String(index).padStart(2, '0')}`,
locationText: '广东省广州市',
bindingScore: 100,
bindingStatus: 'bound'
}));
const lastVehicle = { ...firstPage[0], vin: 'LTEST000000000010', plate: '粤A00010' };
const vehicles = vi.spyOn(api, 'vehicleCoverage').mockImplementation((params) => {
const query = params ?? new URLSearchParams();
return Promise.resolve({
items: query.get('offset') === '10' ? [lastVehicle] : firstPage,
total: 11,
limit: 10,
offset: Number(query.get('offset') ?? 0)
});
});
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render(<QueryClientProvider client={client}><MemoryRouter initialEntries={['/vehicles']} future={ROUTER_FUTURE}><Routes><Route path="/vehicles" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
expect(await screen.findByText('粤A00000')).toBeInTheDocument();
expect(view.container.querySelector('.v2-vehicle-directory-table.semi-table-wrapper')).toBeInTheDocument();
expect(screen.getByText('共 11 辆 · 本页 10 辆')).toBeInTheDocument();
expect(document.querySelector('.v2-table-pagination .semi-page-item-small')).toHaveTextContent('1/2');
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
await waitFor(() => expect(vehicles.mock.calls[vehicles.mock.calls.length - 1]?.[0]?.get('offset')).toBe('10'));
expect(await screen.findByText('粤A00010')).toBeInTheDocument();
expect(screen.getByText('共 11 辆 · 本页 1 辆')).toBeInTheDocument();
expect(document.querySelector('.v2-table-pagination .semi-page-item-small')).toHaveTextContent('2/2');
});
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(<QueryClientProvider client={client}><MemoryRouter initialEntries={[initialEntry]} future={ROUTER_FUTURE}><Routes><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
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(<QueryClientProvider client={client}><MemoryRouter initialEntries={[`/vehicles/${initialRealtime.vin}`]} future={ROUTER_FUTURE}><Routes><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
const liveOverview = (await screen.findByText('最新上报')).closest('.semi-card')!;
for (const label of ['速度', 'SOC', '总里程', '当日里程']) {
const metric = Array.from(liveOverview.querySelectorAll('.v2-workspace-metric-list > [role="listitem"]')).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: 3 }],
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: 'cell_voltages', sourceField: 'gb32960.extended.cell_voltages', label: '单体电压列表', unit: 'V',
category: 'vehicle', valueType: 'array', value: [3.21, 3.22, 3.23, 3.24], protocol: 'GB32960', frameId: 'gb-array', deviceTime: '', serverTime: '2026-07-16 10:00:00',
quality: 'good', qualityReason: '正常', freshnessSeconds: 1, sourceEndpoint: '10.0.0.1:8080'
},
{
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(<QueryClientProvider client={client}><MemoryRouter initialEntries={[`/vehicles/${initialRealtime.vin}`]} future={ROUTER_FUTURE}><Routes><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
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();
const complexValue = screen.getByRole('button', { name: '查看 单体电压列表 完整数据' });
expect(complexValue).toHaveClass('semi-button', 'v2-telemetry-value-trigger');
expect(complexValue).toHaveTextContent('4 个值');
expect(complexValue).toHaveTextContent('3.213.223.23');
fireEvent.click(complexValue);
expect(await screen.findByRole('dialog', { name: '遥测字段详情' })).toBeInTheDocument();
expect(document.querySelector('.v2-telemetry-evidence-sidesheet')).toHaveClass('v2-workspace-detail-sidesheet');
expect(document.querySelector('.v2-telemetry-detail-protocol')).toHaveClass('semi-tag-blue-light');
expect(document.querySelector('.v2-telemetry-value-meta')).toHaveTextContent('数据类型列表');
const detailSummary = screen.getByRole('list', { name: '遥测字段详情摘要' });
expect(detailSummary).toHaveTextContent('协议来源');
expect(detailSummary).toHaveTextContent('10.0.0.1:8080');
expect(detailSummary).toHaveTextContent('完整值');
expect(detailSummary).toHaveTextContent('4 个值');
expect(detailSummary).toHaveTextContent('数据质量');
expect(detailSummary).toHaveTextContent('正常');
expect(document.querySelector('.v2-telemetry-value-detail pre')).toHaveTextContent('3.24');
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,
profile: { brandName: '测试品牌', modelName: '移动端完整车型名称' } as never,
sources: ['GB32960', 'JT808', 'YUTONG_MQTT']
});
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(<QueryClientProvider client={client}><MemoryRouter initialEntries={[`/vehicles/${initialRealtime.vin}`]} future={ROUTER_FUTURE}><Routes><Route path="/vehicles/:vin" element={<VehiclePage />} /><Route path="/history" element={<div data-testid="mobile-history-route"></div>} /></Routes></MemoryRouter></QueryClientProvider>);
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正常');
expect(screen.getByRole('button', { name: '查看 GPS 速度 字段详情' })).toHaveClass('v2-telemetry-mobile-action');
fireEvent.click(screen.getByRole('button', { name: '查看 GPS 速度 字段详情' }));
expect(await screen.findByRole('dialog', { name: '遥测字段详情' })).toBeInTheDocument();
expect(document.querySelector('.v2-telemetry-evidence-sidesheet')).toHaveClass('v2-workspace-detail-sidesheet', 'semi-sidesheet-bottom');
expect(document.querySelector('.v2-telemetry-value-meta')).toHaveTextContent('数据类型数值');
expect(document.querySelector('.v2-telemetry-detail-protocol')).toHaveClass('semi-tag-cyan-light');
expect(screen.getByRole('button', { name: '关闭遥测字段详情' })).toBeInTheDocument();
expect(document.querySelector('.v2-vehicle-record-page')).toHaveClass('v2-vehicle-record-v3', 'is-mobile-layout');
expect(document.querySelector('.v2-identity-model strong')).toHaveAttribute('title', '测试品牌 / 移动端完整车型名称');
const mobileActions = screen.getByRole('group', { name: '车辆快捷操作' });
expect(mobileActions.querySelectorAll('.semi-button')).toHaveLength(3);
expect(mobileActions).toHaveTextContent('切换车辆轨迹回放更多');
expect(mobileActions).not.toHaveTextContent('历史数据里程查询告警事件');
const moreActions = screen.getByRole('button', { name: '更多车辆操作' });
expect(moreActions).toHaveAttribute('aria-haspopup', 'dialog');
expect(moreActions).toHaveAttribute('aria-controls', 'v2-vehicle-mobile-actions');
expect(moreActions).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(moreActions);
expect(await screen.findByRole('dialog', { name: '更多车辆操作' })).toBeVisible();
expect(document.querySelector('.v2-vehicle-mobile-actions-sidesheet')).toHaveClass('v2-workspace-action-sidesheet', 'semi-sidesheet-bottom');
expect(screen.getByRole('button', { name: '历史数据' })).toBeVisible();
expect(screen.getByRole('button', { name: '里程查询' })).toBeVisible();
expect(screen.getByRole('button', { name: '告警事件' })).toBeVisible();
expect(screen.getByRole('button', { name: '关闭更多车辆操作' })).toBeInTheDocument();
expect(document.querySelectorAll('.v2-identity-sources .semi-tag')).toHaveLength(3);
expect(document.querySelector('.v2-identity-sources')).toHaveTextContent('GB/T 32960JT/T 808宇通 MQTT');
expect(document.querySelector('.v2-identity-sources .semi-tag-blue-light')).toHaveTextContent('GB/T 32960');
expect(document.querySelector('.v2-identity-sources .semi-tag-cyan-light')).toHaveTextContent('JT/T 808');
expect(document.querySelector('.v2-identity-sources .semi-tag-violet-light')).toHaveTextContent('宇通 MQTT');
expect(document.querySelector('.v2-vehicle-command-card .v2-workspace-metric-list')).toHaveAttribute('aria-label', '车辆实时指标');
expect(document.querySelectorAll('.v2-vehicle-command-card .v2-workspace-metric-list > [role="listitem"]')).toHaveLength(5);
expect(document.querySelector('.v2-vehicle-command-card .v2-workspace-metric-list > span:first-child')).toHaveTextContent('速度');
expect(document.querySelector('.v2-vehicle-command-card .v2-workspace-metric-list > span:nth-child(4)')).toHaveTextContent('当日里程');
expect(document.querySelector('.v2-vehicle-command-card .v2-workspace-metric-list > span:nth-child(5)')).toHaveTextContent('来源状态');
fireEvent.click(screen.getByRole('button', { name: '历史数据' }));
expect(await screen.findByTestId('mobile-history-route')).toBeInTheDocument();
});