diff --git a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx index 463ec289..674c3b6b 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx @@ -19,6 +19,7 @@ const vehicles = [{ const monitorMap = { clusters: [], points: [], total: 2 }; const fleetMapRenderSpy = vi.hoisted(() => vi.fn()); const vehicleCardArgsSpy = vi.hoisted(() => vi.fn()); +const monitorDataArgsSpy = vi.hoisted(() => vi.fn()); const monitorQueryFlags = vi.hoisted(() => ({ isLoading: false, isFetching: false, isPlaceholderData: false })); vi.mock('../map/FleetMap', () => ({ @@ -50,12 +51,15 @@ vi.mock('../hooks/useMonitorData', () => ({ if (filters.status) params.set('status', filters.status); return params.toString(); }, - useMonitorData: () => ({ - summary: { data: { totalVehicles: 2, onlineVehicles: 2, offlineVehicles: 0, drivingVehicles: 1, idleVehicles: 1, frameToday: 10 } }, - vehicles: { data: { items: vehicles, total: 2 }, isError: false, ...monitorQueryFlags }, - map: { data: monitorMap, isPlaceholderData: monitorQueryFlags.isPlaceholderData }, - selectedVehicle: { data: { items: [] } } - }), + useMonitorData: (...args: unknown[]) => { + monitorDataArgsSpy(...args); + return { + summary: { data: { totalVehicles: 2, onlineVehicles: 2, offlineVehicles: 0, drivingVehicles: 1, idleVehicles: 1, frameToday: 10 } }, + vehicles: { data: { items: vehicles, total: 2 }, isError: false, ...monitorQueryFlags }, + map: { data: monitorMap, isPlaceholderData: monitorQueryFlags.isPlaceholderData }, + selectedVehicle: { data: { items: [] } } + }; + }, useMonitorVehicleCard: (...args: unknown[]) => { vehicleCardArgsSpy(...args); return { detail: {}, activeAlerts: {}, address: {} }; @@ -68,6 +72,7 @@ afterEach(() => { monitorQueryFlags.isFetching = false; monitorQueryFlags.isPlaceholderData = false; vehicleCardArgsSpy.mockClear(); + monitorDataArgsSpy.mockClear(); vi.restoreAllMocks(); }); @@ -146,6 +151,21 @@ test('switches to a lightweight realtime list and resolves addresses only on dem view.unmount(); }); +test('pauses selected-vehicle polling in list mode and resumes it when returning to the map', () => { + vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: vehicles, total: 2, limit: 50, offset: 0 }); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + + fireEvent.click(screen.getByRole('button', { name: /粤A12345 LTEST000000000001/ })); + expect(monitorDataArgsSpy).toHaveBeenLastCalledWith(expect.any(Object), expect.any(Object), 'LTEST000000000001', true, true); + + fireEvent.click(screen.getByRole('button', { name: /列表/ })); + expect(monitorDataArgsSpy).toHaveBeenLastCalledWith(expect.any(Object), expect.any(Object), '', false, false); + + fireEvent.click(screen.getByRole('button', { name: /地图/ })); + expect(monitorDataArgsSpy).toHaveBeenLastCalledWith(expect.any(Object), expect.any(Object), 'LTEST000000000001', true, true); +}); + test('mounts only the mobile list representation and removes its viewport listener', async () => { const addEventListener = vi.fn(); const removeEventListener = vi.fn(); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx index 4ca37c63..d2941780 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx @@ -219,7 +219,8 @@ export default function MonitorPage() { params.set('offset', String(listOffset)); return params; }, [filters, listLimit, listOffset]); - const { summary, vehicles, map, selectedVehicle } = useMonitorData(filters, viewport, selectedVin, mode === 'map', mode === 'map'); + const trackedVin = mode === 'map' ? selectedVin : ''; + const { summary, vehicles, map, selectedVehicle } = useMonitorData(filters, viewport, trackedVin, mode === 'map', mode === 'map'); const realtimeListQuery = useQuery>({ queryKey: ['monitor', 'vehicle-list', listScope, listLimit, listOffset], queryFn: ({ signal }) => api.vehicleRealtime(listParams, signal), diff --git a/vehicle-data-platform/docs/frontend-production-readiness.md b/vehicle-data-platform/docs/frontend-production-readiness.md index 6b165c29..138540cc 100644 --- a/vehicle-data-platform/docs/frontend-production-readiness.md +++ b/vehicle-data-platform/docs/frontend-production-readiness.md @@ -2,6 +2,14 @@ This document records verified risks, the production controls that address them, and the next evidence to collect. It is intentionally operational: a passing build alone is not proof that the browser application is production-ready. +## 2026-07-16: visible-scope selected-vehicle polling + +Selecting a vehicle starts a ten-second realtime query so the map can keep its ripple, plate and camera target synchronized. Switching to the full fleet list unmounted the map and detail card but retained the selected VIN, so that invisible query continued polling up to 360 times per hour per open operator tab without any rendered consumer. + +The monitor now retains the operator's selected VIN as UI state but passes it to the realtime query only while map mode is active. Entering list mode changes the query key to the disabled empty selection, which stops the interval and lets the prior high-volume result reach its zero-retention cleanup; returning to the map restores the same selection and resumes live tracking. TanStack Query documents that an `enabled: false` query does not automatically fetch or refetch in the background, while `refetchInterval` otherwise operates independently of `staleTime`: , . + +A component wiring test selects a vehicle, proves live tracking receives its VIN, switches to list mode and proves the polling input becomes empty, then returns to the map and proves the original selection resumes. Existing cache lifecycle coverage continues proving replaced selected-vehicle payloads are removed immediately. + ## 2026-07-16: bounded route-module recovery The route shell already displayed a meaningful Suspense skeleton and recovered rejected lazy chunks, but a dynamic import that remained pending forever never reached the error boundary. Operators could therefore stay on a loading workspace indefinitely during a stalled resource request. React documents that `lazy` caches its Promise and that Suspense continues showing the nearest fallback until that Promise settles; a rejected Promise is then delivered to the nearest error boundary: , .