144 lines
5.8 KiB
TypeScript
144 lines
5.8 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import React from 'react';
|
|
import { render, screen, fireEvent } from '@testing-library/react';
|
|
import {
|
|
generateRideGpx,
|
|
generateRideCsv,
|
|
synthesizeTelemetryTrack,
|
|
analyzeCorners,
|
|
} from '../lib/telemetry-export';
|
|
import { WebTelemetryReplayModal } from '../components/web-telemetry-replay-modal';
|
|
import { Ride } from '../components/app-cloud-data';
|
|
|
|
const mockRide: Ride = {
|
|
id: 'ride_unit_test_123',
|
|
startedAt: new Date('2026-09-28T14:30:00Z'),
|
|
endedAt: new Date('2026-09-28T15:15:00Z'),
|
|
distanceMiles: 24.8,
|
|
topSpeedMph: 86.4,
|
|
averageSpeed: 48.2,
|
|
maxLeanLeft: 42.1,
|
|
maxLeanRight: 39.5,
|
|
maxAccelG: 0.72,
|
|
maxBrakingG: 0.95,
|
|
movingTime: 1850,
|
|
elevationGain: 1240,
|
|
qualityScore: 98,
|
|
motorcycle: '2024 Yamaha YZF-R6',
|
|
points: Array.from({length:80},(_,i)=>({timestamp:new Date(Date.parse('2026-09-28T14:30:00Z')+i*50).toISOString(),latitude:40+i*0.00001,longitude:-74+i*0.00001,altitude:12,speed:20,heading:45,gpsAccuracy:2,leanAngle:i>10&&i<30?25:0,pitchAngle:0,accelX:0,accelY:0,accelZ:1,longitudinalG:0,lateralG:0,segmentId:0})),
|
|
};
|
|
|
|
describe('Web Telemetry Replay & Export (WEB-23)', () => {
|
|
it('loads recorded 20 Hz telemetry without changing geographic coordinates', () => {
|
|
const points = synthesizeTelemetryTrack(mockRide);
|
|
expect(points.length).toBeGreaterThan(50);
|
|
|
|
// Verify properties exist and have valid numerical ranges
|
|
const first = points[0];
|
|
expect(first.timestamp).toBeDefined();
|
|
expect(first.latitude).toBeGreaterThan(30);
|
|
expect(first.longitude).toBe(-74);
|
|
expect(first.latitude).toBe(40);
|
|
expect(first.speed).toBeGreaterThanOrEqual(0);
|
|
expect(first.leanAngle).toBeDefined();
|
|
expect(first.longitudinalG).toBeDefined();
|
|
expect(first.lateralG).toBeDefined();
|
|
});
|
|
|
|
it('generates valid GPX 1.1 with trackpoints, elevation, speed, and lean extensions', () => {
|
|
const points = synthesizeTelemetryTrack(mockRide);
|
|
const gpx = generateRideGpx(mockRide, points);
|
|
|
|
expect(gpx).toContain('<?xml version="1.0" encoding="UTF-8"?>');
|
|
expect(gpx).toContain('<gpx version="1.1" creator="MotorSignal"');
|
|
expect(gpx).toContain('<trk>');
|
|
expect(gpx).toContain('<trkseg>');
|
|
expect(gpx).toContain('<trkpt lat=');
|
|
expect(gpx).toContain('<ele>');
|
|
expect(gpx).toContain('<extensions>');
|
|
expect(gpx).toContain('<speed>');
|
|
expect(gpx).toContain('<lean>');
|
|
});
|
|
|
|
it('generates compliant 20 Hz CSV matching ExportService format', () => {
|
|
const points = synthesizeTelemetryTrack(mockRide);
|
|
const csv = generateRideCsv(mockRide, points);
|
|
|
|
const lines = csv.split('\n');
|
|
expect(lines[0]).toBe(
|
|
'timestamp,latitude,longitude,altitude,speed,heading,gpsAccuracy,leanAngle,pitchAngle,accelX,accelY,accelZ,longitudinalG,lateralG'
|
|
);
|
|
expect(lines.length).toBe(points.length + 1);
|
|
|
|
// Inspect first sample row
|
|
const firstRowCols = lines[1].split(',');
|
|
expect(firstRowCols.length).toBe(14);
|
|
});
|
|
|
|
it('analyzes corners and grades apex performance correctly', () => {
|
|
const points = synthesizeTelemetryTrack(mockRide);
|
|
const corners = analyzeCorners(points);
|
|
|
|
expect(Array.isArray(corners)).toBe(true);
|
|
if (corners.length > 0) {
|
|
const c = corners[0];
|
|
expect(c.direction).toMatch(/Left|Right/);
|
|
expect(c.entrySpeed).toBeGreaterThan(0);
|
|
expect(c.apexSpeed).toBeGreaterThan(0);
|
|
expect(c.apexLeanAngle).toBeGreaterThan(15);
|
|
expect(['A+', 'A', 'B', 'C', 'D']).toContain(c.grade);
|
|
}
|
|
});
|
|
|
|
it('renders WebTelemetryReplayModal with HUD gauges, transport controls, and export buttons', () => {
|
|
const onClose = vi.fn();
|
|
render(<WebTelemetryReplayModal ride={mockRide} onClose={onClose} />);
|
|
|
|
// Check title and metrics
|
|
expect(screen.getByText('TELEMETRY FORENSIC REPLAY')).toBeDefined();
|
|
expect(screen.getByText(/2024 Yamaha YZF-R6/)).toBeDefined();
|
|
|
|
// Check HUD stats
|
|
expect(screen.getByText('VEHICLE SPEED')).toBeDefined();
|
|
expect(screen.getByText('LEAN ANGLE')).toBeDefined();
|
|
expect(screen.getByText('G-FORCE DYNAMICS')).toBeDefined();
|
|
expect(screen.getByText('ALTITUDE & TIME')).toBeDefined();
|
|
|
|
// Check buttons
|
|
expect(screen.getByTestId('export-gpx-btn')).toBeDefined();
|
|
expect(screen.getByTestId('export-csv-btn')).toBeDefined();
|
|
expect(screen.getByTestId('play-pause-btn')).toBeDefined();
|
|
expect(screen.getByTestId('replay-timeline-slider')).toBeDefined();
|
|
|
|
// Check tabs
|
|
expect(screen.getByTestId('tab-map-replay')).toBeDefined();
|
|
expect(screen.getByTestId('tab-corner-grading')).toBeDefined();
|
|
|
|
// Switch to Corner Apex Grading tab
|
|
fireEvent.click(screen.getByTestId('tab-corner-grading'));
|
|
expect(screen.getByText('Corner')).toBeDefined();
|
|
expect(screen.getByText('Apex Lean')).toBeDefined();
|
|
expect(screen.getByText('Grade')).toBeDefined();
|
|
|
|
// Close modal
|
|
fireEvent.click(screen.getByTestId('close-replay-modal'));
|
|
expect(onClose).toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects generating GPX or CSV when ride telemetry is empty, preventing silent empty export (Finding 4)', () => {
|
|
const emptyRide: Ride = {
|
|
...mockRide,
|
|
id: 'ride_empty_telemetry',
|
|
points: undefined,
|
|
gpxContent: undefined,
|
|
csvContent: undefined,
|
|
telemetryBundlePath: undefined,
|
|
};
|
|
|
|
expect(() => generateRideGpx(emptyRide)).toThrow(/Cannot export ride ride_empty_telemetry: no recorded telemetry points available/);
|
|
expect(() => generateRideCsv(emptyRide)).toThrow(/Cannot export ride ride_empty_telemetry: no recorded telemetry points available/);
|
|
expect(() => generateRideGpx(emptyRide, [])).toThrow(/Cannot export ride ride_empty_telemetry: no recorded telemetry points available/);
|
|
expect(() => generateRideCsv(emptyRide, [])).toThrow(/Cannot export ride ride_empty_telemetry: no recorded telemetry points available/);
|
|
});
|
|
});
|