/** * @vitest-environment node * * These tests run WITHOUT jsdom to verify GPX parsing and generation * 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 = ` Test RouteA test description Berlin Dessauovernight Munich 34 80 519 `; describe("parseGpxAsync (node environment, no DOMParser)", () => { it("parses route name and description", async () => { const result = await parseGpxAsync(sampleGpx); expect(result.name).toBe("Test Route"); expect(result.description).toBe("A test description"); }); it("parses waypoints", async () => { const result = await parseGpxAsync(sampleGpx); expect(result.waypoints).toHaveLength(3); expect(result.waypoints[0]).toEqual({ lat: 52.52, lon: 13.405, name: "Berlin" }); }); it("parses isDayBreak from overnight type", async () => { const result = await parseGpxAsync(sampleGpx); expect(result.waypoints[1]!.isDayBreak).toBe(true); expect(result.waypoints[0]!.isDayBreak).toBeUndefined(); }); it("parses tracks with elevation", async () => { const result = await parseGpxAsync(sampleGpx); expect(result.tracks).toHaveLength(1); expect(result.tracks[0]).toHaveLength(3); expect(result.tracks[0]![0]!.ele).toBe(34); }); it("computes elevation gain and distance", async () => { const result = await parseGpxAsync(sampleGpx); expect(result.elevation.gain).toBe(485); expect(result.elevation.loss).toBe(0); expect(result.distance).toBeGreaterThan(400_000); }); }); // 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({ name: "Round Trip", description: "Testing", waypoints: [ { lat: 52.52, lon: 13.405, name: "Start" }, { lat: 51.0, lon: 12.0, name: "Camp", isDayBreak: true }, { lat: 48.137, lon: 11.576, name: "End" }, ], tracks: [[ { lat: 52.52, lon: 13.405, ele: 34 }, { lat: 51.0, lon: 12.0, ele: 200 }, { lat: 48.137, lon: 11.576, ele: 519 }, ]], }); const parsed = await parseGpxAsync(gpx); expect(parsed.name).toBe("Round Trip"); expect(parsed.description).toBe("Testing"); expect(parsed.waypoints).toHaveLength(3); expect(parsed.waypoints[1]!.isDayBreak).toBe(true); expect(parsed.tracks[0]).toHaveLength(3); }); });