trails/packages/fit/src/gpx-to-fit-course.test.ts
Ullrich Schäfer dbba1c1607
Fix FIT Course capabilities and lap duration
The Course message advertised capabilities=0x04 (time only), telling
consumers the course had no position data. Wahoo accepted the route
(distance/elevation come from request fields) but rendered an empty
map. Set capabilities to valid|distance|position (0x1A).

Also write a real lap totalElapsedTime/totalTimerTime derived from the
1Hz record timestamps; a zero-duration lap can be rejected as malformed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 10:24:50 +02:00

107 lines
4.2 KiB
TypeScript

import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { describe, expect, it } from "vitest";
import FitParser from "fit-file-parser";
import { parseGpxAsync } from "@trails-cool/gpx";
import { gpxToFitCourse } from "./gpx-to-fit-course.ts";
const FIXTURES_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "../__fixtures__");
async function loadFixture(name: string): Promise<string> {
return readFile(resolve(FIXTURES_DIR, name), "utf8");
}
interface ParsedFit {
records: Array<{ position_lat?: number; position_long?: number; altitude?: number }>;
course?:
| Array<{ name?: string; sport?: string; capabilities?: unknown }>
| { name?: string; sport?: string; capabilities?: unknown };
laps?: Array<{ total_elapsed_time?: number; total_timer_time?: number }>;
}
function decode(bytes: Uint8Array): Promise<ParsedFit> {
return new Promise((res, rej) => {
const parser = new FitParser({ force: true, mode: "list", lengthUnit: "m", speedUnit: "m/s" });
parser.parse(Buffer.from(bytes), (err, data) => {
if (err) rej(new Error(err));
else res(data as unknown as ParsedFit);
});
});
}
const FIXTURES = ["short-flat.gpx", "alpine.gpx", "multi-day.gpx", "single-point.gpx"] as const;
describe("gpxToFitCourse", () => {
for (const fixture of FIXTURES) {
it(`encodes ${fixture} round-trip via fit-file-parser`, async () => {
const gpx = await loadFixture(fixture);
const source = await parseGpxAsync(gpx);
const sourcePoints = source.tracks.flat();
const bytes = await gpxToFitCourse({ gpx, name: `Test ${fixture}` });
expect(bytes).toBeInstanceOf(Uint8Array);
expect(bytes.byteLength).toBeGreaterThan(20);
const parsed = await decode(bytes);
const records = parsed.records ?? [];
expect(records.length).toBe(sourcePoints.length);
for (let i = 0; i < sourcePoints.length; i++) {
const src = sourcePoints[i]!;
const got = records[i]!;
expect(got.position_lat).toBeCloseTo(src.lat, 4);
expect(got.position_long).toBeCloseTo(src.lon, 4);
if (src.ele !== undefined && got.altitude !== undefined) {
expect(Math.abs(got.altitude - src.ele)).toBeLessThan(0.5);
}
}
});
}
it("throws on a GPX with zero track points", async () => {
const gpx = await loadFixture("empty.gpx");
await expect(gpxToFitCourse({ gpx, name: "Empty" })).rejects.toThrow(/zero track points/);
});
it("advertises position+distance course capabilities (not time-only)", async () => {
const gpx = await loadFixture("short-flat.gpx");
const bytes = await gpxToFitCourse({ gpx, name: "Caps", sport: "cycling" });
const parsed = await decode(bytes);
const course = Array.isArray(parsed.course) ? parsed.course[0] : parsed.course;
// fit-file-parser returns capabilities as a single-element array of the raw bitfield.
const caps = course?.capabilities;
const value = Array.isArray(caps) ? Number(caps[0]) : Number(caps);
const VALID = 0x02;
const DISTANCE = 0x08;
const POSITION = 0x10;
const TIME = 0x04;
expect(value & VALID).toBe(VALID);
expect(value & DISTANCE).toBe(DISTANCE);
expect(value & POSITION).toBe(POSITION);
expect(value & TIME).toBe(0);
});
it("writes a non-zero lap elapsed time matching record count", async () => {
const gpx = await loadFixture("short-flat.gpx");
const source = await parseGpxAsync(gpx);
const sourcePoints = source.tracks.flat();
const bytes = await gpxToFitCourse({ gpx, name: "Lap", sport: "cycling" });
const parsed = await decode(bytes);
const lap = parsed.laps?.[0];
expect(lap?.total_elapsed_time).toBe(sourcePoints.length - 1);
expect(lap?.total_timer_time).toBe(sourcePoints.length - 1);
});
it("encodes course name and sport", async () => {
const gpx = await loadFixture("short-flat.gpx");
const bytes = await gpxToFitCourse({ gpx, name: "Loop Test", sport: "cycling" });
const parsed = await decode(bytes);
const course = Array.isArray(parsed.course) ? parsed.course[0] : parsed.course;
expect(course?.name).toBe("Loop Test");
expect(course?.sport).toBe("cycling");
});
});