MotorSignal-App/website/tests/telemetry-playback-sync.tes...

336 lines
12 KiB
TypeScript

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