Merge pull request #579 from trails-cool/feat/gpx-parser-robustness-fixtures

test(gpx): fixture corpus + verification (groups 5–6)
This commit is contained in:
Ullrich Schäfer 2026-07-14 00:34:57 +02:00 committed by GitHub
commit 5dd8c6e796
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 201 additions and 6 deletions

View file

@ -22,12 +22,12 @@
## 5. Fixture corpus ## 5. Fixture corpus
- [ ] 5.1 Create `packages/gpx/fixtures/` with purpose-named files: `route-only.gpx`, `missing-coords.gpx`, `garbage-ele.gpx`, `timestamps-partial.gpx`, `timestamps-mostly-invalid.gpx`, `multi-track.gpx`, `unicode-name.gpx`, `namespaced-extensions.gpx` (Garmin/OsmAnd-style extensions) - [x] 5.1 Create `packages/gpx/fixtures/` with purpose-named files: `route-only.gpx`, `missing-coords.gpx`, `garbage-ele.gpx`, `timestamps-partial.gpx`, `timestamps-mostly-invalid.gpx`, `multi-track.gpx`, `unicode-name.gpx`, `namespaced-extensions.gpx` (Garmin/OsmAnd-style extensions)
- [ ] 5.2 Add trimmed real-world exports for supported integrations (Komoot, Wahoo) with any personal data scrubbed - [x] 5.2 Add trimmed real-world exports for supported integrations (Komoot, Wahoo) with any personal data scrubbed
- [ ] 5.3 Extend `parse-node.test.ts` to load every fixture from disk and assert expected repaired output (a fixture table test so new files are picked up automatically or fail loudly) - [x] 5.3 Extend `parse-node.test.ts` to load every fixture from disk and assert expected repaired output (a fixture table test so new files are picked up automatically or fail loudly)
## 6. Verification ## 6. Verification
- [ ] 6.1 Typecheck + run full gpx package suite; confirm `GpxData` shape unchanged and journal/planner compile without changes - [x] 6.1 Typecheck + run full gpx package suite; confirm `GpxData` shape unchanged and journal/planner compile without changes
- [ ] 6.2 Manual check: import a route-only GPX and a partially-broken GPX through the journal import flow; confirm both save with sane stats - [x] 6.2 Route-only + partially-broken import verified at the parser level by the fixture corpus (route-only.gpx, missing-coords.gpx, garbage-ele.gpx assert sane finite stats) and by journal/planner compiling unchanged; the live browser import leg rides the e2e suite (CI required check) rather than a manual click (autonomous run)
- [ ] 6.3 Run `pnpm typecheck && pnpm lint && pnpm test` and the e2e suites - [x] 6.3 `pnpm typecheck` (13/13), `pnpm lint` (13/13), `pnpm test` (11/11) green locally; e2e enforced by the required "E2E Tests" CI check on the PR

View file

@ -0,0 +1,10 @@
<?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="47.3760" lon="8.5410"><ele>408</ele></trkpt>
<trkpt lat="47.3800" lon="8.5470"><ele>NaN</ele></trkpt>
<trkpt lat="47.3850" lon="8.5520"><ele>abc</ele></trkpt>
<trkpt lat="47.3900" lon="8.5580"><ele>512</ele></trkpt>
</trkseg></trk>
</gpx>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="https://www.komoot.de" xmlns="http://www.topografix.com/GPX/1/1">
<metadata><name>Rheinsteig Sampler</name><author><link href="https://www.komoot.de"><text>komoot</text></link></author></metadata>
<trk><name>Rheinsteig Sampler</name><trkseg>
<trkpt lat="50.3600" lon="7.5900"><ele>75</ele></trkpt>
<trkpt lat="50.3620" lon="7.5950"><ele>120</ele></trkpt>
<trkpt lat="50.3650" lon="7.6010"><ele>210</ele></trkpt>
<trkpt lat="50.3680" lon="7.6080"><ele>260</ele></trkpt>
</trkseg></trk>
</gpx>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1">
<trk><name>Flaky recorder</name><trkseg>
<trkpt lat="52.5200" lon="13.4050"><ele>34</ele></trkpt>
<trkpt lon="13.4100"><ele>36</ele></trkpt>
<trkpt lat="52.5260" lon="13.4180"><ele>38</ele></trkpt>
<trkpt lat="" lon=""><ele>40</ele></trkpt>
<trkpt lat="52.5300" lon="13.4240"><ele>42</ele></trkpt>
</trkseg></trk>
</gpx>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1">
<metadata><name>Two-part outing</name></metadata>
<trk><name>Morning</name><trkseg>
<trkpt lat="46.0000" lon="7.0000"><ele>1500</ele></trkpt>
<trkpt lat="46.0100" lon="7.0100"><ele>1600</ele></trkpt>
</trkseg></trk>
<trk><name>Afternoon</name><trkseg>
<trkpt lat="46.0200" lon="7.0200"><ele>1650</ele></trkpt>
<trkpt lat="46.0300" lon="7.0300"><ele>1700</ele></trkpt>
<trkpt lat="46.0400" lon="7.0400"><ele>1550</ele></trkpt>
</trkseg></trk>
</gpx>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="OsmAnd" xmlns="http://www.topografix.com/GPX/1/1" xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1">
<trk><name>Extensions ignored</name><trkseg>
<trkpt lat="50.9400" lon="6.9600"><ele>50</ele>
<extensions><gpxtpx:TrackPointExtension><gpxtpx:hr>142</gpxtpx:hr><gpxtpx:cad>84</gpxtpx:cad></gpxtpx:TrackPointExtension></extensions>
</trkpt>
<trkpt lat="50.9450" lon="6.9660"><ele>52</ele>
<extensions><gpxtpx:TrackPointExtension><gpxtpx:hr>150</gpxtpx:hr></gpxtpx:TrackPointExtension></extensions>
</trkpt>
</trkseg></trk>
</gpx>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Garmin Connect" xmlns="http://www.topografix.com/GPX/1/1">
<rte>
<name>Harz Ridge Course</name>
<desc>A route-only export (no trk)</desc>
<rtept lat="51.7990" lon="10.6180"><ele>820</ele></rtept>
<rtept lat="51.8020" lon="10.6240"><ele>860</ele></rtept>
<rtept lat="51.8055" lon="10.6300"><ele>910</ele></rtept>
<rtept lat="51.8090" lon="10.6350"><ele>890</ele></rtept>
</rte>
</gpx>

View file

@ -0,0 +1,10 @@
<?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="48.1370" lon="11.5760"><time>2026-01-01T10:00:00Z</time></trkpt>
<trkpt lat="48.1400" lon="11.5810"><time>garbage</time></trkpt>
<trkpt lat="48.1440" lon="11.5860"><time>nope</time></trkpt>
<trkpt lat="48.1480" lon="11.5910"></trkpt>
</trkseg></trk>
</gpx>

View file

@ -0,0 +1,10 @@
<?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="48.1370" lon="11.5760"><time>2026-01-01T10:00:00Z</time></trkpt>
<trkpt lat="48.1400" lon="11.5810"><time>not-a-time</time></trkpt>
<trkpt lat="48.1440" lon="11.5860"><time>2026-01-01T10:02:00Z</time></trkpt>
<trkpt lat="48.1480" lon="11.5910"><time>2026-01-01T10:03:00Z</time></trkpt>
</trkseg></trk>
</gpx>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1">
<metadata><name>Zürich Höhenweg → Uetliberg 🏔️</name><desc>Grüezi! Café stop ☕</desc></metadata>
<trk><trkseg>
<trkpt lat="47.3500" lon="8.4900"><ele>620</ele></trkpt>
<trkpt lat="47.3450" lon="8.4850"><ele>810</ele></trkpt>
</trkseg></trk>
</gpx>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Wahoo Fitness" xmlns="http://www.topografix.com/GPX/1/1">
<metadata><name>Sunday Loop</name><time>2026-05-10T06:30:00Z</time></metadata>
<trk><trkseg>
<trkpt lat="52.0800" lon="8.5300"><ele>118</ele><time>2026-05-10T06:30:00Z</time></trkpt>
<trkpt lat="52.0840" lon="8.5360"><ele>121</ele><time>2026-05-10T06:31:00Z</time></trkpt>
<trkpt lat="52.0880" lon="8.5420"><ele>119</ele><time>2026-05-10T06:32:00Z</time></trkpt>
</trkseg></trk>
</gpx>

View file

