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>
This commit is contained in:
parent
e2f25e1d27
commit
99278fd305
4 changed files with 184 additions and 26 deletions
|
|
@ -56,7 +56,10 @@ describe("GPX to geometry coordinates", () => {
|
|||
const gpxData = await parseGpxAsync(singlePointGpx);
|
||||
const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]);
|
||||
|
||||
expect(coords).toHaveLength(1);
|
||||
// Caller should check coords.length >= 2 before creating LineString
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -68,3 +68,132 @@ describe("parseGpxAsync", () => {
|
|||
await expect(parseGpxAsync("not xml at all <<<<")).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseGpxAsync — invalid point handling", () => {
|
||||
const gpx = (body: string) =>
|
||||
`<?xml version="1.0" encoding="UTF-8"?>\n<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">${body}</gpx>`;
|
||||
|
||||
it("skips a point with a missing lat/lon instead of defaulting to Null Island", async () => {
|
||||
const result = await parseGpxAsync(
|
||||
gpx(`<trk><trkseg>
|
||||
<trkpt lat="52.52" lon="13.405"><ele>34</ele></trkpt>
|
||||
<trkpt lon="13.74"><ele>113</ele></trkpt>
|
||||
<trkpt lat="48.137" lon="11.576"><ele>519</ele></trkpt>
|
||||
</trkseg></trk>`),
|
||||
);
|
||||
expect(result.tracks[0]).toHaveLength(2);
|
||||
// No 0,0 point leaked in.
|
||||
expect(result.tracks[0]!.some((p) => p.lat === 0 && p.lon === 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("skips a point with garbage coords and keeps distance finite", async () => {
|
||||
const result = await parseGpxAsync(
|
||||
gpx(`<trk><trkseg>
|
||||
<trkpt lat="52.52" lon="13.405"></trkpt>
|
||||
<trkpt lat="abc" lon="13.74"></trkpt>
|
||||
<trkpt lat="48.137" lon="11.576"></trkpt>
|
||||
</trkseg></trk>`),
|
||||
);
|
||||
expect(result.tracks[0]).toHaveLength(2);
|
||||
expect(Number.isFinite(result.distance)).toBe(true);
|
||||
expect(result.distance).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("treats unparseable <ele> as undefined so gain/loss stay finite", async () => {
|
||||
const result = await parseGpxAsync(
|
||||
gpx(`<trk><trkseg>
|
||||
<trkpt lat="52.52" lon="13.405"><ele>34</ele></trkpt>
|
||||
<trkpt lat="51.05" lon="13.74"><ele>NaN</ele></trkpt>
|
||||
<trkpt lat="48.137" lon="11.576"><ele>519</ele></trkpt>
|
||||
</trkseg></trk>`),
|
||||
);
|
||||
expect(result.tracks[0]![1]!.ele).toBeUndefined();
|
||||
expect(Number.isFinite(result.elevation.gain)).toBe(true);
|
||||
expect(Number.isFinite(result.elevation.loss)).toBe(true);
|
||||
});
|
||||
|
||||
it("tolerates trailing junk on a numeric value (parseFloat lenience)", async () => {
|
||||
const result = await parseGpxAsync(
|
||||
gpx(`<trk><trkseg>
|
||||
<trkpt lat="52.52" lon="13.405"><ele>471.0m</ele></trkpt>
|
||||
<trkpt lat="48.137" lon="11.576"><ele>519</ele></trkpt>
|
||||
</trkseg></trk>`),
|
||||
);
|
||||
expect(result.tracks[0]![0]!.ele).toBe(471);
|
||||
});
|
||||
|
||||
it("drops a segment left with fewer than 2 points", async () => {
|
||||
const result = await parseGpxAsync(
|
||||
gpx(`<trk>
|
||||
<trkseg><trkpt lat="52.52" lon="13.405"></trkpt></trkseg>
|
||||
<trkseg>
|
||||
<trkpt lat="52.52" lon="13.405"></trkpt>
|
||||
<trkpt lat="48.137" lon="11.576"></trkpt>
|
||||
</trkseg>
|
||||
</trk>`),
|
||||
);
|
||||
expect(result.tracks).toHaveLength(1);
|
||||
expect(result.tracks[0]).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("leaves a well-formed file's output unchanged", async () => {
|
||||
const result = await parseGpxAsync(sampleGpx);
|
||||
expect(result.tracks).toEqual([
|
||||
[
|
||||
{ lat: 52.52, lon: 13.405, ele: 34, time: undefined },
|
||||
{ lat: 51.05, lon: 13.74, ele: 113, time: undefined },
|
||||
{ lat: 48.137, lon: 11.576, ele: 519, time: undefined },
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseGpxAsync — route (<rte>) support", () => {
|
||||
const gpx = (body: string) =>
|
||||
`<?xml version="1.0" encoding="UTF-8"?>\n<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">${body}</gpx>`;
|
||||
|
||||
it("parses a route-only file into one segment", async () => {
|
||||
const result = await parseGpxAsync(
|
||||
gpx(`<rte><name>My Course</name>
|
||||
<rtept lat="52.52" lon="13.405"><ele>34</ele></rtept>
|
||||
<rtept lat="51.05" lon="13.74"><ele>113</ele></rtept>
|
||||
<rtept lat="48.137" lon="11.576"><ele>519</ele></rtept>
|
||||
</rte>`),
|
||||
);
|
||||
expect(result.tracks).toHaveLength(1);
|
||||
expect(result.tracks[0]).toHaveLength(3);
|
||||
expect(result.distance).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("preserves rtept ele and time", async () => {
|
||||
const result = await parseGpxAsync(
|
||||
gpx(`<rte>
|
||||
<rtept lat="52.52" lon="13.405"><ele>34</ele><time>2026-01-01T10:00:00Z</time></rtept>
|
||||
<rtept lat="48.137" lon="11.576"><ele>519</ele><time>2026-01-01T11:00:00Z</time></rtept>
|
||||
</rte>`),
|
||||
);
|
||||
expect(result.tracks[0]![0]).toEqual({
|
||||
lat: 52.52,
|
||||
lon: 13.405,
|
||||
ele: 34,
|
||||
time: "2026-01-01T10:00:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("appends route segments after track segments", async () => {
|
||||
const result = await parseGpxAsync(
|
||||
gpx(`<trk><trkseg>
|
||||
<trkpt lat="52.52" lon="13.405"></trkpt>
|
||||
<trkpt lat="51.05" lon="13.74"></trkpt>
|
||||
</trkseg></trk>
|
||||
<rte>
|
||||
<rtept lat="10.0" lon="10.0"></rtept>
|
||||
<rtept lat="11.0" lon="11.0"></rtept>
|
||||
</rte>`),
|
||||
);
|
||||
expect(result.tracks).toHaveLength(2);
|
||||
// Track first, route second.
|
||||
expect(result.tracks[0]![0]!.lat).toBe(52.52);
|
||||
expect(result.tracks[1]![0]!.lat).toBe(10.0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -69,28 +69,54 @@ function parseWaypoints(doc: Document): Waypoint[] {
|
|||
});
|
||||
}
|
||||
|
||||
function parseTracks(doc: Document): TrackPoint[][] {
|
||||
const tracks: TrackPoint[][] = [];
|
||||
const trksegs = doc.querySelectorAll("trk > trkseg");
|
||||
/**
|
||||
* Parse one `trkpt`/`rtept` element into a TrackPoint, or null if it is
|
||||
* unusable. Parsing stays `parseFloat`-lenient (accepts leading `+`,
|
||||
* tolerates trailing junk like `471.0m` that real exporters emit), but a
|
||||
* point whose `lat`/`lon` is missing or does not parse to a finite number
|
||||
* is skipped rather than defaulted to `0,0` (which would land on Null
|
||||
* Island and pass range validation) — spec: gpx-parser-robustness
|
||||
* "Invalid point handling". A non-finite `<ele>` becomes `undefined` (the
|
||||
* existing "no elevation" representation) so it never poisons gain/loss
|
||||
* totals with `NaN`.
|
||||
*/
|
||||
function parsePoint(pt: Element): TrackPoint | null {
|
||||
const lat = parseFloat(pt.getAttribute("lat") ?? "");
|
||||
const lon = parseFloat(pt.getAttribute("lon") ?? "");
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
const eleText = pt.querySelector("ele")?.textContent;
|
||||
const ele = eleText != null ? parseFloat(eleText) : NaN;
|
||||
const time = pt.querySelector("time")?.textContent ?? undefined;
|
||||
return { lat, lon, ele: Number.isFinite(ele) ? ele : undefined, time };
|
||||
}
|
||||
|
||||
for (const seg of trksegs) {
|
||||
const points: TrackPoint[] = [];
|
||||
for (const pt of seg.querySelectorAll("trkpt")) {
|
||||
const lat = parseFloat(pt.getAttribute("lat") ?? "0");
|
||||
const lon = parseFloat(pt.getAttribute("lon") ?? "0");
|
||||
const eleText = pt.querySelector("ele")?.textContent;
|
||||
const time = pt.querySelector("time")?.textContent ?? undefined;
|
||||
points.push({
|
||||
lat,
|
||||
lon,
|
||||
ele: eleText ? parseFloat(eleText) : undefined,
|
||||
time,
|
||||
});
|
||||
}
|
||||
tracks.push(points);
|
||||
function parseSegmentPoints(pts: ArrayLike<Element>): TrackPoint[] {
|
||||
const points: TrackPoint[] = [];
|
||||
for (const pt of Array.from(pts)) {
|
||||
const parsed = parsePoint(pt);
|
||||
if (parsed) points.push(parsed);
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
function parseTracks(doc: Document): TrackPoint[][] {
|
||||
const segments: TrackPoint[][] = [];
|
||||
|
||||
// Standard tracks: <trk><trkseg><trkpt>.
|
||||
for (const seg of Array.from(doc.querySelectorAll("trk > trkseg"))) {
|
||||
segments.push(parseSegmentPoints(seg.querySelectorAll("trkpt")));
|
||||
}
|
||||
// Routes: <rte><rtept>. Many exporters (Garmin Connect courses,
|
||||
// gpx.studio, planner exports) emit only routes; each becomes one
|
||||
// segment appended after the track segments, with rtept handled
|
||||
// identically to trkpt — spec: gpx-parser-robustness "Route support".
|
||||
for (const rte of Array.from(doc.querySelectorAll("rte"))) {
|
||||
segments.push(parseSegmentPoints(rte.querySelectorAll("rtept")));
|
||||
}
|
||||
|
||||
return tracks;
|
||||
// Drop empty or single-point segments: they render nothing and break
|
||||
// distance-math assumptions (spec: "Invalid point handling").
|
||||
return segments.filter((seg) => seg.length >= 2);
|
||||
}
|
||||
|
||||
function parseNoGoAreas(doc: Document): NoGoArea[] {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue