import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { interpolateHeading, interpolateTelemetryPosition, interpolateTelemetryPoint, advancePlaybackIndex, areTelemetryPointsConnected, } from '../lib/telemetry-track'; import type { TelemetryPoint } from '../lib/telemetry-export'; const BASE = Date.parse('2026-09-28T14:30:00.000Z'); function mkPoint( offsetMs: number, lat = 40.0, lng = -74.0, heading = 0, speed = 45, leanAngle = 0, segmentId = 0 ): TelemetryPoint { return { timestamp: new Date(BASE + offsetMs).toISOString(), latitude: lat, longitude: lng, altitude: 100, speed, heading, gpsAccuracy: 1.5, leanAngle, pitchAngle: 0, accelX: 0, accelY: 0, accelZ: 1.0, longitudinalG: 0.1, lateralG: 0.2, segmentId, }; } describe('interpolateHeading: Shortest Angular Path & Wraparound', () => { it('interpolates heading along normal forward angular path', () => { expect(interpolateHeading(10, 50, 0)).toBe(10); expect(interpolateHeading(10, 50, 0.5)).toBeCloseTo(30, 2); expect(interpolateHeading(10, 50, 1.0)).toBe(50); }); it('interpolates 359° to 0° across the north boundary via the shortest path (+1°)', () => { // 359 to 0 is +1 deg, so at t=0.5 it must be 359.5°, not 179.5° const mid = interpolateHeading(359, 0, 0.5); expect(mid).toBeCloseTo(359.5, 2); expect(interpolateHeading(359, 0, 0)).toBe(359); expect(interpolateHeading(359, 0, 1)).toBe(0); }); it('interpolates 0° to 359° across the north boundary via the shortest path (-1°)', () => { const mid = interpolateHeading(0, 359, 0.5); expect(mid).toBeCloseTo(359.5, 2); expect(interpolateHeading(0, 359, 0)).toBe(0); expect(interpolateHeading(0, 359, 1)).toBe(359); }); it('interpolates 350° to 10° crossing 0° (+20° shortest path)', () => { // 350 -> 355 -> 0 -> 5 -> 10 expect(interpolateHeading(350, 10, 0.25)).toBeCloseTo(355, 2); expect(interpolateHeading(350, 10, 0.5)).toBeCloseTo(0, 2); expect(interpolateHeading(350, 10, 0.75)).toBeCloseTo(5, 2); expect(interpolateHeading(350, 10, 1.0)).toBeCloseTo(10, 2); }); it('interpolates 10° to 350° crossing 0° counterclockwise (-20° shortest path)', () => { expect(interpolateHeading(10, 350, 0.25)).toBeCloseTo(5, 2); expect(interpolateHeading(10, 350, 0.5)).toBeCloseTo(0, 2); expect(interpolateHeading(10, 350, 0.75)).toBeCloseTo(355, 2); expect(interpolateHeading(10, 350, 1.0)).toBeCloseTo(350, 2); }); it('handles identical headings without drift', () => { expect(interpolateHeading(180, 180, 0.5)).toBe(180); expect(interpolateHeading(0, 0, 0.7)).toBe(0); }); it('handles out-of-range headings and negative angles safely', () => { expect(interpolateHeading(-10, 30, 0.5)).toBeCloseTo(10, 2); // -10 is 350 expect(interpolateHeading(720, 90, 0.5)).toBeCloseTo(45, 2); // 720 is 0 }); it('handles NaN or non-finite inputs gracefully without throwing', () => { expect(interpolateHeading(NaN, 90, 0.5)).toBe(90); expect(interpolateHeading(90, NaN, 0.5)).toBe(90); expect(interpolateHeading(NaN, NaN, 0.5)).toBe(0); }); it('clamps t parameter strictly to [0, 1]', () => { expect(interpolateHeading(10, 50, -0.5)).toBe(10); expect(interpolateHeading(10, 50, 1.5)).toBe(50); }); }); describe('interpolateTelemetryPosition: Smooth Intermediate Positions', () => { const p1 = mkPoint(0, 40.0, -74.0, 350, 20); const p2 = mkPoint(100, 40.001, -73.999, 10, 30); const points = [p1, p2]; it('returns exact start coordinates at remainingMs = 0', () => { const pos = interpolateTelemetryPosition(points, 0, 0); expect(pos.latitude).toBe(40.0); expect(pos.longitude).toBe(-74.0); expect(pos.heading).toBe(350); }); it('computes smooth sub-sample fractions (25%, 50%, 75%) between GPS points', () => { // At remainingMs = 25 (25% into 100 ms interval) const p25 = interpolateTelemetryPosition(points, 0, 25); expect(p25.latitude).toBeCloseTo(40.00025, 5); expect(p25.longitude).toBeCloseTo(-73.99975, 5); expect(p25.heading).toBeCloseTo(355, 1); // At remainingMs = 50 (50% into 100 ms interval) const p50 = interpolateTelemetryPosition(points, 0, 50); expect(p50.latitude).toBeCloseTo(40.0005, 5); expect(p50.longitude).toBeCloseTo(-73.9995, 5); expect(p50.heading).toBeCloseTo(0, 1); // 350 to 10 midpoint is 0 // At remainingMs = 75 (75% into 100 ms interval) const p75 = interpolateTelemetryPosition(points, 0, 75); expect(p75.latitude).toBeCloseTo(40.00075, 5); expect(p75.longitude).toBeCloseTo(-73.99925, 5); expect(p75.heading).toBeCloseTo(5, 1); }); it('returns exact destination coordinates at remainingMs = dt', () => { const p100 = interpolateTelemetryPosition(points, 0, 100); expect(p100.latitude).toBeCloseTo(40.001, 5); expect(p100.longitude).toBeCloseTo(-73.999, 5); expect(p100.heading).toBe(10); }); it('clamps remainingMs greater than dt to the destination point', () => { const pos = interpolateTelemetryPosition(points, 0, 500); expect(pos.latitude).toBeCloseTo(40.001, 5); expect(pos.longitude).toBeCloseTo(-73.999, 5); expect(pos.heading).toBe(10); }); it('clamps at the final index safely without accessing undefined', () => { const pos = interpolateTelemetryPosition(points, 1, 50); expect(pos.latitude).toBe(40.001); expect(pos.longitude).toBe(-73.999); expect(pos.heading).toBe(10); }); it('handles empty points array safely', () => { const pos = interpolateTelemetryPosition([], 0, 50); expect(pos).toEqual({ latitude: 0, longitude: 0, heading: 0 }); }); it('handles negative or out-of-bounds indices safely', () => { const neg = interpolateTelemetryPosition(points, -5, 0); expect(neg.latitude).toBe(40.0); const beyond = interpolateTelemetryPosition(points, 999, 0); expect(beyond.latitude).toBe(40.001); }); }); describe('interpolateTelemetryPosition: Gaps, Segment Breaks & Edge Cases', () => { it('does NOT interpolate coordinates across a GPS loss gap (>5000 ms)', () => { // 10-second gap between sample 0 and sample 1 const gapPoints = [ mkPoint(0, 40.0, -74.0, 90), mkPoint(10_000, 41.0, -73.0, 90), ]; expect(areTelemetryPointsConnected(gapPoints[0], gapPoints[1])).toBe(false); // During the 50 ms compressed playback of the gap, the marker MUST remain at point 0, // not fly across 1 degree of latitude! const midGap = interpolateTelemetryPosition(gapPoints, 0, 25); expect(midGap.latitude).toBe(40.0); expect(midGap.longitude).toBe(-74.0); }); it('does NOT interpolate coordinates across a segment boundary', () => { const segmentPoints = [ mkPoint(0, 40.0, -74.0, 90, 30, 0, 0), mkPoint(50, 42.0, -71.0, 90, 30, 0, 1), // segmentId changes to 1 ]; expect(areTelemetryPointsConnected(segmentPoints[0], segmentPoints[1])).toBe(false); const midSegment = interpolateTelemetryPosition(segmentPoints, 0, 25); expect(midSegment.latitude).toBe(40.0); expect(midSegment.longitude).toBe(-74.0); }); it('handles duplicate timestamps (dt === 0) safely without division by zero', () => { const dupPoints = [ mkPoint(0, 40.0, -74.0, 90), mkPoint(0, 40.001, -73.999, 90), // same timestamp ]; const pos = interpolateTelemetryPosition(dupPoints, 0, 25); expect(Number.isFinite(pos.latitude)).toBe(true); expect(Number.isFinite(pos.longitude)).toBe(true); expect(pos.latitude).toBe(40.0); }); it('handles backwards or inverted timestamps safely', () => { const invertedPoints = [ mkPoint(100, 40.0, -74.0, 90), mkPoint(50, 40.001, -73.999, 90), // timestamp goes backwards ]; const pos = interpolateTelemetryPosition(invertedPoints, 0, 25); expect(pos.latitude).toBe(40.0); }); it('handles non-finite or corrupted coordinates safely without emitting NaN', () => { const corruptedPoints = [ mkPoint(0, NaN, -74.0, 90), mkPoint(50, 40.001, NaN, 90), ]; const pos = interpolateTelemetryPosition(corruptedPoints, 0, 25); expect(Number.isFinite(pos.latitude)).toBe(true); expect(Number.isFinite(pos.longitude)).toBe(true); expect(pos.latitude).toBeCloseTo(20.0005, 3); // lerp between 0 and 40.001 expect(pos.longitude).toBe(-74.0); // p2Lng falls back to p1Lng (-74.0) }); }); describe('interpolateTelemetryPoint: Full Metric Interpolation', () => { it('interpolates speed, lean angle, altitude, and G-forces proportionally', () => { const p1 = { ...mkPoint(0, 40.0, -74.0, 350, 20, -10), altitude: 100, longitudinalG: 0.1, lateralG: -0.4, }; const p2 = { ...mkPoint(100, 40.001, -73.999, 10, 40, 20), altitude: 120, longitudinalG: 0.5, lateralG: 0.8, }; const points = [p1, p2]; const interpolated = interpolateTelemetryPoint(points, 0, 50); // t = 0.5 expect(interpolated.speed).toBeCloseTo(30, 1); expect(interpolated.leanAngle).toBeCloseTo(5, 1); // -10 + 0.5 * (20 - (-10)) = 5 expect(interpolated.altitude).toBeCloseTo(110, 1); expect(interpolated.longitudinalG).toBeCloseTo(0.3, 2); expect(interpolated.lateralG).toBeCloseTo(0.2, 2); expect(interpolated.heading).toBeCloseTo(0, 1); const expectedTime = new Date(BASE + 50).toISOString(); expect(interpolated.timestamp).toBe(expectedTime); }); it('safely handles points with non-finite or missing metrics', () => { const p1 = { ...mkPoint(0, NaN, -74.0, NaN, 20), speed: undefined as any, }; const p2 = { ...mkPoint(100, 40.0, NaN, 10, 40), }; const points = [p1, p2]; const interpolated = interpolateTelemetryPoint(points, 0, 50); expect(Number.isFinite(interpolated.latitude)).toBe(true); expect(Number.isFinite(interpolated.longitude)).toBe(true); expect(Number.isFinite(interpolated.speed)).toBe(true); expect(Number.isFinite(interpolated.heading)).toBe(true); }); }); describe('Playback Simulation: 60 FPS Animation & Marker Smoothness', () => { // 20 Hz recording (50 ms intervals) over 100 points const uniform20Hz = Array.from({ length: 100 }, (_, i) => mkPoint(i * 50, 40.0 + i * 0.0001, -74.0 + i * 0.0001, (i * 5) % 360, 45, (i % 20) - 10) ); it('produces distinct smooth intermediate positions at 60 FPS (16.67 ms ticks)', () => { let index = 0; let carry = 0; let lastTime: number | null = null; const recordedPositions: { lat: number; lng: number }[] = []; const frameStep = (timestamp: number, speed = 1.0) => { if (lastTime == null) lastTime = timestamp; const frameDelta = Math.max(0, Math.min(timestamp - lastTime, 200)); lastTime = timestamp; const budget = frameDelta * speed + carry; const res = advancePlaybackIndex(uniform20Hz, index, budget); index = res.index; carry = index >= uniform20Hz.length - 1 ? 0 : res.remainingMs; const pos = interpolateTelemetryPosition(uniform20Hz, index, carry); recordedPositions.push({ lat: pos.latitude, lng: pos.longitude }); }; // Simulate 6 consecutive animation frames at 60 FPS (approx 100 ms total) for (let f = 0; f < 6; f++) { frameStep(1000 + f * 16.6667, 1.0); } expect(recordedPositions.length).toBe(6); // Frame 0: starting point expect(recordedPositions[0].lat).toBeCloseTo(40.0, 5); // Frame 1 (~16.67 ms): sub-sample movement between pt 0 and pt 1 expect(recordedPositions[1].lat).toBeGreaterThan(recordedPositions[0].lat); expect(recordedPositions[1].lat).toBeLessThan(40.0001); // Frame 2 (~33.33 ms): further continuous sub-sample movement expect(recordedPositions[2].lat).toBeGreaterThan(recordedPositions[1].lat); expect(recordedPositions[2].lat).toBeLessThan(40.0001); // Frame 3 (~50.0 ms): crosses point 1 into point 2 expect(recordedPositions[3].lat).toBeGreaterThanOrEqual(40.0001); // Every single frame produced a strictly increasing position — NO FREEZES OR SNAPS! for (let i = 1; i < recordedPositions.length; i++) { expect(recordedPositions[i].lat).toBeGreaterThan(recordedPositions[i - 1].lat); } }); it('supports 0.5x, 1x, 2x, 5x, and 10x playback speeds with continuous interpolation', () => { const speeds = [0.5, 1.0, 2.0, 5.0, 10.0]; speeds.forEach((speed) => { let index = 0; let carry = 0; let lastTime: number | null = null; let prevLat = 40.0; // Run 10 frames at each speed for (let f = 0; f < 10; f++) { const timestamp = 1000 + f * 16.6667; if (lastTime == null) lastTime = timestamp; const frameDelta = timestamp - lastTime; lastTime = timestamp; const budget = frameDelta * speed + carry; const res = advancePlaybackIndex(uniform20Hz, index, budget); index = res.index; carry = index >= uniform20Hz.length - 1 ? 0 : res.remainingMs; const pos = interpolateTelemetryPosition(uniform20Hz, index, carry); if (f > 0) { expect(pos.latitude).toBeGreaterThan(prevLat); } prevLat = pos.latitude; } }); }); it('pauses without marker drift and resumes seamlessly from exact sub-sample position', () => { let index = 0; let carry = 0; let lastTime: number | null = null; const tick = (now: number, isPlaying: boolean) => { if (!isPlaying) { lastTime = null; // Notice carry is preserved! return; } if (lastTime == null) lastTime = now; const rawDelta = now - lastTime; const frameDelta = Math.max(0, Math.min(rawDelta, 200)); lastTime = now; const budget = frameDelta * 1.0 + carry; const res = advancePlaybackIndex(uniform20Hz, index, budget); index = res.index; carry = index >= uniform20Hz.length - 1 ? 0 : res.remainingMs; }; // Play for 2 frames: t=1000, t=1016.67, t=1033.33 tick(1000, true); tick(1016.67, true); tick(1033.33, true); const pausedPos = interpolateTelemetryPosition(uniform20Hz, index, carry); const pausedIndex = index; const pausedCarry = carry; // Pause for 10 seconds tick(1040, false); tick(11040, false); // Marker MUST NOT drift while paused const stillPausedPos = interpolateTelemetryPosition(uniform20Hz, index, carry); expect(stillPausedPos.latitude).toBe(pausedPos.latitude); expect(stillPausedPos.longitude).toBe(pausedPos.longitude); // Resume at t=12000 tick(12000, true); // First frame of resume: delta = 0 expect(index).toBe(pausedIndex); expect(carry).toBe(pausedCarry); const resumedPos = interpolateTelemetryPosition(uniform20Hz, index, carry); expect(resumedPos.latitude).toBe(pausedPos.latitude); expect(resumedPos.longitude).toBe(pausedPos.longitude); // Continue playback smoothly tick(12050, true); const afterPos = interpolateTelemetryPosition(uniform20Hz, index, carry); expect(afterPos.latitude).toBeGreaterThan(resumedPos.latitude); }); it('seeking to an exact position snaps cleanly with zero carryover', () => { let index = 10; let carry = 32.5; // Seek to index 42 index = 42; carry = 0; const pos = interpolateTelemetryPosition(uniform20Hz, index, carry); expect(pos.latitude).toBe(uniform20Hz[42].latitude); expect(pos.longitude).toBe(uniform20Hz[42].longitude); expect(pos.heading).toBe(uniform20Hz[42].heading); }); it('smoothly replays a massive 144,000-point (2-hour 20 Hz) recording with high performance', () => { const huge = Array.from({ length: 144_000 }, (_, i) => mkPoint(i * 50, 40.0 + i * 0.000001, -74.0 + i * 0.000001, (i * 2) % 360) ); const start = performance.now(); let index = 50_000; let carry = 0; // Simulate 500 animation frames (approx 8.3 seconds of 60 FPS playback) for (let f = 0; f < 500; f++) { const budget = 16.67 * 2.0 + carry; // 2x speed const res = advancePlaybackIndex(huge, index, budget); index = res.index; carry = index >= huge.length - 1 ? 0 : res.remainingMs; const pos = interpolateTelemetryPosition(huge, index, carry); expect(pos.latitude).toBeGreaterThan(40.0); } const elapsed = performance.now() - start; expect(elapsed).toBeLessThan(500); // 500 frames interpolated in under 500 ms (<1 ms/frame, well under the 16.67 ms budget) }); });