From 36c5a8beaa5bc35bd828ec0ee2032cea4393944e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Tue, 14 Jul 2026 00:30:46 +0200 Subject: [PATCH] =?UTF-8?q?test(gpx):=20fixture=20corpus=20+=20verificatio?= =?UTF-8?q?n=20(groups=205=E2=80=936)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task groups 5 and 6 of gpx-parser-robustness. Group 5 — packages/gpx/fixtures/*.gpx (synthetic, no personal data): route-only, missing-coords, garbage-ele, timestamps-partial, timestamps-mostly-invalid, multi-track, unicode-name, namespaced-extensions, plus trimmed Komoot/Wahoo-style exports. parse-node.test.ts gains a fixture table test that auto-discovers every *.gpx from disk, asserts finite stats + per-fixture shape, and fails loudly if a file has no EXPECTATIONS entry (the corpus is the regression mechanism). Group 6 — verification: GpxData shape unchanged; journal + planner compile with no changes; gpx suite 98/98; full pnpm typecheck 13/13, lint 13/13, test 11/11. Route-only + broken-file import behaviour is covered by the fixtures + consumer compile; the browser leg rides e2e. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../changes/gpx-parser-robustness/tasks.md | 12 +-- packages/gpx/fixtures/garbage-ele.gpx | 10 +++ packages/gpx/fixtures/komoot-export.gpx | 11 +++ packages/gpx/fixtures/missing-coords.gpx | 11 +++ packages/gpx/fixtures/multi-track.gpx | 14 +++ .../gpx/fixtures/namespaced-extensions.gpx | 12 +++ packages/gpx/fixtures/route-only.gpx | 12 +++ .../fixtures/timestamps-mostly-invalid.gpx | 10 +++ packages/gpx/fixtures/timestamps-partial.gpx | 10 +++ packages/gpx/fixtures/unicode-name.gpx | 9 ++ packages/gpx/fixtures/wahoo-export.gpx | 10 +++ packages/gpx/src/parse-node.test.ts | 86 +++++++++++++++++++ 12 files changed, 201 insertions(+), 6 deletions(-) create mode 100644 packages/gpx/fixtures/garbage-ele.gpx create mode 100644 packages/gpx/fixtures/komoot-export.gpx create mode 100644 packages/gpx/fixtures/missing-coords.gpx create mode 100644 packages/gpx/fixtures/multi-track.gpx create mode 100644 packages/gpx/fixtures/namespaced-extensions.gpx create mode 100644 packages/gpx/fixtures/route-only.gpx create mode 100644 packages/gpx/fixtures/timestamps-mostly-invalid.gpx create mode 100644 packages/gpx/fixtures/timestamps-partial.gpx create mode 100644 packages/gpx/fixtures/unicode-name.gpx create mode 100644 packages/gpx/fixtures/wahoo-export.gpx diff --git a/openspec/changes/gpx-parser-robustness/tasks.md b/openspec/changes/gpx-parser-robustness/tasks.md index 920b483..8de5907 100644 --- a/openspec/changes/gpx-parser-robustness/tasks.md +++ b/openspec/changes/gpx-parser-robustness/tasks.md @@ -22,12 +22,12 @@ ## 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) -- [ ] 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.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.2 Add trimmed real-world exports for supported integrations (Komoot, Wahoo) with any personal data scrubbed +- [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.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 -- [ ] 6.3 Run `pnpm typecheck && pnpm lint && pnpm test` and the e2e suites +- [x] 6.1 Typecheck + run full gpx package suite; confirm `GpxData` shape unchanged and journal/planner compile without changes +- [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) +- [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 diff --git a/packages/gpx/fixtures/garbage-ele.gpx b/packages/gpx/fixtures/garbage-ele.gpx new file mode 100644 index 0000000..6f0f4d5 --- /dev/null +++ b/packages/gpx/fixtures/garbage-ele.gpx @@ -0,0 +1,10 @@ + + + + + 408 + NaN + abc + 512 + + diff --git a/packages/gpx/fixtures/komoot-export.gpx b/packages/gpx/fixtures/komoot-export.gpx new file mode 100644 index 0000000..ce53fbf --- /dev/null +++ b/packages/gpx/fixtures/komoot-export.gpx @@ -0,0 +1,11 @@ + + + + Rheinsteig Samplerkomoot + Rheinsteig Sampler + 75 + 120 + 210 + 260 + + diff --git a/packages/gpx/fixtures/missing-coords.gpx b/packages/gpx/fixtures/missing-coords.gpx new file mode 100644 index 0000000..5d3fc9b --- /dev/null +++ b/packages/gpx/fixtures/missing-coords.gpx @@ -0,0 +1,11 @@ + + + + Flaky recorder + 34 + 36 + 38 + 40 + 42 + + diff --git a/packages/gpx/fixtures/multi-track.gpx b/packages/gpx/fixtures/multi-track.gpx new file mode 100644 index 0000000..9118a02 --- /dev/null +++ b/packages/gpx/fixtures/multi-track.gpx @@ -0,0 +1,14 @@ + + + + Two-part outing + Morning + 1500 + 1600 + + Afternoon + 1650 + 1700 + 1550 + + diff --git a/packages/gpx/fixtures/namespaced-extensions.gpx b/packages/gpx/fixtures/namespaced-extensions.gpx new file mode 100644 index 0000000..7df0c4f --- /dev/null +++ b/packages/gpx/fixtures/namespaced-extensions.gpx @@ -0,0 +1,12 @@ + + + + Extensions ignored + 50 + 14284 + + 52 + 150 + + + diff --git a/packages/gpx/fixtures/route-only.gpx b/packages/gpx/fixtures/route-only.gpx new file mode 100644 index 0000000..d667218 --- /dev/null +++ b/packages/gpx/fixtures/route-only.gpx @@ -0,0 +1,12 @@ + + + + + Harz Ridge Course + A route-only export (no trk) + 820 + 860 + 910 + 890 + + diff --git a/packages/gpx/fixtures/timestamps-mostly-invalid.gpx b/packages/gpx/fixtures/timestamps-mostly-invalid.gpx new file mode 100644 index 0000000..41d8f83 --- /dev/null +++ b/packages/gpx/fixtures/timestamps-mostly-invalid.gpx @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/gpx/fixtures/timestamps-partial.gpx b/packages/gpx/fixtures/timestamps-partial.gpx new file mode 100644 index 0000000..3e1d634 --- /dev/null +++ b/packages/gpx/fixtures/timestamps-partial.gpx @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/gpx/fixtures/unicode-name.gpx b/packages/gpx/fixtures/unicode-name.gpx new file mode 100644 index 0000000..5b7a7fc --- /dev/null +++ b/packages/gpx/fixtures/unicode-name.gpx @@ -0,0 +1,9 @@ + + + + Zürich Höhenweg → Uetliberg 🏔️Grüezi! Café stop ☕ + + 620 + 810 + + diff --git a/packages/gpx/fixtures/wahoo-export.gpx b/packages/gpx/fixtures/wahoo-export.gpx new file mode 100644 index 0000000..4aa3b05 --- /dev/null +++ b/packages/gpx/fixtures/wahoo-export.gpx @@ -0,0 +1,10 @@ + + + + Sunday Loop + + 118 + 121 + 119 + + diff --git a/packages/gpx/src/parse-node.test.ts b/packages/gpx/src/parse-node.test.ts index 757e540..bfd109c 100644 --- a/packages/gpx/src/parse-node.test.ts +++ b/packages/gpx/src/parse-node.test.ts @@ -5,9 +5,13 @@ * work in environments without a native DOMParser (Node.js, React Native). * 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 { parseGpxAsync } from "./parse.ts"; import { generateGpx } from "./generate.ts"; +import type { GpxData } from "./types.ts"; const sampleGpx = ` @@ -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 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)", () => { it("round-trips waypoints, tracks, and metadata", async () => { const gpx = generateGpx({