354 lines
13 KiB
TypeScript
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();
|
|
});
|
|
});
|