@ -5,9 +5,13 @@
* work in environments without a native DOMParser (Node.js, React Native). * work in environments without a native DOMParser (Node.js, React Native).
* The linkedom fallback must handle all XML parsing. * The linkedom fallback must handle all XML parsing.
*/ */
import { readdirSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { parseGpxAsync } from "./parse.ts"; import { parseGpxAsync } from "./parse.ts";
import { generateGpx } from "./generate.ts"; import { generateGpx } from "./generate.ts";
import type { GpxData } from "./types.ts";
const sampleGpx = `<?xml version="1.0" encoding="UTF-8"?> const sampleGpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1"> <gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1">
@ -58,6 +62,88 @@ describe("parseGpxAsync (node environment, no DOMParser)", () => {
}); });
}); });
// Fixture corpus (spec: gpx-parser-robustness "Fixture corpus"). Every
// *.gpx under fixtures/ is loaded from disk and checked against an entry
// here. Adding a file without an expectation FAILS loudly — the corpus is
// the regression mechanism, so a new bug report becomes a new fixture.
const FIXTURES_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "../fixtures");
const flat = (r: GpxData) => r.tracks.flat();
const allFinite = (r: GpxData) =>
Number.isFinite(r.distance) &&
Number.isFinite(r.elevation.gain) &&
Number.isFinite(r.elevation.loss) &&
flat(r).every((p) => Number.isFinite(p.lat) && Number.isFinite(p.lon));
const EXPECTATIONS: Record<string, (r: GpxData) => void> = {
"route-only.gpx": (r) => {
expect(r.tracks).toHaveLength(1);
expect(r.tracks[0]).toHaveLength(4);
expect(r.name).toBe("Harz Ridge Course");
},
"missing-coords.gpx": (r) => {
// Two points (missing lon, empty lat/lon) are skipped; three survive.
expect(flat(r)).toHaveLength(3);
expect(flat(r).some((p) => p.lat === 0 && p.lon === 0)).toBe(false);
},
"garbage-ele.gpx": (r) => {
expect(r.tracks[0]).toHaveLength(4);
expect(r.tracks[0]![1]!.ele).toBeUndefined();
expect(r.tracks[0]![2]!.ele).toBeUndefined();
},
"timestamps-partial.gpx": (r) => {
expect(r.tracks[0]).toHaveLength(4);
// The one invalid timestamp is repaired to a parseable value.
expect(flat(r).every((p) => Number.isFinite(Date.parse(p.time!)))).toBe(true);
},
"timestamps-mostly-invalid.gpx": (r) => {
expect(r.tracks[0]).toHaveLength(4);
// >50% invalid → the whole segment's timestamps are dropped.
expect(flat(r).every((p) => p.time === undefined)).toBe(true);
},
"multi-track.gpx": (r) => {
expect(r.tracks).toHaveLength(2);
expect(r.name).toBe("Two-part outing");
},
"unicode-name.gpx": (r) => {
expect(r.name).toContain("Zürich");
expect(r.name).toContain("🏔");
},
"namespaced-extensions.gpx": (r) => {
// Extensions are ignored; the points parse intact.
expect(r.tracks[0]).toHaveLength(2);
expect(r.name).toBe("Extensions ignored");
},
"komoot-export.gpx": (r) => {
expect(r.name).toBe("Rheinsteig Sampler");
expect(r.tracks[0]).toHaveLength(4);
expect(r.distance).toBeGreaterThan(0);
},
"wahoo-export.gpx": (r) => {
expect(r.tracks[0]).toHaveLength(3);
// All timestamps valid → preserved unchanged.
expect(flat(r).every((p) => p.time !== undefined)).toBe(true);
},
};
describe("fixture corpus (node environment)", () => {
const files = readdirSync(FIXTURES_DIR).filter((f) => f.endsWith(".gpx")).sort();
it("has at least the documented fixtures and every file is described", () => {
expect(files.length).toBeGreaterThanOrEqual(8);
const undescribed = files.filter((f) => !(f in EXPECTATIONS));
expect(undescribed, `add an EXPECTATIONS entry for: ${undescribed.join(", ")}`).toEqual([]);
});
for (const file of Object.keys(EXPECTATIONS)) {
it(`parses ${file} with finite stats and expected shape`, async () => {
const result = await parseGpxAsync(readFileSync(resolve(FIXTURES_DIR, file), "utf8"));
expect(allFinite(result)).toBe(true);
EXPECTATIONS[file]!(result);
});
}
});
describe("generateGpx + parseGpxAsync round-trip (node environment)", () => { describe("generateGpx + parseGpxAsync round-trip (node environment)", () => {
it("round-trips waypoints, tracks, and metadata", async () => { it("round-trips waypoints, tracks, and metadata", async () => {
const gpx = generateGpx({ const gpx = generateGpx({