Add GPX file support for activity imports

This commit adds comprehensive GPX file support alongside existing FIT file support, enabling users to import activities from Strava, Komoot, and other GPS apps.

## Key Features

### Core Components
- **GpxParser**: Full GPX 1.1 parsing with Garmin TrackPointExtension support
- **GpxFileValidator**: Validation for GPX file format and structure
- **ActivityFileService**: Unified service with automatic format detection (FIT/GPX)
- **ParsedActivityData**: Common data structure for both FIT and GPX files

### GPX Parsing Capabilities
- GPS track point extraction (latitude, longitude, elevation, timestamp)
- Garmin extension data (heart rate, cadence, temperature)
- Activity type detection from GPX metadata
- Distance calculation using Haversine formula
- Elevation gain/loss calculation
- Speed calculation from consecutive GPS points
- Speed smoothing to remove GPS artifacts
- Timezone detection from GPS coordinates
- Moving time vs. stopped time analysis

### Database Changes
- Migration V15: Renamed raw_fit_file → raw_activity_file
- Added source_file_format column (FIT/GPX) with constraint
- Index on source_file_format for performance
- Updated Activity entity with new fields

### Controller & UI Updates
- ActivityController: Now handles both FIT and GPX uploads
- Upload form: Updated to accept .fit and .gpx files
- Help text: Clarified both formats are supported

### Testing
- GpxParserIntegrationTest: 9 comprehensive tests with real GPX file
- Tests cover: parsing, validation, heart rate extraction, distance calculation,
  elevation metrics, speed calculation, chronological ordering, smoothing
- Fixed TrainingLoadServiceTest date issue (testDate outside 30-day window)
- All 97 unit tests passing (integration tests require Docker)

### Technical Details
- Supports GPX 1.0 and 1.1 specifications
- Handles multiple track segments
- Processes Garmin TrackPointExtension v1 and v2
- Same track simplification as FIT (Douglas-Peucker algorithm)
- Consistent JSONB storage format for track points
- Compatible with existing analytics, heatmaps, and image generation

## Testing Summary
-  9/9 GpxParserIntegrationTest tests passing
-  4/4 FitParserIntegrationTest tests passing
-  14/14 FitFileServiceTest tests passing
-  97/97 total unit tests passing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Tim Zöller 2026-01-02 13:31:05 +01:00
parent 66b14ebf7f
commit f4be439002
21 changed files with 7466 additions and 160 deletions

View file

