feat(platform): harden telemetry pipeline and unify Semi UI workspaces

This commit is contained in:
lingniu
2026-07-18 00:26:36 +08:00
parent 65b4e4f055
commit 159c80b0ae
136 changed files with 21616 additions and 1785 deletions

View File

@@ -9,6 +9,7 @@ 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[] }) => {
@@ -20,6 +21,7 @@ vi.mock('../map/FleetMap', () => ({
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: [],
@@ -44,6 +46,7 @@ const detail = {
afterEach(() => {
cleanup();
layout.mobile = false;
vi.restoreAllMocks();
fleetMapVehicles.mockReset();
});
@@ -62,7 +65,7 @@ test('polls lightweight single-vehicle realtime data and passes its report inter
});
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 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] });
@@ -74,12 +77,106 @@ test('polls lightweight single-vehicle realtime data and passes its report inter
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<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();
fireEvent.click(screen.getByTitle('展开全部位置来源'));
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('keeps the monitor return on the vehicle page and its nested investigation links', async () => {
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: '测试品牌',
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(<QueryClientProvider client={client}><MemoryRouter initialEntries={['/vehicles']} future={ROUTER_FUTURE}><Routes><Route path="/vehicles" element={<VehiclePage />} /><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
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();
expect(vehicles).not.toHaveBeenCalled();
fireEvent.focus(input);
expect(input).toHaveAttribute('aria-expanded', 'true');
fireEvent.change(input, { target: { value: initialRealtime.plate } });
await waitFor(() => expect(vehicles).toHaveBeenCalled());
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);
@@ -94,21 +191,28 @@ test('keeps the monitor return on the vehicle page and its nested investigation
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 trackLink = screen.getByRole('link', { name: /轨迹回放/ });
const historyLink = screen.getByRole('link', { name: /历史数据/ });
const mileageLink = screen.getByRole('link', { name: /里程查询/ });
expect(new URL(trackLink.getAttribute('href')!, 'http://localhost').searchParams.get('monitorReturn')).toBe(monitorPath);
expect(new URL(historyLink.getAttribute('href')!, 'http://localhost').searchParams.get('monitorReturn')).toBe(monitorPath);
expect(new URL(mileageLink.getAttribute('href')!, 'http://localhost').searchParams.get('monitorReturn')).toBe(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('section');
const archive = screen.getByText('车辆主档').closest('section');
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();
fireEvent.click(screen.getByRole('button', { name: '解析当前位置' }));
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);
});
@@ -125,17 +229,89 @@ test('shows unavailable live fields as dashes instead of fabricated zeroes', asy
todayMileageAvailable: false,
todayMileageKm: 0
};
vi.spyOn(api, 'vehicleDetail').mockResolvedValue({ ...detail, realtimeSummary: unavailable });
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('section')!;
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(<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();
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(<QueryClientProvider client={client}><MemoryRouter initialEntries={[`/vehicles/${initialRealtime.vin}`]} future={ROUTER_FUTURE}><Routes><Route path="/vehicles/:vin" element={<VehiclePage />} /></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正常');
});