MotorSignal-App/website/tests/replay-recorded-data.test.tsx

234 lines
13 KiB
TypeScript

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(<WebTelemetryReplayModal ride={inline} onClose={()=>{}}/>);
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(<WebTelemetryReplayModal ride={ride} onClose={()=>{}}/>);
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(<WebTelemetryReplayModal ride={ride} onClose={()=>{}}/>);
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(<WebTelemetryReplayModal ride={ride} onClose={()=>{}}/>);
await waitFor(()=>expect(fetch).toHaveBeenCalledTimes(1));
rerender(<WebTelemetryReplayModal ride={{...ride,id:'ride_next'}} onClose={()=>{}}/>);
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(<WebTelemetryReplayModal ride={inline} onClose={()=>{}}/>);
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(<WebTelemetryReplayModal ride={inline} onClose={()=>{}}/>);
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(<WebTelemetryReplayModal ride={inline} onClose={()=>{}}/>);
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(<WebTelemetryReplayModal ride={ride1} onClose={() => {}} />);
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(<WebTelemetryReplayModal ride={ride2} onClose={() => {}} />);
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)]);
});
});