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'; import type { TelemetryPoint } from '../lib/telemetry-export'; import authenticPoints from '../test-support/fixtures/pixel8a-authentic-ride.json'; const mocks = vi.hoisted(() => ({ token: vi.fn().mockResolvedValue('authorized-test-token'), })); vi.mock('../lib/firebase-client', () => ({ firebaseAuth: { currentUser: { uid: 'test-rider-pixel8a', getIdToken: mocks.token }, }, })); describe('Phase 2 — Real Recorded Telemetry Playback & Smooth Animation', () => { let authenticRide: Ride; let pointsClone: TelemetryPoint[]; beforeEach(() => { vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); mocks.token.mockResolvedValue('authorized-test-token'); // Deep clone authentic fixture to ensure test isolation and prove immutability pointsClone = JSON.parse(JSON.stringify(authenticPoints)); authenticRide = { id: 'ride_pixel8a_real_field_test', startedAt: new Date(pointsClone[0].timestamp), endedAt: new Date(pointsClone[pointsClone.length - 1].timestamp), movingTime: 45, distanceMiles: 0.85, topSpeedMph: 59.47, averageSpeed: 38.2, maxLeanLeft: 8.53, maxLeanRight: 5.2, maxAccelG: 0.45, maxBrakingG: -0.55, elevationGain: 12, qualityScore: 99, motorcycle: 'Yamaha YZF-R6', points: pointsClone, telemetryBundlePath: undefined, }; }); afterEach(() => { cleanup(); delete (window as Window & { L?: unknown }).L; vi.restoreAllMocks(); vi.unstubAllGlobals(); }); it('renders authentic Pixel 8a telemetry with Leaflet map, polyline, and HUD metrics', 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(), getElement: vi.fn().mockReturnValue(null), }; 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; render( {}} />); // Verify map initialized and fitted to authentic GPS coordinates await waitFor(() => expect(L.map).toHaveBeenCalled()); expect(L.polyline).toHaveBeenCalled(); expect(map.fitBounds).toHaveBeenCalled(); // Verify initial rider marker set at synthetic starting coordinates expect(rider.setLatLng).toHaveBeenCalledWith([ pointsClone[0].latitude, pointsClone[0].longitude, ]); // Verify GPS readout displays synthetic starting coordinates const sourceBadge = screen.getByTestId('replay-data-source').parentElement!; expect(sourceBadge.textContent).toContain( `${pointsClone[0].latitude.toFixed(5)}, ${pointsClone[0].longitude.toFixed(5)}` ); }); it('animates smoothly at 60 FPS without snapping, and preserves sub-sample position on pause and tab switch', async () => { let rafCallback: ((time: number) => void) | null = null; vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { rafCallback = cb; return 999; }); 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(), getElement: vi.fn().mockReturnValue(null), }; 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; render( {}} />); await waitFor(() => expect(L.marker).toHaveBeenCalled()); // 1. Start playback const playBtn = screen.getByTestId('play-pause-btn'); fireEvent.click(playBtn); expect(screen.getByText('PAUSE')).toBeDefined(); expect(rafCallback).toBeDefined(); // Tick 1: t = 1000 ms (delta = 0) rafCallback!(1000); // Tick 2: t = 1016.67 ms (first 60 FPS animation frame) rafCallback!(1016.67); const firstCallCoords = rider.setLatLng.mock.calls[rider.setLatLng.mock.calls.length - 1][0]; expect(Number.isFinite(firstCallCoords[0])).toBe(true); expect(Number.isFinite(firstCallCoords[1])).toBe(true); // Tick 3: t = 1033.33 ms (second 60 FPS animation frame) rafCallback!(1033.33); const secondCallCoords = rider.setLatLng.mock.calls[rider.setLatLng.mock.calls.length - 1][0]; // Tick 4: t = 1050 ms (third 60 FPS animation frame) rafCallback!(1050); const thirdCallCoords = rider.setLatLng.mock.calls[rider.setLatLng.mock.calls.length - 1][0]; // 2. Click PAUSE fireEvent.click(screen.getByTestId('play-pause-btn')); expect(screen.getByText('PLAY')).toBeDefined(); const pausedCoords = rider.setLatLng.mock.calls[rider.setLatLng.mock.calls.length - 1][0]; expect(pausedCoords).toEqual(thirdCallCoords); // 3. Switch tabs while paused: Map Replay -> Corner Grading -> Map Replay fireEvent.click(screen.getByTestId('tab-corner-grading')); expect(rider.setLatLng).toHaveBeenLastCalledWith(pausedCoords); fireEvent.click(screen.getByTestId('tab-map-replay')); expect(rider.setLatLng).toHaveBeenLastCalledWith(pausedCoords); // 4. Resume playback: resumes from exact pausedCoords without jumping fireEvent.click(screen.getByTestId('play-pause-btn')); expect(screen.getByText('PAUSE')).toBeDefined(); expect(rafCallback).toBeDefined(); // First frame of resume: delta = 0 rafCallback!(5000); expect(rider.setLatLng).toHaveBeenLastCalledWith(pausedCoords); // Subsequent frame: advances smoothly forward rafCallback!(5020); const afterResumeCoords = rider.setLatLng.mock.calls[rider.setLatLng.mock.calls.length - 1][0]; expect(Number.isFinite(afterResumeCoords[0])).toBe(true); }); it('supports seeking to any point and testing all playback speeds (0.5x, 1x, 2x, 5x, 10x)', 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(), getElement: vi.fn().mockReturnValue(null), }; 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; render( {}} />); await waitFor(() => expect(L.marker).toHaveBeenCalled()); // Verify all speed buttons exist and are clickable const speeds = ['0.5x', '1x', '2x', '5x', '10x']; speeds.forEach((spd) => { const btn = screen.getByText(spd); expect(btn).toBeDefined(); fireEvent.click(btn); }); // Seek to point 50 (highway acceleration) const slider = screen.getByTestId('replay-timeline-slider'); fireEvent.change(slider, { target: { value: '50' } }); expect(rider.setLatLng).toHaveBeenLastCalledWith([ pointsClone[50].latitude, pointsClone[50].longitude, ]); // Seek to point 100 (high speed curve) fireEvent.change(slider, { target: { value: '100' } }); expect(rider.setLatLng).toHaveBeenLastCalledWith([ pointsClone[100].latitude, pointsClone[100].longitude, ]); }); it('guarantees that recorded telemetry points are NEVER mutated during replay operations', async () => { const originalPointsJson = JSON.stringify(authenticPoints); render( {}} />); // Exercise play, seek, speed change, and pause const playBtn = screen.getByTestId('play-pause-btn'); fireEvent.click(playBtn); fireEvent.click(screen.getByText('5x')); fireEvent.change(screen.getByTestId('replay-timeline-slider'), { target: { value: '75' } }); fireEvent.click(playBtn); // Verify original points array was not mutated in any way expect(JSON.stringify(authenticRide.points)).toBe(originalPointsJson); }); });