287 lines
9.8 KiB
TypeScript
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);
|
|
});
|
|
});
|