@sports-alliance/sports-lib
Version:
A Library to for importing / exporting and processing GPX, TCX, FIT and JSON files from services such as Strava, Movescount, Garmin, Polar etc
254 lines (253 loc) • 15.4 kB
JavaScript
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
Object.defineProperty(exports, "__esModule", { value: true });
const fs_1 = require("fs");
const activity_types_1 = require("../../../../activities/activity.types");
const data_ascent_1 = require("../../../../data/data.ascent");
const data_descent_1 = require("../../../../data/data.descent");
const data_distance_1 = require("../../../../data/data.distance");
const data_gnss_distance_miles_1 = require("../../../../data/data.gnss-distance-miles");
const data_grade_1 = require("../../../../data/data.grade");
const data_grade_avg_1 = require("../../../../data/data.grade-avg");
const data_grade_max_1 = require("../../../../data/data.grade-max");
const data_grade_min_1 = require("../../../../data/data.grade-min");
const data_latitude_degrees_1 = require("../../../../data/data.latitude-degrees");
const data_longitude_degrees_1 = require("../../../../data/data.longitude-degrees");
const data_speed_1 = require("../../../../data/data.speed");
const parsing_event_lib_error_1 = require("../../../../errors/parsing-event-lib.error");
const file_type_enum_1 = require("../../../../events/adapters/file-type.enum");
const index_1 = require("../../../../index");
const route_parsing_options_1 = require("../../../route-parsing-options");
const importer_route_fit_1 = require("./importer.route.fit");
describe('RouteImporterFIT', () => {
const importerWithPrivateParse = importer_route_fit_1.RouteImporterFIT;
const readFixture = (path) => {
const buffer = (0, fs_1.readFileSync)(path);
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
};
it('should import a FIT course as a first-class route file', () => __awaiter(void 0, void 0, void 0, function* () {
var _a;
const routeFile = yield importer_route_fit_1.RouteImporterFIT.getFromArrayBuffer(readFixture(`${process.cwd()}/samples/fit-routes/coursepointer-cptr002.fit`));
const route = routeFile.getFirstRoute();
const waypoints = routeFile.getWaypoints();
expect(routeFile.name).toBe('cptr002');
expect(routeFile.srcFileType).toBe(file_type_enum_1.FileType.FIT);
expect((_a = routeFile.createdAt) === null || _a === void 0 ? void 0 : _a.toISOString()).toBe('2025-05-06T15:56:24.000Z');
expect(routeFile.getRoutes()).toHaveLength(1);
expect(route.activityType).toBe(activity_types_1.ActivityTypes.Cycling);
expect(route.getPointCount()).toBe(666);
expect(route.hasStreamData(data_latitude_degrees_1.DataLatitudeDegrees.type)).toBe(true);
expect(route.hasStreamData(data_longitude_degrees_1.DataLongitudeDegrees.type)).toBe(true);
expect(route.hasStreamData(data_distance_1.DataDistance.type)).toBe(true);
expect(route.hasStreamData(data_grade_1.DataGrade.type)).toBe(true);
expect(route.getStat(data_distance_1.DataDistance.type).getValue()).toBeCloseTo(23187.9, 1);
expect(route.getStreamData(data_distance_1.DataDistance.type)[0]).toBe(0);
expect(route.getStreamData(data_distance_1.DataDistance.type)[route.getPointCount() - 1]).toBeCloseTo(23187.75, 1);
expect(routeFile.getStat(data_distance_1.DataDistance.type).getValue()).toEqual(route.getStat(data_distance_1.DataDistance.type).getValue());
expect(routeFile.getStat(data_ascent_1.DataAscent.type).getValue()).toEqual(route.getStat(data_ascent_1.DataAscent.type).getValue());
expect(routeFile.getStat(data_grade_min_1.DataGradeMin.type).getValue()).toEqual(route.getStat(data_grade_min_1.DataGradeMin.type).getValue());
expect(routeFile.getStat(data_grade_max_1.DataGradeMax.type).getValue()).toEqual(route.getStat(data_grade_max_1.DataGradeMax.type).getValue());
expect(routeFile.getStat(data_grade_avg_1.DataGradeAvg.type).getValue()).toEqual(route.getStat(data_grade_avg_1.DataGradeAvg.type).getValue());
expect(waypoints).toHaveLength(2);
expect(waypoints[0]).toMatchObject({
name: 'First here cour',
type: 'generic',
distance: 8813,
routeIndex: 0
});
expect(waypoints[0].routePointIndex).toBeGreaterThan(0);
expect(waypoints[1]).toMatchObject({
name: 'Point2',
routeIndex: 0
});
expect(waypoints[1].distance).toBeCloseTo(18349.29, 1);
expect(waypoints[1].routePointIndex).toBeGreaterThan(waypoints[0].routePointIndex);
}));
it('should expose FIT course import through the public SportsLib route API', () => __awaiter(void 0, void 0, void 0, function* () {
const routeFile = yield index_1.SportsLib.importRoutesFromFit(readFixture(`${process.cwd()}/samples/fit-routes/coursepointer-cptr002.fit`));
expect(routeFile.name).toBe('cptr002');
expect(routeFile.getFirstRoute().getPointCount()).toBe(666);
}));
it('should infer course point distance from geometry when Garmin Connect exports zero distances', () => __awaiter(void 0, void 0, void 0, function* () {
const routeFile = yield importer_route_fit_1.RouteImporterFIT.getFromArrayBuffer(readFixture(`${process.cwd()}/samples/fit-routes/coursepointer-cptr003-connect.fit`));
const waypoints = routeFile.getWaypoints();
expect(waypoints).toHaveLength(4);
expect(waypoints.map(waypoint => waypoint.name)).toEqual(['Russian Ridge', 'Saratoga', 'OLH Start', 'OLH End']);
expect(waypoints.every(waypoint => typeof waypoint.type === 'string')).toBe(true);
waypoints.forEach(waypoint => {
expect(waypoint.distance).toBeGreaterThan(0);
expect(waypoint.routePointIndex).toEqual(expect.any(Number));
});
expect(waypoints[0].distance).toBeCloseTo(23382.02, 1);
expect(waypoints[1].distance).toBeCloseTo(44795.42, 1);
expect(waypoints[2].distance).toBeCloseTo(8879.36, 1);
expect(waypoints[3].distance).toBeCloseTo(14168.11, 1);
}));
it('should support route stream includeTypes for FIT courses', () => __awaiter(void 0, void 0, void 0, function* () {
const routeFile = yield importer_route_fit_1.RouteImporterFIT.getFromArrayBuffer(readFixture(`${process.cwd()}/samples/fit-routes/coursepointer-cptr002.fit`), new route_parsing_options_1.RouteParsingOptions({
streams: { includeTypes: [data_distance_1.DataDistance.type, data_gnss_distance_miles_1.DataGNSSDistanceMiles.type, data_grade_1.DataGrade.type] }
}));
const streamTypes = routeFile
.getFirstRoute()
.getAllStreams()
.map(stream => stream.type);
expect(new Set(streamTypes)).toEqual(new Set([data_distance_1.DataDistance.type, data_gnss_distance_miles_1.DataGNSSDistanceMiles.type, data_grade_1.DataGrade.type]));
}));
it('should reject unsupported activity-only includeTypes for FIT routes', () => __awaiter(void 0, void 0, void 0, function* () {
yield expect(importer_route_fit_1.RouteImporterFIT.getFromArrayBuffer(readFixture(`${process.cwd()}/samples/fit-routes/coursepointer-cptr002.fit`), new route_parsing_options_1.RouteParsingOptions({ streams: { includeTypes: [data_speed_1.DataSpeed.type] } }))).rejects.toThrow('Unsupported route stream includeTypes');
}));
it('should preserve parser-decoded course point types without FIT enum mapping in sports-lib', () => {
const parseSpy = jest.spyOn(importerWithPrivateParse, 'parseFIT').mockResolvedValue({
file_ids: [{ type: 'course', manufacturer: 'garmin', product: 0 }],
course: { name: 'Decoded Course', sport: 'cycling' },
records: [
{ position_lat: 1, position_long: 2, distance: 0, altitude: 10 },
{ position_lat: 1.1, position_long: 2.1, distance: 100, altitude: 12 }
],
course_points: [
{ position_lat: 1, position_long: 2, type: 'rest_area', name: 'Decoded' },
{ position_lat: 1.1, position_long: 2.1, type: 29, name: 'Numeric' }
]
});
return importer_route_fit_1.RouteImporterFIT.getFromArrayBuffer(new ArrayBuffer(0))
.then(routeFile => {
expect(routeFile.getWaypoints().map(waypoint => waypoint.type)).toEqual(['rest_area', '29']);
})
.finally(() => {
parseSpy.mockRestore();
});
});
it('should preserve FIT course summary stats when they are present', () => {
const parseSpy = jest.spyOn(importerWithPrivateParse, 'parseFIT').mockResolvedValue({
file_ids: [{ type: 'course', manufacturer: 'garmin', product: 0 }],
course: {
name: 'Summary Course',
sport: 'cycling',
total_distance: 222,
total_ascent: 77,
total_descent: 33,
avg_grade: 5,
max_pos_grade: 11,
max_neg_grade: -9
},
records: [
{ position_lat: 1, position_long: 2, distance: 0, altitude: 10 },
{ position_lat: 1.1, position_long: 2.1, distance: 100, altitude: 20 }
],
course_points: []
});
return importer_route_fit_1.RouteImporterFIT.getFromArrayBuffer(new ArrayBuffer(0))
.then(routeFile => {
const route = routeFile.getFirstRoute();
expect(route.getStat(data_distance_1.DataDistance.type).getValue()).toBe(222);
expect(route.getStat(data_ascent_1.DataAscent.type).getValue()).toBe(77);
expect(route.getStat(data_descent_1.DataDescent.type).getValue()).toBe(33);
expect(route.getStat(data_grade_avg_1.DataGradeAvg.type).getValue()).toBe(5);
expect(route.getStat(data_grade_max_1.DataGradeMax.type).getValue()).toBe(11);
expect(route.getStat(data_grade_min_1.DataGradeMin.type).getValue()).toBe(-9);
expect(routeFile.getStat(data_distance_1.DataDistance.type).getValue()).toBe(222);
})
.finally(() => {
parseSpy.mockRestore();
});
});
it('should aggregate FIT lap summary stats when course summaries are absent', () => {
const parseSpy = jest.spyOn(importerWithPrivateParse, 'parseFIT').mockResolvedValue({
file_ids: [{ type: 'course', manufacturer: 'garmin', product: 0 }],
course: {
name: 'Lap Summary Course',
sport: 'cycling'
},
laps: [
{
total_distance: 100,
total_ascent: 10,
total_descent: 5,
avg_grade: 2,
max_pos_grade: 8,
max_neg_grade: -3,
records: [
{ position_lat: 1, position_long: 2, distance: 0, altitude: 10 },
{ position_lat: 1.01, position_long: 2.01, distance: 100, altitude: 20 }
]
},
{
total_distance: 300,
total_ascent: 20,
total_descent: 15,
avg_grade: 6,
max_pos_grade: 12,
max_neg_grade: -9,
records: [
{ position_lat: 1.02, position_long: 2.02, distance: 100, altitude: 20 },
{ position_lat: 1.03, position_long: 2.03, distance: 400, altitude: 15 }
]
}
],
course_points: []
});
return importer_route_fit_1.RouteImporterFIT.getFromArrayBuffer(new ArrayBuffer(0))
.then(routeFile => {
const route = routeFile.getFirstRoute();
expect(route.getStat(data_distance_1.DataDistance.type).getValue()).toBe(400);
expect(route.getStat(data_ascent_1.DataAscent.type).getValue()).toBe(30);
expect(route.getStat(data_descent_1.DataDescent.type).getValue()).toBe(20);
expect(route.getStat(data_grade_avg_1.DataGradeAvg.type).getValue()).toBe(5);
expect(route.getStat(data_grade_max_1.DataGradeMax.type).getValue()).toBe(12);
expect(route.getStat(data_grade_min_1.DataGradeMin.type).getValue()).toBe(-9);
expect(routeFile.getStat(data_distance_1.DataDistance.type).getValue()).toBe(400);
})
.finally(() => {
parseSpy.mockRestore();
});
});
it('should use final record distance when FIT lap summary distance coverage is incomplete', () => {
const parseSpy = jest.spyOn(importerWithPrivateParse, 'parseFIT').mockResolvedValue({
file_ids: [{ type: 'course', manufacturer: 'garmin', product: 0 }],
course: {
name: 'Partial Lap Summary Course',
sport: 'cycling'
},
laps: [
{
total_distance: 100,
total_ascent: 10,
records: [
{ position_lat: 1, position_long: 2, distance: 0, altitude: 10 },
{ position_lat: 1.01, position_long: 2.01, distance: 100, altitude: 20 }
]
},
{
records: [
{ position_lat: 1.02, position_long: 2.02, distance: 100, altitude: 20 },
{ position_lat: 1.03, position_long: 2.03, distance: 400, altitude: 40 },
{ position_lat: 1.04, position_long: 2.04, altitude: 40 }
]
}
],
course_points: []
});
return importer_route_fit_1.RouteImporterFIT.getFromArrayBuffer(new ArrayBuffer(0), new route_parsing_options_1.RouteParsingOptions({ streams: { smooth: { altitudeSmooth: false, grade: false, gradeSmooth: false } } }))
.then(routeFile => {
const route = routeFile.getFirstRoute();
expect(route.getStat(data_distance_1.DataDistance.type).getValue()).toBe(400);
expect(routeFile.getStat(data_distance_1.DataDistance.type).getValue()).toBe(400);
expect(route.getStat(data_ascent_1.DataAscent.type).getValue()).toBe(30);
expect(routeFile.getStat(data_ascent_1.DataAscent.type).getValue()).toBe(30);
})
.finally(() => {
parseSpy.mockRestore();
});
});
it('should reject normal activity FIT files', () => __awaiter(void 0, void 0, void 0, function* () {
yield expect(importer_route_fit_1.RouteImporterFIT.getFromArrayBuffer(readFixture(`${process.cwd()}/src/specs/fixtures/runs/fit/2067489619.fit`))).rejects.toBeInstanceOf(parsing_event_lib_error_1.ParsingEventLibError);
}));
});