import { describe, it, expect } from 'vitest'; import { advancePlaybackIndex } from '../lib/telemetry-track'; import type { TelemetryPoint } from '../lib/telemetry-export'; const BASE = Date.parse('2026-09-28T14:30:00.000Z'); // Minimal TelemetryPoint builder — advancePlaybackIndex only reads timestamp + segmentId. function mk(offsetMs: number, segmentId = 0): TelemetryPoint { return { timestamp: new Date(BASE + offsetMs).toISOString(), latitude: 40, longitude: -74, altitude: 0, speed: 0, heading: 0, gpsAccuracy: 0, leanAngle: 0, pitchAngle: 0, accelX: 0, accelY: 0, accelZ: 0, longitudinalG: 0, lateralG: 0, segmentId, }; } describe('advancePlaybackIndex (replay playback synchronization)', () => { const uniform = Array.from({ length: 10 }, (_, i) => mk(i * 50)); // 20 Hz, 50 ms apart it('advances in step with real recorded time (1x)', () => { // 200 ms of budget over 50 ms samples = 4 steps. const r = advancePlaybackIndex(uniform, 0, 200); expect(r.index).toBe(4); expect(r.remainingMs).toBe(0); }); it('carries leftover budget when it does not cover a full step', () => { // 120 ms consumes two 50 ms steps (100 ms), leaving 20 ms. const r = advancePlaybackIndex(uniform, 0, 120); expect(r.index).toBe(2); expect(r.remainingMs).toBe(20); }); it('does not advance when the budget is below one step', () => { const r = advancePlaybackIndex(uniform, 3, 10); expect(r.index).toBe(3); expect(r.remainingMs).toBe(10); }); it('compresses a multi-second gap to a single 50 ms step so playback never stalls', () => { // Samples 0..2 uniform; sample 3 is 10 s later (disconnected). A 60 ms budget from // index 2 must still cross the gap (treated as a 50 ms step), not wait 10 s. const gapped = [mk(0), mk(50), mk(100), mk(10_100)]; const r = advancePlaybackIndex(gapped, 2, 60); expect(r.index).toBe(3); expect(r.remainingMs).toBe(0); // reached the end }); it('clamps at the final index with no remaining budget', () => { const r = advancePlaybackIndex(uniform, 8, 100_000); expect(r.index).toBe(9); expect(r.remainingMs).toBe(0); }); it('is a no-op on empty input', () => { expect(advancePlaybackIndex([], 0, 1000)).toEqual({ index: 0, remainingMs: 0 }); }); }); describe('replay regression tests: irregular timestamps', () => { it('correctly tracks jittered / non-uniform sample intervals', () => { // Non-uniform offsets: 0, 30, 80, 110, 160 ms const jittered = [mk(0), mk(30), mk(80), mk(110), mk(160)]; // 25 ms: not enough for step 0->1 (needs 30) let r = advancePlaybackIndex(jittered, 0, 25); expect(r.index).toBe(0); expect(r.remainingMs).toBe(25); // Add another 15 ms (total 40 ms): crosses step 0->1 (30 ms), leaves 10 ms (step 1->2 needs 50 ms) r = advancePlaybackIndex(jittered, r.index, 15 + r.remainingMs); expect(r.index).toBe(1); expect(r.remainingMs).toBe(10); // Add 60 ms (total 70 ms): crosses step 1->2 (50 ms, leaves 20 ms). Step 2->3 needs 30 ms (20 < 30). r = advancePlaybackIndex(jittered, r.index, 60 + r.remainingMs); expect(r.index).toBe(2); expect(r.remainingMs).toBe(20); }); it('steps through identical timestamps (dt === 0) without consuming extraneous budget', () => { // Samples 1 and 2 have the exact same timestamp const dups = [mk(0), mk(50), mk(50), mk(100)]; // Budget 50 ms from index 0: covers 0->1 (50 ms) and 1->2 (0 ms) const r = advancePlaybackIndex(dups, 0, 50); expect(r.index).toBe(2); expect(r.remainingMs).toBe(0); }); it('does not advance on zero or negative budget even if adjacent samples have identical timestamp (dt === 0)', () => { const dups = [mk(0), mk(0), mk(50)]; const rZero = advancePlaybackIndex(dups, 0, 0); expect(rZero.index).toBe(0); expect(rZero.remainingMs).toBe(0); const rNeg = advancePlaybackIndex(dups, 0, -10); expect(rNeg.index).toBe(0); expect(rNeg.remainingMs).toBe(0); }); it('treats inverted / backwards timestamps as disconnected (50 ms step) instead of looping or stalling', () => { // Sample 2 is out of order (timestamp goes backwards) const backwards = [mk(0), mk(50), mk(30), mk(100)]; // From index 1 to 2, delta is -20 ms (disconnected). Should step across with 50 ms step. const r = advancePlaybackIndex(backwards, 1, 50); expect(r.index).toBe(2); expect(r.remainingMs).toBe(0); }); it('handles unparseable or invalid timestamps gracefully without throwing', () => { const invalidPts: TelemetryPoint[] = [ mk(0), { ...mk(50), timestamp: 'invalid-timestamp-string' }, mk(100), ]; const r = advancePlaybackIndex(invalidPts, 0, 100); expect(r.index).toBe(2); }); it('safely handles non-finite or NaN budget without freezing', () => { const uniform = [mk(0), mk(50), mk(100)]; const rNaN = advancePlaybackIndex(uniform, 0, NaN); expect(rNaN.index).toBe(0); expect(rNaN.remainingMs).toBe(0); const rNeg = advancePlaybackIndex(uniform, 0, -500); expect(rNeg.index).toBe(0); expect(rNeg.remainingMs).toBe(0); }); }); describe('replay regression tests: large gaps & segment breaks', () => { it('compresses a multi-minute gap (>5s) to a single 50 ms step', () => { // 15-minute gap const gapped = [mk(0), mk(50), mk(15 * 60 * 1000)]; const r = advancePlaybackIndex(gapped, 1, 50); expect(r.index).toBe(2); expect(r.remainingMs).toBe(0); }); it('compresses explicit segment boundary transitions to 50 ms', () => { const segments = [mk(0, 0), mk(50, 0), mk(60, 1), mk(110, 1)]; // From index 1 to 2, segmentId changes from 0 to 1 const r = advancePlaybackIndex(segments, 1, 50); expect(r.index).toBe(2); expect(r.remainingMs).toBe(0); }); it('handles multiple consecutive disconnected gaps smoothly', () => { // 3 successive gaps of 10 seconds each const multiGapped = [mk(0), mk(10_000), mk(20_000), mk(30_000)]; // Each gap is compressed to 50 ms. Total 150 ms crosses all 3 gaps. const r = advancePlaybackIndex(multiGapped, 0, 150); expect(r.index).toBe(3); expect(r.remainingMs).toBe(0); }); it('compresses a 24-hour pause in ride without stalling', () => { const dayGap = [mk(0), mk(50), mk(24 * 60 * 60 * 1000)]; const r = advancePlaybackIndex(dayGap, 1, 55); expect(r.index).toBe(2); expect(r.remainingMs).toBe(0); }); }); describe('replay regression tests: pause and resume playback simulation', () => { const points = Array.from({ length: 20 }, (_, i) => mk(i * 50)); it('simulates play -> pause -> resume without a timing jump', () => { let currentIndex = 0; let carry = 0; let lastTime: number | null = null; // Helper simulating an animation frame at 60 fps (16.67 ms frame delta) const tick = (now: number, isPlaying: boolean, speed = 1.0) => { if (!isPlaying) { lastTime = null; carry = 0; return; } if (lastTime == null) lastTime = now; const rawDelta = now - lastTime; const frameDelta = Math.max(0, Math.min(rawDelta, 200)); lastTime = now; const budget = frameDelta * speed + carry; const res = advancePlaybackIndex(points, currentIndex, budget); currentIndex = res.index; carry = currentIndex >= points.length - 1 ? 0 : res.remainingMs; }; // 1. Play for 3 frames (16.67 ms each, ~50 ms total) tick(1000, true); // frame 1: delta 0, index 0, carry 0 expect(currentIndex).toBe(0); tick(1016.67, true); // frame 2: delta 16.67, index 0, carry 16.67 expect(currentIndex).toBe(0); expect(carry).toBeCloseTo(16.67, 1); tick(1050, true); // frame 3: delta 33.33 + carry 16.67 = 50 ms -> index 1! expect(currentIndex).toBe(1); expect(carry).toBe(0); // 2. Pause for 5 seconds (5000 ms wall clock) tick(1060, false); // pause triggered expect(lastTime).toBeNull(); expect(carry).toBe(0); // 3. Resume 5 seconds later at t=6050 tick(6050, true); // first resume frame: lastTime initialized to 6050, delta = 0! expect(currentIndex).toBe(1); // DID NOT JUMP expect(carry).toBe(0); // 4. Continue playing after resume tick(6100, true); // 50 ms delta -> index 2! expect(currentIndex).toBe(2); expect(carry).toBe(0); }); }); describe('replay regression tests: manual seek', () => { const points = Array.from({ length: 50 }, (_, i) => mk(i * 50)); it('seeks forward and clears carryover', () => { let index = 5; let carry = 25; // Manual seek to 20 index = 20; carry = 0; const r = advancePlaybackIndex(points, index, 50 + carry); expect(r.index).toBe(21); expect(r.remainingMs).toBe(0); }); it('seeks backward and clears carryover', () => { let index = 30; let carry = 40; // Manual seek back to 10 index = 10; carry = 0; const r = advancePlaybackIndex(points, index, 100 + carry); expect(r.index).toBe(12); }); it('clamps out-of-bounds seek indices safely', () => { const rNegative = advancePlaybackIndex(points, -100, 100); expect(rNegative.index).toBe(2); // starts from 0, advances 2 steps const rBeyond = advancePlaybackIndex(points, 9999, 100); expect(rBeyond.index).toBe(49); // clamped to length - 1 expect(rBeyond.remainingMs).toBe(0); }); }); describe('replay regression tests: speed changes', () => { const points = Array.from({ length: 100 }, (_, i) => mk(i * 50)); it('scales budget consumption proportionally to speed', () => { // At 0.5x speed: 100 ms real time = 50 ms virtual budget = 1 step const rHalf = advancePlaybackIndex(points, 0, 100 * 0.5); expect(rHalf.index).toBe(1); // At 2x speed: 50 ms real time = 100 ms virtual budget = 2 steps const rDouble = advancePlaybackIndex(points, 0, 50 * 2.0); expect(rDouble.index).toBe(2); // At 5x speed: 50 ms real time = 250 ms virtual budget = 5 steps const rFive = advancePlaybackIndex(points, 0, 50 * 5.0); expect(rFive.index).toBe(5); // At 10x speed: 50 ms real time = 500 ms virtual budget = 10 steps const rTen = advancePlaybackIndex(points, 0, 50 * 10.0); expect(rTen.index).toBe(10); }); it('handles speed changes mid-playback without time discontinuity', () => { let index = 10; let carry = 0; let speed = 1.0; // 1 frame at 1x (50 ms): advances 1 step let r = advancePlaybackIndex(points, index, 50 * speed + carry); index = r.index; carry = r.remainingMs; expect(index).toBe(11); // User switches to 4x speed: reset lastTime (delta = 0 on transition), next frame is 50 ms * 4 = 200 ms speed = 4.0; r = advancePlaybackIndex(points, index, 50 * speed + carry); index = r.index; carry = r.remainingMs; // 200 ms budget over 50 ms steps = 4 steps: 11 + 4 = 15 expect(index).toBe(15); expect(carry).toBe(0); }); }); describe('replay regression tests: large datasets', () => { it('advances efficiently across 10,000 points', () => { const large = Array.from({ length: 10_000 }, (_, i) => mk(i * 50)); const start = performance.now(); // Advance 500 steps (25,000 ms budget) const r = advancePlaybackIndex(large, 1000, 25_000); const duration = performance.now() - start; expect(r.index).toBe(1500); expect(r.remainingMs).toBe(0); expect(duration).toBeLessThan(100); }); it('handles 144,000 points (full 2-hour 20 Hz recording) without stalling', () => { const huge = Array.from({ length: 144_000 }, (_, i) => mk(i * 50)); const start = performance.now(); // Advance 2,000 steps at high speed const r = advancePlaybackIndex(huge, 100_000, 100_000); const duration = performance.now() - start; expect(r.index).toBe(102_000); expect(r.remainingMs).toBe(0); expect(duration).toBeLessThan(100); // End of recording clamp const rEnd = advancePlaybackIndex(huge, 143_990, 1_000_000); expect(rEnd.index).toBe(143_999); expect(rEnd.remainingMs).toBe(0); }); });