import React from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { 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:'sanitized-owner',getIdToken:mocks.token}}})); const ride={id:'ride_sanitized',startedAt:new Date('2026-09-03T20:00:00Z'),movingTime:301,distanceMiles:15.66,topSpeedMph:83.7,averageSpeed:60,maxLeanLeft:27,maxLeanRight:26,telemetryBundlePath:'users/sanitized-owner/rides/ride_sanitized/telemetry.zip'} as Ride; const recorded=[{timestamp:'2026-09-03T20:00:00Z',latitude:40,longitude:-74,speed:18,leanAngle:0}, {timestamp:'2026-09-03T20:00:01Z',latitude:40.001,longitude:-73.999,speed:20,leanAngle:25}]; beforeEach(()=>{ vi.spyOn(HTMLCanvasElement.prototype,'getContext').mockReturnValue(null); mocks.token.mockResolvedValue('authorized-test-token'); }); afterEach(()=>{cleanup();delete (window as Window & {L?:unknown}).L;vi.restoreAllMocks();vi.unstubAllGlobals();}); describe('recorded replay loading and identity',()=>{ it('keeps readable GPS metadata outside the interactive map viewport',async()=>{ const inline={...ride,telemetryBundlePath:undefined,points:recorded} as Ride; render({}}/>); const source=screen.getByTestId('replay-data-source'); const badge=source.parentElement!; const mapViewport=screen.getByTestId('telemetry-leaflet-map').parentElement!; expect(badge.textContent).toContain('GPS: 40.00000, -74.00000'); expect(mapViewport.contains(badge)).toBe(false); expect(badge.classList.contains('hidden')).toBe(false); fireEvent.click(screen.getByTestId('tab-corner-grading')); expect(badge.classList.contains('hidden')).toBe(true); fireEvent.click(screen.getByTestId('tab-map-replay')); expect(badge.classList.contains('hidden')).toBe(false); }); it('shows loading, never a synthetic trace, and sends the existing Firebase identity',async()=>{ vi.stubGlobal('fetch',vi.fn(()=>new Promise(()=>{}))); render({}}/>); expect(screen.getByText('Loading recorded telemetry…')).toBeDefined(); expect(document.querySelector('canvas')).toBeNull(); await waitFor(()=>expect(fetch).toHaveBeenCalledWith(expect.stringContaining(ride.id),expect.objectContaining({headers:{Authorization:'Bearer authorized-test-token'},cache:'no-store'}))); }); it('shows retrieval errors instead of plausible invented GPS or an export',async()=>{ vi.stubGlobal('fetch',vi.fn().mockResolvedValue(new Response(JSON.stringify({success:false,error:'Recorded telemetry is unavailable.'}),{status:404}))); render({}}/>); await waitFor(()=>expect(screen.getByText('Recorded telemetry is unavailable.')).toBeDefined()); expect((screen.getByTestId('export-gpx-btn') as HTMLButtonElement).disabled).toBe(true); expect(screen.queryByText(/35\.18520/)).toBeNull(); }); it('clears prior ride geometry during switching and ignores late responses from a closed ride',async()=>{ let resolveFirst!:(r:Response)=>void; vi.stubGlobal('fetch',vi.fn().mockImplementationOnce(()=>new Promise(r=>{resolveFirst=r;})).mockResolvedValue(new Response(JSON.stringify({success:true,rideId:'ride_next',source:'r2-csv',points:recorded})))); const {rerender}=render({}}/>); await waitFor(()=>expect(fetch).toHaveBeenCalledTimes(1)); rerender({}}/>); await waitFor(()=>expect(screen.getByText(/GPS: 40\.00000, -74\.00000/)).toBeDefined()); resolveFirst(new Response(JSON.stringify({success:true,rideId:ride.id,source:'r2-gpx',points:[{...recorded[0],latitude:12,longitude:13}]}))); await waitFor(()=>expect(screen.queryByText(/GPS: 12\.00000/)).toBeNull()); }); it('uses geographic Leaflet layers/bounds and recorded rider positions; cleans up on close and reopen',async()=>{ const map={invalidateSize:vi.fn(),remove:vi.fn(),fitBounds:vi.fn(),removeLayer:vi.fn(),panTo:vi.fn(),getBounds:()=>({contains:()=>true})}; const rider={addTo:vi.fn().mockReturnThis(),setLatLng:vi.fn(),setIcon:vi.fn(),bindTooltip:vi.fn().mockReturnThis(),on:vi.fn().mockReturnThis()}; const layer=()=>({addTo:vi.fn().mockReturnThis(),on:vi.fn().mockReturnThis(),addLayer:vi.fn()}); const L={map:vi.fn(()=>map),tileLayer:vi.fn(layer),featureGroup:vi.fn(layer),polyline:vi.fn((_coords:[number,number][],_options:unknown)=>layer()),marker:vi.fn(()=>rider),divIcon:vi.fn(),circleMarker:vi.fn(()=>rider),latLngBounds:vi.fn((coords)=>coords)}; (window as Window & {L?:unknown}).L=L; const samples=[...recorded,{...recorded[1],timestamp:'2026-09-03T20:05:00Z',latitude:41,longitude:-73,segmentId:1},{...recorded[1],timestamp:'2026-09-03T20:05:01Z',latitude:41.001,longitude:-72.999,segmentId:1}]; const inline={...ride,telemetryBundlePath:undefined,points:samples} as Ride; const {unmount}=render({}}/>); await waitFor(()=>expect(L.polyline).toHaveBeenCalledTimes(2)); expect(L.polyline.mock.calls.map(call=>call[0])).toEqual([[[40,-74],[40.001,-73.999]],[[41,-73],[41.001,-72.999]]]); expect(map.fitBounds).toHaveBeenCalledWith([[40,-74],[40.001,-73.999],[41,-73],[41.001,-72.999]],{padding:[35,35]}); fireEvent.change(screen.getByTestId('replay-timeline-slider'),{target:{value:'3'}}); expect(rider.setLatLng).toHaveBeenLastCalledWith([41.001,-72.999]); const resizeCalls=map.invalidateSize.mock.calls.length; fireEvent(window,new Event('resize')); expect(map.invalidateSize.mock.calls.length).toBeGreaterThan(resizeCalls); unmount(); expect(map.remove).toHaveBeenCalledTimes(1); render({}}/>); await waitFor(()=>expect(L.map).toHaveBeenCalledTimes(2)); expect(rider.setLatLng).toHaveBeenLastCalledWith([40,-74]); }); it('interpolates motorcycle marker smoothly during animation and preserves position on pause',async()=>{ let rafCallback: ((time: number) => void) | null = null; vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { rafCallback = cb; return 12345; }); vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => { rafCallback = null; }); const map={invalidateSize:vi.fn(),remove:vi.fn(),fitBounds:vi.fn(),removeLayer:vi.fn(),panTo:vi.fn(),getBounds:()=>({contains:()=>true})}; const rider={addTo:vi.fn().mockReturnThis(),setLatLng:vi.fn(),setIcon:vi.fn(),bindTooltip:vi.fn().mockReturnThis(),on:vi.fn().mockReturnThis()}; const layer=()=>({addTo:vi.fn().mockReturnThis(),on:vi.fn().mockReturnThis(),addLayer:vi.fn()}); const L={map:vi.fn(()=>map),tileLayer:vi.fn(layer),featureGroup:vi.fn(layer),polyline:vi.fn(()=>layer()),marker:vi.fn(()=>rider),divIcon:vi.fn(),circleMarker:vi.fn(()=>rider),latLngBounds:vi.fn((coords)=>coords)}; (window as Window & {L?:unknown}).L=L; // 1-second interval between points (1000 ms) const points=[ {timestamp:'2026-09-03T20:00:00.000Z',latitude:40.0,longitude:-74.0,speed:20,heading:350,segmentId:0}, {timestamp:'2026-09-03T20:00:01.000Z',latitude:40.01,longitude:-73.99,speed:30,heading:10,segmentId:0}, ]; const inline={...ride,telemetryBundlePath:undefined,points} as Ride; render({}}/>); await waitFor(()=>expect(L.marker).toHaveBeenCalled()); expect(rider.setLatLng).toHaveBeenCalledWith([40.0,-74.0]); // Click Play const playBtn = screen.getByTestId('play-pause-btn'); fireEvent.click(playBtn); expect(screen.getByText('PAUSE')).toBeDefined(); expect(rafCallback).toBeDefined(); // First animation frame at t=1000 (delta = 0) rafCallback!(1000); // Second animation frame at t=1050 (frameDelta = 50 ms -> 5% into the 1000 ms interval) rafCallback!(1050); // Interpolated latitude at t=0.05: 40.0 + 0.05 * 0.01 = 40.0005 expect(rider.setLatLng).toHaveBeenLastCalledWith([expect.closeTo(40.0005, 4), expect.closeTo(-73.9995, 4)]); // Third animation frame at t=1250 (additional 200 ms -> 250 ms total = 25% into interval) rafCallback!(1250); // Interpolated latitude at t=0.25: 40.0 + 0.25 * 0.01 = 40.0025 expect(rider.setLatLng).toHaveBeenLastCalledWith([expect.closeTo(40.0025, 4), expect.closeTo(-73.9975, 4)]); // Pause playback: marker MUST NOT snap backwards to [40.0, -74.0] fireEvent.click(screen.getByTestId('play-pause-btn')); expect(screen.getByText('PLAY')).toBeDefined(); expect(rider.setLatLng).toHaveBeenLastCalledWith([expect.closeTo(40.0025, 4), expect.closeTo(-73.9975, 4)]); // Trigger component re-render while paused (switch tab) — marker MUST STILL NOT snap back! fireEvent.click(screen.getByTestId('tab-corner-grading')); expect(rider.setLatLng).toHaveBeenLastCalledWith([expect.closeTo(40.0025, 4), expect.closeTo(-73.9975, 4)]); fireEvent.click(screen.getByTestId('tab-map-replay')); expect(rider.setLatLng).toHaveBeenLastCalledWith([expect.closeTo(40.0025, 4), expect.closeTo(-73.9975, 4)]); // Check speed controls include 10x expect(screen.getByText('10x')).toBeDefined(); fireEvent.click(screen.getByText('10x')); }); it('resets carry and cursor when switching rides so the new ride starts at its first coordinate without carryover', async () => { let rafCallback: ((time: number) => void) | null = null; vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { rafCallback = cb; return 54321; }); vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => { rafCallback = null; }); const map = { invalidateSize: vi.fn(), remove: vi.fn(), fitBounds: vi.fn(), removeLayer: vi.fn(), panTo: vi.fn(), getBounds: () => ({ contains: () => true }), }; const rider = { addTo: vi.fn().mockReturnThis(), setLatLng: vi.fn(), setIcon: vi.fn(), bindTooltip: vi.fn().mockReturnThis(), on: vi.fn().mockReturnThis(), }; const layer = () => ({ addTo: vi.fn().mockReturnThis(), on: vi.fn().mockReturnThis(), addLayer: vi.fn(), }); const L = { map: vi.fn(() => map), tileLayer: vi.fn(layer), featureGroup: vi.fn(layer), polyline: vi.fn(() => layer()), marker: vi.fn(() => rider), divIcon: vi.fn(), circleMarker: vi.fn(() => rider), latLngBounds: vi.fn((coords) => coords), }; (window as Window & { L?: unknown }).L = L; const points1 = [ { timestamp: '2026-09-03T20:00:00.000Z', latitude: 40.0, longitude: -74.0, speed: 20, heading: 350, segmentId: 0 }, { timestamp: '2026-09-03T20:00:01.000Z', latitude: 40.01, longitude: -73.99, speed: 30, heading: 10, segmentId: 0 }, ]; const ride1 = { ...ride, id: 'ride_1', telemetryBundlePath: undefined, points: points1 } as Ride; const points2 = [ { timestamp: '2026-10-01T12:00:00.000Z', latitude: 35.0, longitude: -115.0, speed: 15, heading: 90, segmentId: 0 }, { timestamp: '2026-10-01T12:00:01.000Z', latitude: 35.01, longitude: -114.99, speed: 25, heading: 100, segmentId: 0 }, ]; const ride2 = { ...ride, id: 'ride_2', telemetryBundlePath: undefined, points: points2 } as Ride; // 1. Mount with ride1 const { rerender } = render( {}} />); await waitFor(() => expect(L.marker).toHaveBeenCalled()); expect(rider.setLatLng).toHaveBeenCalledWith([40.0, -74.0]); // 2. Play ride1 and advance frames to generate non-zero carry const playBtn = screen.getByTestId('play-pause-btn'); fireEvent.click(playBtn); expect(rafCallback).toBeDefined(); // Frame 1: delta 0 rafCallback!(1000); // Frame 2: frameDelta = 100 ms (10% into the 1000ms interval) rafCallback!(1100); expect(rider.setLatLng).toHaveBeenLastCalledWith([expect.closeTo(40.001, 4), expect.closeTo(-73.999, 4)]); // 3. Pause midway with non-zero sub-sample carry (100 ms carry) fireEvent.click(screen.getByTestId('play-pause-btn')); expect(screen.getByText('PLAY')).toBeDefined(); // 4. Switch to ride2 rerender( {}} />); await waitFor(() => expect(screen.getByText(/GPS: 35\.00000, -115\.00000/)).toBeDefined()); // Verify rider marker is placed at the exact first point of ride2 with 0 carry expect(rider.setLatLng).toHaveBeenLastCalledWith([35.0, -115.0]); // 5. Start playback on ride2 fireEvent.click(screen.getByTestId('play-pause-btn')); expect(screen.getByText('PAUSE')).toBeDefined(); expect(rafCallback).toBeDefined(); // Frame 1 on ride2: delta 0 (lastTime initialized) rafCallback!(2000); // With carry reset to 0, marker stays at [35.0, -115.0] on delta 0 instead of leaping forward by ride1's 100ms carry expect(rider.setLatLng).toHaveBeenLastCalledWith([35.0, -115.0]); // Frame 2 on ride2: 50 ms elapsed rafCallback!(2050); // 50ms / 1000ms = 5% into ride2's interval: 35.0 + 0.05 * 0.01 = 35.0005 expect(rider.setLatLng).toHaveBeenLastCalledWith([expect.closeTo(35.0005, 4), expect.closeTo(-114.9995, 4)]); }); });