import { describe, expect, it, vi } from 'vitest'; import { computeCameraFollowStep } from '../lib/telemetry-camera-follow'; describe('telemetry-camera-follow engine', () => { const createMockMap = (center = { lat: 35.0, lng: -115.0 }, size = { x: 600, y: 400 }) => { return { getCenter: vi.fn(() => ({ ...center })), getSize: vi.fn(() => ({ ...size })), latLngToContainerPoint: vi.fn(([lat, lng]: [number, number]) => { // Linear scale for testing: 1 deg = 1000 px, centered at (300, 200) const x = (size.x / 2) + (lng - center.lng) * 1000; const y = (size.y / 2) - (lat - center.lat) * 1000; return { x, y }; }), containerPointToLatLng: vi.fn((pt: { x: number; y: number }) => { const lng = center.lng + (pt.x - (size.x / 2)) / 1000; const lat = center.lat - (pt.y - (size.y / 2)) / 1000; return { lat, lng }; }), }; }; it('keeps camera stationary when motorcycle marker is within dead zone radius', () => { const map = createMockMap({ lat: 35.0, lng: -115.0 }); // Marker offset by only 10 pixels (dead zone is 32px) // 10 px / 1000 = 0.01 deg const markerLatLng = { lat: 35.005, lng: -115.005 }; // hypot is ~7 px const result = computeCameraFollowStep(map, markerLatLng, 16.67, { deadZoneRadiusPx: 32 }); expect(result.shouldPan).toBe(false); expect(result.excessPx).toBe(0); expect(result.targetLatLng.lat).toBe(35.0); expect(result.targetLatLng.lng).toBe(-115.0); }); it('smoothly follows towards marker when outside dead zone', () => { const map = createMockMap({ lat: 35.0, lng: -115.0 }); // Marker offset to the right by 100 pixels (dx = 100, dy = 0) // lng = -115.0 + 100 / 1000 = -114.90 const markerLatLng = { lat: 35.0, lng: -114.90 }; const result = computeCameraFollowStep(map, markerLatLng, 16.67, { deadZoneRadiusPx: 30, maxStepPxPerFrame: 20, dampingLambda: 4.5, }); expect(result.shouldPan).toBe(true); expect(result.distanceFromCenterPx).toBeCloseTo(100, 1); expect(result.excessPx).toBeCloseTo(70, 1); expect(result.panDeltaPx.x).toBeGreaterThan(0); expect(result.panDeltaPx.y).toBeCloseTo(0, 4); // Target longitude should move east (towards -114.90) expect(result.targetLatLng.lng).toBeGreaterThan(-115.0); expect(result.targetLatLng.lng).toBeLessThan(-114.90); expect(result.targetLatLng.lat).toBeCloseTo(35.0, 5); }); it('strictly clamps maximum pan step per frame to prevent outrunning tile loading', () => { const map = createMockMap({ lat: 35.0, lng: -115.0 }); // Marker very far away (500 pixels away) const markerLatLng = { lat: 35.0, lng: -114.50 }; const maxStep = 10; const result = computeCameraFollowStep(map, markerLatLng, 100, { deadZoneRadiusPx: 30, maxStepPxPerFrame: maxStep, dampingLambda: 10, // high damping }); expect(result.shouldPan).toBe(true); const stepLength = Math.hypot(result.panDeltaPx.x, result.panDeltaPx.y); expect(stepLength).toBeCloseTo(maxStep, 2); }); it('adjusts smoothly to higher playback speeds and larger frame deltas', () => { const map = createMockMap({ lat: 35.0, lng: -115.0 }); const markerLatLng = { lat: 35.0, lng: -114.90 }; // 100px away const stepShort = computeCameraFollowStep(map, markerLatLng, 16.67, { maxStepPxPerFrame: 50 }); const stepLong = computeCameraFollowStep(map, markerLatLng, 50, { maxStepPxPerFrame: 50 }); expect(stepLong.panDeltaPx.x).toBeGreaterThan(stepShort.panDeltaPx.x); }); it('falls back safely to LatLng interpolation when container projection methods are missing', () => { const mapWithoutProjection = { getCenter: () => ({ lat: 35.0, lng: -115.0 }), }; const markerLatLng = { lat: 35.01, lng: -115.01 }; // outside ~0.0003 deg dead zone const result = computeCameraFollowStep(mapWithoutProjection, markerLatLng, 16.67); expect(result.shouldPan).toBe(true); expect(result.targetLatLng.lat).toBeGreaterThan(35.0); expect(result.targetLatLng.lng).toBeLessThan(-115.0); }); it('handles null, undefined, and non-finite coordinates gracefully without throwing', () => { const map = createMockMap(); const resultNull = computeCameraFollowStep(null, null as any); expect(resultNull.shouldPan).toBe(false); const resultNaN = computeCameraFollowStep(map, { lat: NaN, lng: 10 }); expect(resultNaN.shouldPan).toBe(false); }); });