445 lines
16 KiB
TypeScript
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)
|
|
});
|
|
});
|