MotorSignal-App/website/tests/real-telemetry-replay.test.tsx

287 lines
9.8 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';
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(<WebTelemetryReplayModal ride={authenticRide} onClose={() => {}} />);
// 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(<WebTelemetryReplayModal ride={authenticRide} onClose={() => {}} />);
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(<WebTelemetryReplayModal ride={authenticRide} onClose={() => {}} />);
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(<WebTelemetryReplayModal ride={authenticRide} onClose={() => {}} />);
// 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);
});
});