MotorSignal-App/website/tests/replay-map-camera-follow.te...

354 lines
13 KiB
TypeScript

import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, 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: 'test-rider', getIdToken: mocks.token },
},
}));
describe('Replay Map Camera Following & Tile Loading', () => {
let sampleRide: Ride;
let rafCallback: ((time: number) => void) | null = null;
beforeEach(() => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
mocks.token.mockResolvedValue('authorized-test-token');
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
rafCallback = cb;
return 1001;
});
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {
rafCallback = null;
});
const points = [
{ timestamp: '2026-10-01T12:00:00.000Z', latitude: 35.0, longitude: -115.0, speed: 30, heading: 90, segmentId: 0 },
{ timestamp: '2026-10-01T12:00:01.000Z', latitude: 35.0, longitude: -114.99, speed: 35, heading: 90, segmentId: 0 },
{ timestamp: '2026-10-01T12:00:02.000Z', latitude: 35.0, longitude: -114.98, speed: 40, heading: 90, segmentId: 0 },
];
sampleRide = {
id: 'ride_camera_test',
startedAt: new Date('2026-10-01T12:00:00.000Z'),
endedAt: new Date('2026-10-01T12:00:02.000Z'),
movingTime: 2,
distanceMiles: 1.0,
topSpeedMph: 40,
averageSpeed: 35,
maxLeanLeft: 10,
maxLeanRight: 10,
maxAccelG: 0.3,
maxBrakingG: -0.4,
elevationGain: 5,
qualityScore: 98,
points,
telemetryBundlePath: undefined,
} as Ride;
});
afterEach(() => {
cleanup();
delete (window as Window & { L?: unknown }).L;
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
const setupLeafletMock = () => {
const listeners: Record<string, ((...args: any[]) => void)[]> = {};
const map = {
invalidateSize: vi.fn(),
remove: vi.fn(),
fitBounds: vi.fn(() => {
(listeners['zoomstart'] || []).forEach((cb) => cb());
(listeners['zoomend'] || []).forEach((cb) => cb());
}),
removeLayer: vi.fn(),
panTo: vi.fn(),
getBounds: () => ({ contains: () => true }),
getCenter: vi.fn(() => ({ lat: 35.0, lng: -115.0 })),
getSize: vi.fn(() => ({ x: 600, y: 400 })),
latLngToContainerPoint: vi.fn(([lat, lng]: [number, number]) => {
// Linear screen projection: 1 deg = 5000 px, center at (300, 200)
return {
x: 300 + (lng - (-115.0)) * 5000,
y: 200 - (lat - 35.0) * 5000,
};
}),
containerPointToLatLng: vi.fn((pt: { x: number; y: number }) => {
return {
lat: 35.0 - (pt.y - 200) / 5000,
lng: -115.0 + (pt.x - 300) / 5000,
};
}),
on: vi.fn((event: string, cb: (...args: any[]) => void) => {
listeners[event] = listeners[event] || [];
listeners[event].push(cb);
return map;
}),
off: vi.fn((event: string, cb: (...args: any[]) => void) => {
if (listeners[event]) {
listeners[event] = listeners[event].filter((h) => h !== cb);
}
return map;
}),
_trigger: (event: string, ...args: any[]) => {
(listeners[event] || []).forEach((cb) => cb(...args));
},
};
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),
};
let tileOptionsCapture: any = null;
const tileLayerFn = vi.fn((_url?: string, options?: any) => {
tileOptionsCapture = options;
return {
addTo: vi.fn().mockReturnThis(),
on: vi.fn().mockReturnThis(),
addLayer: vi.fn(),
};
});
const genericLayer = () => ({
addTo: vi.fn().mockReturnThis(),
on: vi.fn().mockReturnThis(),
addLayer: vi.fn(),
});
const L = {
map: vi.fn(() => map),
tileLayer: tileLayerFn,
featureGroup: vi.fn(genericLayer),
polyline: vi.fn(genericLayer),
marker: vi.fn(() => rider),
divIcon: vi.fn(),
circleMarker: vi.fn(() => rider),
latLngBounds: vi.fn((coords) => coords),
};
(window as Window & { L?: unknown }).L = L;
return { map, rider, L, getTileOptions: () => tileOptionsCapture, triggerMapEvent: map._trigger };
};
it('configures tileLayer with mobile continuous loading options to prevent black map tiles', async () => {
const { getTileOptions } = setupLeafletMock();
render(<WebTelemetryReplayModal ride={sampleRide} onClose={() => {}} />);
await waitFor(() => expect(screen.getByTestId('telemetry-leaflet-map')).toBeDefined());
const options = getTileOptions();
expect(options).toBeDefined();
// Verify critical mobile options
expect(options.updateWhenIdle).toBe(false);
expect(options.keepBuffer).toBeGreaterThanOrEqual(6);
expect(options.updateInterval).toBeLessThanOrEqual(150);
expect(options.crossOrigin).toBe(true);
});
it('smoothly follows motorcycle marker with camera during playback and stops upon pause', async () => {
const { map, rider } = setupLeafletMock();
render(<WebTelemetryReplayModal ride={sampleRide} onClose={() => {}} />);
await waitFor(() => expect(screen.getByTestId('follow-toggle-btn')).toBeDefined());
// Auto-follow button shows active "Following" status by default
const followBtn = screen.getByTestId('follow-toggle-btn');
expect(followBtn.textContent).toContain('Following');
// Click play
const playBtn = screen.getByTestId('play-pause-btn');
fireEvent.click(playBtn);
expect(screen.getByText('PAUSE')).toBeDefined();
expect(rafCallback).toBeDefined();
// Frame 1: delta 0
rafCallback!(1000);
// Frame 2: 50ms advance (rider moves east to -114.9995 deg -> 2.5 px offset, within dead zone)
rafCallback!(1050);
expect(rider.setLatLng).toHaveBeenCalled();
// Frames 3-6: step rAF frames forward (frameDelta clamped to max 200ms per frame)
// 200ms + 200ms + 200ms + 200ms = 850ms total
rafCallback!(1250);
rafCallback!(1450);
rafCallback!(1650);
rafCallback!(1850);
// Marker is now ~85% of interval (42.5 px east), exceeding the 32px dead zone
// Verify map.panTo was called with { animate: false } to smoothly shift the camera
expect(map.panTo).toHaveBeenCalledWith(
expect.arrayContaining([expect.any(Number), expect.any(Number)]),
{ animate: false }
);
// Click pause
fireEvent.click(playBtn);
expect(screen.getByText('PLAY')).toBeDefined();
const panCallsBeforePause = map.panTo.mock.calls.length;
// After pause, rafCallback is cleared and no further camera pan calls occur
expect(rafCallback).toBeNull();
expect(map.panTo.mock.calls.length).toBe(panCallsBeforePause);
});
it('suspends follow mode upon user map drag and resumes upon clicking Re-center', async () => {
const { map, triggerMapEvent } = setupLeafletMock();
render(<WebTelemetryReplayModal ride={sampleRide} onClose={() => {}} />);
await waitFor(() => expect(screen.getByTestId('follow-toggle-btn')).toBeDefined());
const followBtn = screen.getByTestId('follow-toggle-btn');
expect(followBtn.textContent).toContain('Following');
// User manually drags the map (Leaflet dragstart event)
fireEvent(window, new Event('resize')); // test safety
// Wrap state change in waitFor / act
await waitFor(() => {
triggerMapEvent('dragstart');
expect(followBtn.textContent).toContain('Re-center');
});
// Start playback while follow is suspended
const playBtn = screen.getByTestId('play-pause-btn');
fireEvent.click(playBtn);
const panCallsWhileSuspended = map.panTo.mock.calls.length;
rafCallback!(1000);
rafCallback!(1800); // large movement
// Camera did not pan while follow was suspended
expect(map.panTo.mock.calls.length).toBe(panCallsWhileSuspended);
// User clicks Re-center
fireEvent.click(followBtn);
// Verify follow mode resumes and camera immediately re-centers on current position
expect(followBtn.textContent).toContain('Following');
expect(map.panTo).toHaveBeenCalledWith([35, -114.998], { animate: false });
});
it('provides Fit Route button that fits bounds and suspends follow mode', async () => {
const { map } = setupLeafletMock();
render(<WebTelemetryReplayModal ride={sampleRide} onClose={() => {}} />);
await waitFor(() => expect(screen.getByTestId('fit-route-btn')).toBeDefined());
const fitBtn = screen.getByTestId('fit-route-btn');
const followBtn = screen.getByTestId('follow-toggle-btn');
expect(followBtn.textContent).toContain('Following');
fireEvent.click(fitBtn);
expect(map.fitBounds).toHaveBeenCalled();
expect(followBtn.textContent).toContain('Re-center');
});
it('recenters map camera cleanly when scrubbing timeline slider', async () => {
const { map, rider } = setupLeafletMock();
render(<WebTelemetryReplayModal ride={sampleRide} onClose={() => {}} />);
await waitFor(() => expect(screen.getByTestId('replay-timeline-slider')).toBeDefined());
const slider = screen.getByTestId('replay-timeline-slider');
fireEvent.change(slider, { target: { value: '2' } });
expect(rider.setLatLng).toHaveBeenLastCalledWith([sampleRide.points![2].latitude, sampleRide.points![2].longitude]);
expect(map.panTo).toHaveBeenCalledWith([sampleRide.points![2].latitude, sampleRide.points![2].longitude], { animate: false });
});
it('suspends follow mode upon manual zoom (zoomstart) without being disabled by programmatic fitBounds', async () => {
const { map, triggerMapEvent } = setupLeafletMock();
render(<WebTelemetryReplayModal ride={sampleRide} onClose={() => {}} />);
await waitFor(() => expect(screen.getByTestId('follow-toggle-btn')).toBeDefined());
const followBtn = screen.getByTestId('follow-toggle-btn');
// Programmatic zoom during mount (fitBounds) did not disable follow mode
expect(followBtn.textContent).toContain('Following');
// User performs manual pinch, wheel, or double-click zoom (emitting Leaflet zoomstart)
await waitFor(() => {
triggerMapEvent('zoomstart');
expect(followBtn.textContent).toContain('Re-center');
});
// Start playback while follow is suspended
const playBtn = screen.getByTestId('play-pause-btn');
fireEvent.click(playBtn);
const panCallsWhileSuspended = map.panTo.mock.calls.length;
// Step forward frames with movement that would otherwise exceed dead zone
act(() => {
rafCallback!(1000);
rafCallback!(1800);
});
// Animation loop does NOT pan or fight user while follow is suspended
expect(map.panTo.mock.calls.length).toBe(panCallsWhileSuspended);
// Explicit user click on Re-center resumes following and immediately aligns camera
fireEvent.click(followBtn);
expect(followBtn.textContent).toContain('Following');
expect(map.panTo).toHaveBeenCalledWith([35, -114.998], { animate: false });
});
it('crosses multiple telemetry sample boundaries without triggering raw panTo snapping', async () => {
// 4 points spaced closely so all points are within the 32px dead zone from origin
// In our test projection: 1 deg = 5000 px, so 0.001 deg = 5 px.
const closePoints = [
{ timestamp: '2026-10-01T12:00:00.000Z', latitude: 35.0, longitude: -115.0000, speed: 20, heading: 90, segmentId: 0 },
{ timestamp: '2026-10-01T12:00:00.050Z', latitude: 35.0, longitude: -114.9998, speed: 20, heading: 90, segmentId: 0 }, // 1 px offset
{ timestamp: '2026-10-01T12:00:00.100Z', latitude: 35.0, longitude: -114.9996, speed: 20, heading: 90, segmentId: 0 }, // 2 px offset
{ timestamp: '2026-10-01T12:00:00.150Z', latitude: 35.0, longitude: -114.9994, speed: 20, heading: 90, segmentId: 0 }, // 3 px offset
];
const testRide = {
...sampleRide,
id: 'ride_sample_boundaries_test',
endedAt: new Date('2026-10-01T12:00:00.150Z'),
points: closePoints,
} as Ride;
const { map } = setupLeafletMock();
render(<WebTelemetryReplayModal ride={testRide} onClose={() => {}} />);
await waitFor(() => expect(screen.getByTestId('follow-toggle-btn')).toBeDefined());
const playBtn = screen.getByTestId('play-pause-btn');
fireEvent.click(playBtn);
expect(rafCallback).toBeDefined();
// Clear initial setup calls
map.panTo.mockClear();
// Frame 1: t=0ms, index = 0
act(() => {
rafCallback!(1000);
// Frame 2: t=60ms (crosses sample boundary from point 0 to point 1 at 50ms)
rafCallback!(1060);
// Frame 3: t=120ms (crosses sample boundary from point 1 to point 2 at 100ms)
rafCallback!(1120);
});
// Because rider moved by at most 2px (well within 32px dead-zone) across 2 sample boundaries,
// neither the dead-zone follow engine nor the tab effect should have called map.panTo.
expect(map.panTo).not.toHaveBeenCalled();
});
});