234 lines
13 KiB
TypeScript
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)]);
|
|
});
|
|
});
|