import React from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { act, cleanup, render, screen, waitFor, fireEvent } from '@testing-library/react'; import { WebTelemetryReplayModal } from '../components/web-telemetry-replay-modal'; import type { Ride } from '../components/app-cloud-data'; const mocks = vi.hoisted(() => ({ token: vi.fn().mockResolvedValue('authorized-test-token'), })); vi.mock('../lib/firebase-client', () => ({ firebaseAuth: { currentUser: { uid: 'test-rider', getIdToken: mocks.token }, }, })); describe('Replay Map Camera Following & Tile Loading', () => { let sampleRide: Ride; let rafCallback: ((time: number) => void) | null = null; beforeEach(() => { vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); mocks.token.mockResolvedValue('authorized-test-token'); vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { rafCallback = cb; return 1001; }); vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => { rafCallback = null; }); const points = [ { timestamp: '2026-10-01T12:00:00.000Z', latitude: 35.0, longitude: -115.0, speed: 30, heading: 90, segmentId: 0 }, { timestamp: '2026-10-01T12:00:01.000Z', latitude: 35.0, longitude: -114.99, speed: 35, heading: 90, segmentId: 0 }, { timestamp: '2026-10-01T12:00:02.000Z', latitude: 35.0, longitude: -114.98, speed: 40, heading: 90, segmentId: 0 }, ]; sampleRide = { id: 'ride_camera_test', startedAt: new Date('2026-10-01T12:00:00.000Z'), endedAt: new Date('2026-10-01T12:00:02.000Z'), movingTime: 2, distanceMiles: 1.0, topSpeedMph: 40, averageSpeed: 35, maxLeanLeft: 10, maxLeanRight: 10, maxAccelG: 0.3, maxBrakingG: -0.4, elevationGain: 5, qualityScore: 98, points, telemetryBundlePath: undefined, } as Ride; }); afterEach(() => { cleanup(); delete (window as Window & { L?: unknown }).L; vi.restoreAllMocks(); vi.unstubAllGlobals(); }); const setupLeafletMock = () => { const listeners: Record void)[]> = {}; const map = { invalidateSize: vi.fn(), remove: vi.fn(), fitBounds: vi.fn(() => { (listeners['zoomstart'] || []).forEach((cb) => cb()); (listeners['zoomend'] || []).forEach((cb) => cb()); }), removeLayer: vi.fn(), panTo: vi.fn(), getBounds: () => ({ contains: () => true }), getCenter: vi.fn(() => ({ lat: 35.0, lng: -115.0 })), getSize: vi.fn(() => ({ x: 600, y: 400 })), latLngToContainerPoint: vi.fn(([lat, lng]: [number, number]) => { // Linear screen projection: 1 deg = 5000 px, center at (300, 200) return { x: 300 + (lng - (-115.0)) * 5000, y: 200 - (lat - 35.0) * 5000, }; }), containerPointToLatLng: vi.fn((pt: { x: number; y: number }) => { return { lat: 35.0 - (pt.y - 200) / 5000, lng: -115.0 + (pt.x - 300) / 5000, }; }), on: vi.fn((event: string, cb: (...args: any[]) => void) => { listeners[event] = listeners[event] || []; listeners[event].push(cb); return map; }), off: vi.fn((event: string, cb: (...args: any[]) => void) => { if (listeners[event]) { listeners[event] = listeners[event].filter((h) => h !== cb); } return map; }), _trigger: (event: string, ...args: any[]) => { (listeners[event] || []).forEach((cb) => cb(...args)); }, }; const rider = { addTo: vi.fn().mockReturnThis(), setLatLng: vi.fn(), setIcon: vi.fn(), bindTooltip: vi.fn().mockReturnThis(), on: vi.fn().mockReturnThis(), getElement: vi.fn().mockReturnValue(null), }; let tileOptionsCapture: any = null; const tileLayerFn = vi.fn((_url?: string, options?: any) => { tileOptionsCapture = options; return { addTo: vi.fn().mockReturnThis(), on: vi.fn().mockReturnThis(), addLayer: vi.fn(), }; }); const genericLayer = () => ({ addTo: vi.fn().mockReturnThis(), on: vi.fn().mockReturnThis(), addLayer: vi.fn(), }); const L = { map: vi.fn(() => map), tileLayer: tileLayerFn, featureGroup: vi.fn(genericLayer), polyline: vi.fn(genericLayer), marker: vi.fn(() => rider), divIcon: vi.fn(), circleMarker: vi.fn(() => rider), latLngBounds: vi.fn((coords) => coords), }; (window as Window & { L?: unknown }).L = L; return { map, rider, L, getTileOptions: () => tileOptionsCapture, triggerMapEvent: map._trigger }; }; it('configures tileLayer with mobile continuous loading options to prevent black map tiles', async () => { const { getTileOptions } = setupLeafletMock(); render( {}} />); await waitFor(() => expect(screen.getByTestId('telemetry-leaflet-map')).toBeDefined()); const options = getTileOptions(); expect(options).toBeDefined(); // Verify critical mobile options expect(options.updateWhenIdle).toBe(false); expect(options.keepBuffer).toBeGreaterThanOrEqual(6); expect(options.updateInterval).toBeLessThanOrEqual(150); expect(options.crossOrigin).toBe(true); }); it('smoothly follows motorcycle marker with camera during playback and stops upon pause', async () => { const { map, rider } = setupLeafletMock(); render( {}} />); await waitFor(() => expect(screen.getByTestId('follow-toggle-btn')).toBeDefined()); // Auto-follow button shows active "Following" status by default const followBtn = screen.getByTestId('follow-toggle-btn'); expect(followBtn.textContent).toContain('Following'); // Click play const playBtn = screen.getByTestId('play-pause-btn'); fireEvent.click(playBtn); expect(screen.getByText('PAUSE')).toBeDefined(); expect(rafCallback).toBeDefined(); // Frame 1: delta 0 rafCallback!(1000); // Frame 2: 50ms advance (rider moves east to -114.9995 deg -> 2.5 px offset, within dead zone) rafCallback!(1050); expect(rider.setLatLng).toHaveBeenCalled(); // Frames 3-6: step rAF frames forward (frameDelta clamped to max 200ms per frame) // 200ms + 200ms + 200ms + 200ms = 850ms total rafCallback!(1250); rafCallback!(1450); rafCallback!(1650); rafCallback!(1850); // Marker is now ~85% of interval (42.5 px east), exceeding the 32px dead zone // Verify map.panTo was called with { animate: false } to smoothly shift the camera expect(map.panTo).toHaveBeenCalledWith( expect.arrayContaining([expect.any(Number), expect.any(Number)]), { animate: false } ); // Click pause fireEvent.click(playBtn); expect(screen.getByText('PLAY')).toBeDefined(); const panCallsBeforePause = map.panTo.mock.calls.length; // After pause, rafCallback is cleared and no further camera pan calls occur expect(rafCallback).toBeNull(); expect(map.panTo.mock.calls.length).toBe(panCallsBeforePause); }); it('suspends follow mode upon user map drag and resumes upon clicking Re-center', async () => { const { map, triggerMapEvent } = setupLeafletMock(); render( {}} />); await waitFor(() => expect(screen.getByTestId('follow-toggle-btn')).toBeDefined()); const followBtn = screen.getByTestId('follow-toggle-btn'); expect(followBtn.textContent).toContain('Following'); // User manually drags the map (Leaflet dragstart event) fireEvent(window, new Event('resize')); // test safety // Wrap state change in waitFor / act await waitFor(() => { triggerMapEvent('dragstart'); expect(followBtn.textContent).toContain('Re-center'); }); // Start playback while follow is suspended const playBtn = screen.getByTestId('play-pause-btn'); fireEvent.click(playBtn); const panCallsWhileSuspended = map.panTo.mock.calls.length; rafCallback!(1000); rafCallback!(1800); // large movement // Camera did not pan while follow was suspended expect(map.panTo.mock.calls.length).toBe(panCallsWhileSuspended); // User clicks Re-center fireEvent.click(followBtn); // Verify follow mode resumes and camera immediately re-centers on current position expect(followBtn.textContent).toContain('Following'); expect(map.panTo).toHaveBeenCalledWith([35, -114.998], { animate: false }); }); it('provides Fit Route button that fits bounds and suspends follow mode', async () => { const { map } = setupLeafletMock(); render( {}} />); await waitFor(() => expect(screen.getByTestId('fit-route-btn')).toBeDefined()); const fitBtn = screen.getByTestId('fit-route-btn'); const followBtn = screen.getByTestId('follow-toggle-btn'); expect(followBtn.textContent).toContain('Following'); fireEvent.click(fitBtn); expect(map.fitBounds).toHaveBeenCalled(); expect(followBtn.textContent).toContain('Re-center'); }); it('recenters map camera cleanly when scrubbing timeline slider', async () => { const { map, rider } = setupLeafletMock(); render( {}} />); await waitFor(() => expect(screen.getByTestId('replay-timeline-slider')).toBeDefined()); const slider = screen.getByTestId('replay-timeline-slider'); fireEvent.change(slider, { target: { value: '2' } }); expect(rider.setLatLng).toHaveBeenLastCalledWith([sampleRide.points![2].latitude, sampleRide.points![2].longitude]); expect(map.panTo).toHaveBeenCalledWith([sampleRide.points![2].latitude, sampleRide.points![2].longitude], { animate: false }); }); it('suspends follow mode upon manual zoom (zoomstart) without being disabled by programmatic fitBounds', async () => { const { map, triggerMapEvent } = setupLeafletMock(); render( {}} />); await waitFor(() => expect(screen.getByTestId('follow-toggle-btn')).toBeDefined()); const followBtn = screen.getByTestId('follow-toggle-btn'); // Programmatic zoom during mount (fitBounds) did not disable follow mode expect(followBtn.textContent).toContain('Following'); // User performs manual pinch, wheel, or double-click zoom (emitting Leaflet zoomstart) await waitFor(() => { triggerMapEvent('zoomstart'); expect(followBtn.textContent).toContain('Re-center'); }); // Start playback while follow is suspended const playBtn = screen.getByTestId('play-pause-btn'); fireEvent.click(playBtn); const panCallsWhileSuspended = map.panTo.mock.calls.length; // Step forward frames with movement that would otherwise exceed dead zone act(() => { rafCallback!(1000); rafCallback!(1800); }); // Animation loop does NOT pan or fight user while follow is suspended expect(map.panTo.mock.calls.length).toBe(panCallsWhileSuspended); // Explicit user click on Re-center resumes following and immediately aligns camera fireEvent.click(followBtn); expect(followBtn.textContent).toContain('Following'); expect(map.panTo).toHaveBeenCalledWith([35, -114.998], { animate: false }); }); it('crosses multiple telemetry sample boundaries without triggering raw panTo snapping', async () => { // 4 points spaced closely so all points are within the 32px dead zone from origin // In our test projection: 1 deg = 5000 px, so 0.001 deg = 5 px. const closePoints = [ { timestamp: '2026-10-01T12:00:00.000Z', latitude: 35.0, longitude: -115.0000, speed: 20, heading: 90, segmentId: 0 }, { timestamp: '2026-10-01T12:00:00.050Z', latitude: 35.0, longitude: -114.9998, speed: 20, heading: 90, segmentId: 0 }, // 1 px offset { timestamp: '2026-10-01T12:00:00.100Z', latitude: 35.0, longitude: -114.9996, speed: 20, heading: 90, segmentId: 0 }, // 2 px offset { timestamp: '2026-10-01T12:00:00.150Z', latitude: 35.0, longitude: -114.9994, speed: 20, heading: 90, segmentId: 0 }, // 3 px offset ]; const testRide = { ...sampleRide, id: 'ride_sample_boundaries_test', endedAt: new Date('2026-10-01T12:00:00.150Z'), points: closePoints, } as Ride; const { map } = setupLeafletMock(); render( {}} />); await waitFor(() => expect(screen.getByTestId('follow-toggle-btn')).toBeDefined()); const playBtn = screen.getByTestId('play-pause-btn'); fireEvent.click(playBtn); expect(rafCallback).toBeDefined(); // Clear initial setup calls map.panTo.mockClear(); // Frame 1: t=0ms, index = 0 act(() => { rafCallback!(1000); // Frame 2: t=60ms (crosses sample boundary from point 0 to point 1 at 50ms) rafCallback!(1060); // Frame 3: t=120ms (crosses sample boundary from point 1 to point 2 at 100ms) rafCallback!(1120); }); // Because rider moved by at most 2px (well within 32px dead-zone) across 2 sample boundaries, // neither the dead-zone follow engine nor the tab effect should have called map.panTo. expect(map.panTo).not.toHaveBeenCalled(); }); });