Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.test.tsx
2026-07-19 00:43:37 +08:00

563 lines
35 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-live-grid');
expect(commandCard?.querySelectorAll('.v2-live-grid > [role="listitem"]')).toHaveLength(6);
expect(commandCard?.querySelector('.v2-live-grid > div:first-child')).toHaveTextContent('速度');
expect(commandCard?.querySelector('.v2-live-grid > div:nth-child(2)')).toHaveTextContent('SOC');
expect(commandCard?.querySelector('.v2-live-grid > div:nth-child(4)')).toHaveTextContent('当日里程');
expect(commandCard?.querySelector('.v2-live-grid > div:nth-child(5)')).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();
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-live-source-link');
expect(totalMileageSource.closest('.semi-card')).toHaveClass('v2-live-overview');
expect(totalMileageSource.closest('.semi-card')).toBe(commandCard);
fireEvent.click(locationSource);
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('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();
expect(await screen.findByText('本页 1 辆')).toBeInTheDocument();
expect(screen.getByText('本页在线 1')).toBeInTheDocument();
expect(screen.getByText('多源车辆 0')).toBeInTheDocument();
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.getByRole('button', { name: `打开 ${initialRealtime.vin} 车辆档案` })).toBeInTheDocument();
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>);
expect(await screen.findByText('本页 8 辆')).toBeInTheDocument();
expect(screen.getAllByRole('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();
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.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.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: '重置范围' }));
await waitFor(() => expect(screen.getByRole('button', { name: '修改车辆范围10 辆授权车辆' })).toBeInTheDocument());
expect(screen.getByRole('heading', { name: '授权车辆', level: 5 })).toBeInTheDocument();
});
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-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: 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-detail-protocol')).toHaveClass('semi-tag-blue-light');
expect(document.querySelector('.v2-telemetry-value-meta')).toHaveTextContent('数据类型列表');
expect(document.querySelector('.v2-telemetry-value-detail pre')).toHaveTextContent('3.24');
expect(document.querySelector('.v2-telemetry-value-detail > footer strong')).toHaveTextContent('10.0.0.1:8080');
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, 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-value-sidesheet')).toHaveClass('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');
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('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-live-grid')).toHaveAttribute('aria-label', '车辆实时指标');
expect(document.querySelectorAll('.v2-vehicle-command-card .v2-live-grid > [role="listitem"]')).toHaveLength(6);
expect(document.querySelector('.v2-live-metric.is-speed')).toHaveTextContent('速度');
expect(document.querySelector('.v2-live-metric.is-today-mileage')).toHaveTextContent('当日里程');
expect(document.querySelector('.v2-live-metric.is-source-status')).toHaveTextContent('来源状态');
fireEvent.click(screen.getByRole('button', { name: '历史数据' }));
expect(await screen.findByTestId('mobile-history-route')).toBeInTheDocument();
});