MotorSignal-App/website/tests/telemetry-smooth-replay.tes...

445 lines
16 KiB
TypeScript

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)
});
});