trails/packages/gpx/src/geom.test.ts
Ullrich Schäfer 99278fd305
feat(gpx): lenient point parsing + route (<rte>) support
Task groups 1–2 of gpx-parser-robustness. The parser trusted its input:
`parseFloat(attr ?? "0")` turned a missing lat/lon into a 0,0 Null Island
point (which passes range validation) and garbage into NaN that poisoned
distance and gain/loss totals; `<rte>`/`rtept` files (Garmin courses, many
exporters) parsed to zero track points and were rejected.

- `parsePoint`: skip a trkpt/rtept whose lat/lon is missing or non-finite
  (no more 0,0 default); a non-finite `<ele>` becomes `undefined` so it
  never leaks NaN into totals. Parsing stays parseFloat-lenient (trailing
  junk like `471.0m` still accepted), gated by Number.isFinite.
- Drop segments left with fewer than 2 points (render nothing / break
  distance math).
- Parse `<rte>` as track segments appended after `<trk>` segments, rtept
  handled identically — route-only files now import.

No GpxData shape change; well-formed files parse identically. Updated the
geom single-point test to the new drop-invariant.

Verified: gpx typecheck + lint clean, 76/76 tests pass, journal + planner
typecheck unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 00:06:35 +02:00

65 lines
2.6 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { parseGpxAsync } from "./parse.ts";
const sampleGpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1">
<trk>
<trkseg>
<trkpt lat="52.52" lon="13.405"><ele>34</ele></trkpt>
<trkpt lat="51.05" lon="13.74"><ele>113</ele></trkpt>
<trkpt lat="48.137" lon="11.576"><ele>519</ele></trkpt>
</trkseg>
</trk>
</gpx>`;
describe("GPX to geometry coordinates", () => {
it("extracts [lon, lat] coordinates from track points", async () => {
const gpxData = await parseGpxAsync(sampleGpx);
const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]);
expect(coords).toHaveLength(3);
// GeoJSON order: [lon, lat]
expect(coords[0]).toEqual([13.405, 52.52]);
expect(coords[1]).toEqual([13.74, 51.05]);
expect(coords[2]).toEqual([11.576, 48.137]);
});
it("produces valid GeoJSON LineString", async () => {
const gpxData = await parseGpxAsync(sampleGpx);
const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]);
const geojson = { type: "LineString", coordinates: coords };
expect(geojson.type).toBe("LineString");
expect(geojson.coordinates).toHaveLength(3);
// Verify serialization doesn't throw
expect(() => JSON.stringify(geojson)).not.toThrow();
});
it("handles empty GPX gracefully", async () => {
const emptyGpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1">
<trk><trkseg></trkseg></trk>
</gpx>`;
const gpxData = await parseGpxAsync(emptyGpx);
const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]);
expect(coords).toHaveLength(0);
});
it("handles single point (insufficient for LineString)", async () => {
const singlePointGpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1">
<trk><trkseg>
<trkpt lat="52.52" lon="13.405"><ele>34</ele></trkpt>
</trkseg></trk>
</gpx>`;
const gpxData = await parseGpxAsync(singlePointGpx);
const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]);
// The parser now drops segments left with fewer than 2 points
// (gpx-parser-robustness "Invalid point handling"), so a lone point
// yields no track segment at all — the "insufficient for LineString"
// guard is enforced at the parser boundary rather than left to callers.
expect(coords).toHaveLength(0);
});
});