@ -10,6 +10,7 @@ import org.operaton.fitpub.model.entity.User;
import org.operaton.fitpub.repository.ActivityRepository;
import org.operaton.fitpub.repository.UserRepository;
import org.operaton.fitpub.util.FitParser;
import org.operaton.fitpub.util.ParsedActivityData;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
@ -67,7 +68,7 @@ class ActivityImageServiceTest {
assertTrue(fitFileData.length > 0, "FIT file should not be empty");
// Parse FIT file
FitParser.ParsedFitData parsedData = fitParser.parse(fitFileData);
ParsedActivityData parsedData = fitParser.parse(fitFileData);
assertNotNull(parsedData);
assertNotNull(parsedData.getStartTime());
assertNotNull(parsedData.getEndTime());
@ -182,7 +183,7 @@ class ActivityImageServiceTest {
/**
* Helper method to convert parsed track points to JSON format.
*/
private String convertTrackPointsToJson(FitParser.ParsedFitData parsedData) throws IOException {
private String convertTrackPointsToJson(ParsedActivityData parsedData) throws IOException {
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
mapper.registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule());

View file

@ -19,6 +19,7 @@ import org.operaton.fitpub.repository.ActivityMetricsRepository;
import org.operaton.fitpub.repository.ActivityRepository;
import org.operaton.fitpub.util.FitFileValidator;
import org.operaton.fitpub.util.FitParser;
import org.operaton.fitpub.util.ParsedActivityData;
import org.operaton.fitpub.util.TrackSimplifier;
import org.springframework.mock.web.MockMultipartFile;
@ -81,7 +82,7 @@ class FitFileServiceTest {
private UUID testUserId;
private MockMultipartFile testFile;
private FitParser.ParsedFitData testParsedData;
private ParsedActivityData testParsedData;
@BeforeEach
void setUp() {
@ -512,8 +513,8 @@ class FitFileServiceTest {
/**
* Creates test parsed FIT data with realistic values.
*/
private FitParser.ParsedFitData createTestParsedData() {
FitParser.ParsedFitData data = new FitParser.ParsedFitData();
private ParsedActivityData createTestParsedData() {
ParsedActivityData data = new ParsedActivityData();
LocalDateTime startTime = LocalDateTime.of(2024, 1, 15, 8, 0, 0);
data.setStartTime(startTime);
@ -525,10 +526,10 @@ class FitFileServiceTest {
data.setElevationLoss(BigDecimal.valueOf(95.0));
// Add test track points
List<FitParser.TrackPointData> trackPoints = new ArrayList<>();
List<ParsedActivityData.TrackPointData> trackPoints = new ArrayList<>();
for (int i = 0; i < 10; i++) {
FitParser.TrackPointData tp = new FitParser.TrackPointData();
ParsedActivityData.TrackPointData tp = new ParsedActivityData.TrackPointData();
tp.setTimestamp(startTime.plusMinutes(i * 3));
tp.setLatitude(47.0 + i * 0.001);
tp.setLongitude(8.0 + i * 0.001);
@ -541,7 +542,7 @@ class FitFileServiceTest {
data.setTrackPoints(trackPoints);
// Add test metrics
FitParser.ActivityMetricsData metrics = new FitParser.ActivityMetricsData();
ParsedActivityData.ActivityMetricsData metrics = new ParsedActivityData.ActivityMetricsData();
metrics.setAverageSpeed(BigDecimal.valueOf(10.0));
metrics.setMaxSpeed(BigDecimal.valueOf(15.0));
metrics.setAverageHeartRate(150);

View file

@ -280,8 +280,9 @@ class TrainingLoadServiceTest {
// Given
int days = 30;
LocalDate startDate = LocalDate.now().minusDays(days - 1);
LocalDate recentDate = LocalDate.now().minusDays(5); // Use a date within the last 30 days
List<TrainingLoad> existingLoad = List.of(
createTrainingLoad(userId, testDate, BigDecimal.valueOf(100.0))
createTrainingLoad(userId, recentDate, BigDecimal.valueOf(100.0))
);
when(trainingLoadRepository.findByUserIdSinceDate(userId, startDate))
@ -297,7 +298,7 @@ class TrainingLoadServiceTest {
// Verify that the existing load is included
assertTrue(result.stream().anyMatch(tl ->
tl.getDate().equals(testDate) &&
tl.getDate().equals(recentDate) &&
tl.getTrainingStressScore() != null &&
tl.getTrainingStressScore().compareTo(BigDecimal.valueOf(100.0)) == 0
));

View file

@ -46,7 +46,7 @@ class FitParserIntegrationTest {
"Real FIT file should pass validation");
// Parse the file
FitParser.ParsedFitData parsedData = assertDoesNotThrow(
ParsedActivityData parsedData = assertDoesNotThrow(
() -> parser.parse(fileData),
"Real FIT file should parse without errors"
);
@ -93,7 +93,7 @@ class FitParserIntegrationTest {
assertTrue(parsedData.getTrackPoints().size() > 0, "Should have at least one track point");
// Verify track point data quality
FitParser.TrackPointData firstPoint = parsedData.getTrackPoints().get(0);
ParsedActivityData.TrackPointData firstPoint = parsedData.getTrackPoints().get(0);
assertNotNull(firstPoint, "First track point should not be null");
assertNotEquals(0.0, firstPoint.getLatitude(), "Latitude should be set");
assertNotEquals(0.0, firstPoint.getLongitude(), "Longitude should be set");
@ -116,7 +116,7 @@ class FitParserIntegrationTest {
// Verify metrics if present
if (parsedData.getMetrics() != null) {
FitParser.ActivityMetricsData metrics = parsedData.getMetrics();
ParsedActivityData.ActivityMetricsData metrics = parsedData.getMetrics();
log.info("Metrics:");
if (metrics.getAverageSpeed() != null) {
@ -159,7 +159,7 @@ class FitParserIntegrationTest {
inputStream.close();
// Parse the file
FitParser.ParsedFitData parsedData = parser.parse(fileData);
ParsedActivityData parsedData = parser.parse(fileData);
// Test converting to entity structures
Activity.ActivityType activityType = parsedData.getActivityType();
@ -167,7 +167,7 @@ class FitParserIntegrationTest {
// Verify we can convert track points to entities
if (!parsedData.getTrackPoints().isEmpty()) {
FitParser.TrackPointData trackPointData = parsedData.getTrackPoints().get(0);
ParsedActivityData.TrackPointData trackPointData = parsedData.getTrackPoints().get(0);
// Test geometry creation
assertDoesNotThrow(() -> trackPointData.toGeometry(),
@ -209,13 +209,13 @@ class FitParserIntegrationTest {
byte[] fileData = inputStream.readAllBytes();
inputStream.close();
FitParser.ParsedFitData parsedData = parser.parse(fileData);
ParsedActivityData parsedData = parser.parse(fileData);
// Verify track points are in chronological order
if (parsedData.getTrackPoints().size() > 1) {
for (int i = 0; i < parsedData.getTrackPoints().size() - 1; i++) {
FitParser.TrackPointData current = parsedData.getTrackPoints().get(i);
FitParser.TrackPointData next = parsedData.getTrackPoints().get(i + 1);
ParsedActivityData.TrackPointData current = parsedData.getTrackPoints().get(i);
ParsedActivityData.TrackPointData next = parsedData.getTrackPoints().get(i + 1);
if (current.getTimestamp() != null && next.getTimestamp() != null) {
assertTrue(

View file

@ -0,0 +1,427 @@
package org.operaton.fitpub.util;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import org.operaton.fitpub.model.entity.Activity;
import org.operaton.fitpub.util.ParsedActivityData.TrackPointData;
import org.operaton.fitpub.util.ParsedActivityData.ActivityMetricsData;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;
/**
* Integration test for GpxParser using a real GPX file.
* Tests parsing of GPX files exported from Strava with Garmin extensions.
*/
@Slf4j
class GpxParserIntegrationTest {
private GpxParser parser;
private GpxFileValidator validator;
private SpeedSmoother speedSmoother;
@BeforeEach
void setUp() {
speedSmoother = new SpeedSmoother();
parser = new GpxParser(speedSmoother);
validator = new GpxFileValidator();
}
@Test
@DisplayName("Should successfully parse real GPX file from test resources")
void testParseRealGpxFile() throws IOException {
// Load the real GPX file from test resources
String gpxFileName = "/7410863774.gpx";
InputStream inputStream = getClass().getResourceAsStream(gpxFileName);
assertNotNull(inputStream, "GPX file should exist in test resources: " + gpxFileName);
// Read file into byte array
byte[] fileData = inputStream.readAllBytes();
inputStream.close();
// Validate the file
assertDoesNotThrow(() -> validator.validate(fileData),
"Real GPX file should pass validation");
// Parse the file
ParsedActivityData parsedData = assertDoesNotThrow(
() -> parser.parse(fileData),
"Real GPX file should parse without errors"
);
// Verify parsed data structure
assertNotNull(parsedData, "Parsed data should not be null");
assertEquals("GPX", parsedData.getSourceFormat(), "Source format should be GPX");
// Verify track points
assertNotNull(parsedData.getTrackPoints(), "Track points should not be null");
assertFalse(parsedData.getTrackPoints().isEmpty(), "Track points should not be empty");
log.info("Successfully parsed real GPX file:");
log.info(" Track points: {}", parsedData.getTrackPoints().size());
log.info(" Activity type: {}", parsedData.getActivityType());
if (parsedData.getStartTime() != null) {
log.info(" Start time: {}", parsedData.getStartTime());
}
if (parsedData.getEndTime() != null) {
log.info(" End time: {}", parsedData.getEndTime());
}
if (parsedData.getTotalDistance() != null) {
log.info(" Total distance: {} meters", parsedData.getTotalDistance());
}
if (parsedData.getTotalDuration() != null) {
long minutes = parsedData.getTotalDuration().toMinutes();
long seconds = parsedData.getTotalDuration().getSeconds() % 60;
log.info(" Total duration: {}m {}s", minutes, seconds);
}
if (parsedData.getElevationGain() != null) {
log.info(" Elevation gain: {} meters", parsedData.getElevationGain());
}
if (parsedData.getElevationLoss() != null) {
log.info(" Elevation loss: {} meters", parsedData.getElevationLoss());
}
if (parsedData.getTimezone() != null) {
log.info(" Timezone: {}", parsedData.getTimezone());
}
// Verify at least some basic data
assertNotNull(parsedData.getActivityType(), "Activity type should be determined");
assertEquals(Activity.ActivityType.RUN, parsedData.getActivityType(),
"Activity type should be RUN (from GPX <type>running</type>)");
assertTrue(parsedData.getTrackPoints().size() > 0, "Should have at least one track point");
// Verify track point data quality
TrackPointData firstPoint = parsedData.getTrackPoints().get(0);
assertNotNull(firstPoint, "First track point should not be null");
assertNotEquals(0.0, firstPoint.getLatitude(), "Latitude should be set");
assertNotEquals(0.0, firstPoint.getLongitude(), "Longitude should be set");
// Verify GPS coordinates are in valid range
assertTrue(firstPoint.getLatitude() >= -90 && firstPoint.getLatitude() <= 90,
"Latitude should be in valid range (-90 to 90)");
assertTrue(firstPoint.getLongitude() >= -180 && firstPoint.getLongitude() <= 180,
"Longitude should be in valid range (-180 to 180)");
log.info(" First point: lat={}, lon={}", firstPoint.getLatitude(), firstPoint.getLongitude());
if (firstPoint.getElevation() != null) {
log.info(" First point elevation: {} meters", firstPoint.getElevation());
}
if (firstPoint.getHeartRate() != null) {
log.info(" First point heart rate: {} bpm", firstPoint.getHeartRate());
}
// Verify calculated metrics (GPX doesn't have session summaries, so we calculate them)
if (parsedData.getMetrics() != null) {
ActivityMetricsData metrics = parsedData.getMetrics();
log.info("Calculated Metrics:");
if (metrics.getAverageSpeed() != null) {
log.info(" Average speed: {} km/h", metrics.getAverageSpeed());
assertTrue(metrics.getAverageSpeed().compareTo(BigDecimal.ZERO) > 0,
"Average speed should be positive");
}
if (metrics.getMaxSpeed() != null) {
log.info(" Max speed: {} km/h", metrics.getMaxSpeed());
assertTrue(metrics.getMaxSpeed().compareTo(BigDecimal.ZERO) > 0,
"Max speed should be positive");
}
if (metrics.getAverageHeartRate() != null) {
log.info(" Average heart rate: {} bpm", metrics.getAverageHeartRate());
assertTrue(metrics.getAverageHeartRate() > 0 && metrics.getAverageHeartRate() < 220,
"Average heart rate should be in reasonable range");
}
if (metrics.getMaxHeartRate() != null) {
log.info(" Max heart rate: {} bpm", metrics.getMaxHeartRate());
assertTrue(metrics.getMaxHeartRate() > 0 && metrics.getMaxHeartRate() < 220,
"Max heart rate should be in reasonable range");
}
if (metrics.getMinElevation() != null) {
log.info(" Min elevation: {} meters", metrics.getMinElevation());
}
if (metrics.getMaxElevation() != null) {
log.info(" Max elevation: {} meters", metrics.getMaxElevation());
}
if (metrics.getMovingTime() != null) {
log.info(" Moving time: {} seconds", metrics.getMovingTime().getSeconds());
}
if (metrics.getStoppedTime() != null) {
log.info(" Stopped time: {} seconds", metrics.getStoppedTime().getSeconds());
}
}
}
@Test
@DisplayName("Should extract heart rate data from Garmin extensions")
void testExtractHeartRateFromExtensions() throws IOException {
// Load the real GPX file
String gpxFileName = "/7410863774.gpx";
InputStream inputStream = getClass().getResourceAsStream(gpxFileName);
byte[] fileData = inputStream.readAllBytes();
inputStream.close();
// Parse the file
ParsedActivityData parsedData = parser.parse(fileData);
// Verify heart rate data is extracted from extensions
long pointsWithHeartRate = parsedData.getTrackPoints().stream()
.filter(tp -> tp.getHeartRate() != null)
.count();
assertTrue(pointsWithHeartRate > 0,
"Should have extracted heart rate data from Garmin TrackPointExtension");
log.info("Points with heart rate data: {} out of {}",
pointsWithHeartRate, parsedData.getTrackPoints().size());
// Verify first point has heart rate
TrackPointData firstPoint = parsedData.getTrackPoints().get(0);
assertNotNull(firstPoint.getHeartRate(),
"First point should have heart rate from extension");
assertTrue(firstPoint.getHeartRate() > 0 && firstPoint.getHeartRate() < 220,
"Heart rate should be in reasonable range (0-220 bpm)");
log.info("First point heart rate: {} bpm", firstPoint.getHeartRate());
}
@Test
@DisplayName("Should calculate distance from GPS coordinates using Haversine formula")
void testCalculateDistanceFromCoordinates() throws IOException {
// Load the real GPX file
String gpxFileName = "/7410863774.gpx";
InputStream inputStream = getClass().getResourceAsStream(gpxFileName);
byte[] fileData = inputStream.readAllBytes();
inputStream.close();
ParsedActivityData parsedData = parser.parse(fileData);
// Verify total distance was calculated
assertNotNull(parsedData.getTotalDistance(), "Total distance should be calculated");
assertTrue(parsedData.getTotalDistance().compareTo(BigDecimal.ZERO) > 0,
"Total distance should be positive");
log.info("Calculated total distance: {} meters", parsedData.getTotalDistance());
// Verify distance is reasonable for a running activity
// (GPX files don't have session summaries, so we calculate from track points)
double distanceKm = parsedData.getTotalDistance().doubleValue() / 1000.0;
assertTrue(distanceKm > 0 && distanceKm < 100,
"Distance should be reasonable for a running activity");
log.info("Distance in km: {}", distanceKm);
}
@Test
@DisplayName("Should calculate elevation gain and loss from track points")
void testCalculateElevationMetrics() throws IOException {
// Load the real GPX file
String gpxFileName = "/7410863774.gpx";
InputStream inputStream = getClass().getResourceAsStream(gpxFileName);
byte[] fileData = inputStream.readAllBytes();
inputStream.close();
ParsedActivityData parsedData = parser.parse(fileData);
// Verify elevation metrics were calculated
assertNotNull(parsedData.getElevationGain(), "Elevation gain should be calculated");
assertNotNull(parsedData.getElevationLoss(), "Elevation loss should be calculated");
// Elevation gain/loss should be non-negative
assertTrue(parsedData.getElevationGain().compareTo(BigDecimal.ZERO) >= 0,
"Elevation gain should be non-negative");
assertTrue(parsedData.getElevationLoss().compareTo(BigDecimal.ZERO) >= 0,
"Elevation loss should be non-negative");
log.info("Calculated elevation gain: {} meters", parsedData.getElevationGain());
log.info("Calculated elevation loss: {} meters", parsedData.getElevationLoss());
// Verify min/max elevation in metrics
if (parsedData.getMetrics() != null) {
BigDecimal minElev = parsedData.getMetrics().getMinElevation();
BigDecimal maxElev = parsedData.getMetrics().getMaxElevation();
if (minElev != null && maxElev != null) {
assertTrue(maxElev.compareTo(minElev) >= 0,
"Max elevation should be >= min elevation");
log.info("Elevation range: {} - {} meters", minElev, maxElev);
}
}
}
@Test
@DisplayName("Should validate real GPX file successfully")
void testValidateRealGpxFile() throws IOException {
// Load the real GPX file
String gpxFileName = "/7410863774.gpx";
InputStream inputStream = getClass().getResourceAsStream(gpxFileName);
byte[] fileData = inputStream.readAllBytes();
inputStream.close();
// Should pass all validation checks
assertDoesNotThrow(() -> validator.validate(fileData),
"Real GPX file should pass validation");
// File should have valid extension
assertTrue(validator.hasValidExtension(gpxFileName),
"File should have valid .gpx extension");
}
@Test
@DisplayName("Should handle track points in chronological order")
void testTrackPointsChronologicalOrder() throws IOException {
// Load the real GPX file
String gpxFileName = "/7410863774.gpx";
InputStream inputStream = getClass().getResourceAsStream(gpxFileName);
byte[] fileData = inputStream.readAllBytes();
inputStream.close();
ParsedActivityData parsedData = parser.parse(fileData);
// Verify track points are in chronological order
if (parsedData.getTrackPoints().size() > 1) {
for (int i = 0; i < parsedData.getTrackPoints().size() - 1; i++) {
TrackPointData current = parsedData.getTrackPoints().get(i);
TrackPointData next = parsedData.getTrackPoints().get(i + 1);
if (current.getTimestamp() != null && next.getTimestamp() != null) {
assertTrue(
!current.getTimestamp().isAfter(next.getTimestamp()),
"Track points should be in chronological order at index " + i
);
}
}
log.info("Track points are in chronological order");
log.info(" First timestamp: {}", parsedData.getTrackPoints().get(0).getTimestamp());
log.info(" Last timestamp: {}",
parsedData.getTrackPoints().get(parsedData.getTrackPoints().size() - 1).getTimestamp());
}
}
@Test
@DisplayName("Should calculate speed from consecutive GPS points")
void testSpeedCalculation() throws IOException {
// Load the real GPX file
String gpxFileName = "/7410863774.gpx";
InputStream inputStream = getClass().getResourceAsStream(gpxFileName);
byte[] fileData = inputStream.readAllBytes();
inputStream.close();
ParsedActivityData parsedData = parser.parse(fileData);
// Verify speed was calculated for track points
long pointsWithSpeed = parsedData.getTrackPoints().stream()
.filter(tp -> tp.getSpeed() != null)
.count();
assertTrue(pointsWithSpeed > 0,
"Should have calculated speed for track points");
log.info("Points with calculated speed: {} out of {}",
pointsWithSpeed, parsedData.getTrackPoints().size());
// Verify speeds are reasonable for running
for (TrackPointData point : parsedData.getTrackPoints()) {
if (point.getSpeed() != null) {
double speedKmh = point.getSpeed().doubleValue();
assertTrue(speedKmh >= 0 && speedKmh < 50,
"Running speed should be reasonable (0-50 km/h)");
}
}
}
@Test
@DisplayName("Should extract complete activity data from real GPX file")
void testExtractCompleteActivityData() throws IOException {
// Load the real GPX file
String gpxFileName = "/7410863774.gpx";
InputStream inputStream = getClass().getResourceAsStream(gpxFileName);
byte[] fileData = inputStream.readAllBytes();
inputStream.close();
// Parse the file
ParsedActivityData parsedData = parser.parse(fileData);
// Test converting to entity structures
Activity.ActivityType activityType = parsedData.getActivityType();
assertNotNull(activityType, "Activity type should be extracted");
assertEquals(Activity.ActivityType.RUN, activityType,
"Activity should be detected as RUN from GPX <type>running</type>");
// Verify we can convert track points to entities
if (!parsedData.getTrackPoints().isEmpty()) {
TrackPointData trackPointData = parsedData.getTrackPoints().get(0);
// Test geometry creation
assertDoesNotThrow(() -> trackPointData.toGeometry(),
"Should be able to create Point geometry from track point");
var point = trackPointData.toGeometry();
assertNotNull(point, "Point geometry should not be null");
assertEquals(trackPointData.getLongitude(), point.getX(), 0.0001,
"Point X coordinate should match longitude");
assertEquals(trackPointData.getLatitude(), point.getY(), 0.0001,
"Point Y coordinate should match latitude");
}
// Verify timezone was determined from GPS coordinates
assertNotNull(parsedData.getTimezone(), "Timezone should be determined from GPS");
assertFalse(parsedData.getTimezone().isEmpty(), "Timezone should not be empty");
log.info("Determined timezone: {}", parsedData.getTimezone());
// Verify start and end times
assertNotNull(parsedData.getStartTime(), "Start time should be set");
assertNotNull(parsedData.getEndTime(), "End time should be set");
assertTrue(!parsedData.getStartTime().isAfter(parsedData.getEndTime()),
"Start time should be before or equal to end time");
// Verify total duration
assertNotNull(parsedData.getTotalDuration(), "Total duration should be calculated");
assertTrue(parsedData.getTotalDuration().getSeconds() > 0,
"Total duration should be positive");
}
@Test
@DisplayName("Should apply speed smoothing to remove GPS artifacts")
void testSpeedSmoothing() throws IOException {
// Load the real GPX file
String gpxFileName = "/7410863774.gpx";
InputStream inputStream = getClass().getResourceAsStream(gpxFileName);
byte[] fileData = inputStream.readAllBytes();
inputStream.close();
ParsedActivityData parsedData = parser.parse(fileData);
// Verify speed smoothing was applied (max speed should be reasonable after smoothing)
if (parsedData.getMetrics() != null && parsedData.getMetrics().getMaxSpeed() != null) {
double maxSpeedKmh = parsedData.getMetrics().getMaxSpeed().doubleValue();
// For running, max speed should be reasonable after smoothing (typically < 30 km/h)
assertTrue(maxSpeedKmh > 0 && maxSpeedKmh < 30,
"Max speed should be reasonable for running after smoothing: " + maxSpeedKmh);
log.info("Max speed after smoothing: {} km/h", maxSpeedKmh);
}
}
}