diff --git a/packages/gpx/src/nogo.test.ts b/packages/gpx/src/nogo.test.ts new file mode 100644 index 0000000..2f6bff0 --- /dev/null +++ b/packages/gpx/src/nogo.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect } from "vitest"; +import { generateGpx } from "./generate.ts"; +import { parseGpxAsync } from "./parse.ts"; +import { extractWaypoints } from "./waypoints.ts"; + +describe("no-go area generation", () => { + it("includes trails namespace when no-go areas are present", () => { + const gpx = generateGpx({ + noGoAreas: [{ points: [{ lat: 52, lon: 13 }, { lat: 51, lon: 13 }, { lat: 51, lon: 14 }] }], + }); + expect(gpx).toContain('xmlns:trails="https://trails.cool/gpx/1"'); + }); + + it("does not include trails namespace without no-go areas", () => { + const gpx = generateGpx({ name: "test" }); + expect(gpx).not.toContain("xmlns:trails"); + expect(gpx).not.toContain(""); + }); + + it("generates correct extension structure", () => { + const gpx = generateGpx({ + noGoAreas: [{ points: [{ lat: 52.5, lon: 13.3 }, { lat: 52.4, lon: 13.4 }, { lat: 52.3, lon: 13.2 }] }], + }); + expect(gpx).toContain(""); + expect(gpx).toContain(""); + expect(gpx).toContain(""); + expect(gpx).toContain(''); + expect(gpx).toContain(''); + expect(gpx).toContain(''); + }); + + it("generates multiple no-go areas", () => { + const gpx = generateGpx({ + noGoAreas: [ + { points: [{ lat: 1, lon: 2 }, { lat: 3, lon: 4 }, { lat: 5, lon: 6 }] }, + { points: [{ lat: 7, lon: 8 }, { lat: 9, lon: 10 }, { lat: 11, lon: 12 }] }, + ], + }); + const matches = gpx.match(//g); + expect(matches).toHaveLength(2); + }); +}); + +describe("no-go area parsing", () => { + it("parses namespaced no-go areas", async () => { + const gpx = ` + + + + + + + + + + + `; + const data = await parseGpxAsync(gpx); + expect(data.noGoAreas).toHaveLength(1); + expect(data.noGoAreas[0]!.points).toHaveLength(3); + expect(data.noGoAreas[0]!.points[0]).toEqual({ lat: 52.5, lon: 13.3 }); + }); + + it("parses non-namespaced no-go areas", async () => { + const gpx = ` + + + + + + + + + + + `; + const data = await parseGpxAsync(gpx); + expect(data.noGoAreas).toHaveLength(1); + expect(data.noGoAreas[0]!.points).toHaveLength(3); + }); + + it("rejects no-go areas with fewer than 3 points", async () => { + const gpx = ` + + + + + `; + const data = await parseGpxAsync(gpx); + expect(data.noGoAreas).toHaveLength(0); + }); + + it("returns empty array when no extensions", async () => { + const gpx = ` + + + `; + const data = await parseGpxAsync(gpx); + expect(data.noGoAreas).toHaveLength(0); + }); + + it("parses multiple no-go areas", async () => { + const gpx = ` + + + + + + + + + + `; + const data = await parseGpxAsync(gpx); + expect(data.noGoAreas).toHaveLength(2); + }); +}); + +describe("no-go area round-trip", () => { + it("round-trips no-go areas through generate and parse", async () => { + const noGoAreas = [ + { points: [{ lat: 52.5, lon: 13.3 }, { lat: 52.4, lon: 13.4 }, { lat: 52.3, lon: 13.2 }] }, + { points: [{ lat: 48.1, lon: 11.5 }, { lat: 48.0, lon: 11.6 }, { lat: 47.9, lon: 11.4 }] }, + ]; + const gpx = generateGpx({ + name: "No-go test", + tracks: [[{ lat: 52.5, lon: 13.3, ele: 34 }, { lat: 48.1, lon: 11.5, ele: 519 }]], + noGoAreas, + }); + const parsed = await parseGpxAsync(gpx); + expect(parsed.name).toBe("No-go test"); + expect(parsed.noGoAreas).toHaveLength(2); + expect(parsed.noGoAreas[0]!.points).toEqual(noGoAreas[0]!.points); + expect(parsed.noGoAreas[1]!.points).toEqual(noGoAreas[1]!.points); + }); + + it("round-trips complete planning state", async () => { + const gpx = generateGpx({ + name: "Full plan", + waypoints: [{ lat: 52.5, lon: 13.3, name: "Berlin" }, { lat: 48.1, lon: 11.5, name: "Munich" }], + tracks: [[{ lat: 52.5, lon: 13.3, ele: 34 }, { lat: 50.0, lon: 12.0, ele: 300 }, { lat: 48.1, lon: 11.5, ele: 519 }]], + noGoAreas: [{ points: [{ lat: 51, lon: 12 }, { lat: 50, lon: 13 }, { lat: 50, lon: 11 }] }], + }); + const parsed = await parseGpxAsync(gpx); + expect(parsed.name).toBe("Full plan"); + expect(parsed.waypoints).toHaveLength(2); + expect(parsed.tracks[0]).toHaveLength(3); + expect(parsed.noGoAreas).toHaveLength(1); + expect(parsed.distance).toBeGreaterThan(0); + }); +}); + +describe("extractWaypoints", () => { + it("returns explicit waypoints when present", async () => { + const gpx = ` + + Berlin + Munich + + + + + `; + const data = await parseGpxAsync(gpx); + const wps = extractWaypoints(data); + expect(wps).toHaveLength(2); + expect(wps[0]!.name).toBe("Berlin"); + }); + + it("extracts segment endpoints for multi-segment tracks", async () => { + const gpx = ` + + + + + + + `; + const data = await parseGpxAsync(gpx); + const wps = extractWaypoints(data); + // Start of each segment (deduped) + end of last = 52, 51, 50, 48 + expect(wps.length).toBeGreaterThanOrEqual(3); + expect(wps[0]).toEqual({ lat: 52, lon: 13 }); + }); + + it("uses Douglas-Peucker for single-segment tracks", async () => { + // Build a track with a clear deviation in the middle + const points = []; + for (let i = 0; i <= 10; i++) { + const lat = 52 - i * 0.4; // straight south + const lon = i === 5 ? 14.5 : 13; // big eastward deviation at midpoint + points.push(``); + } + const gpx = ` + + ${points.join("")} + `; + const data = await parseGpxAsync(gpx); + const wps = extractWaypoints(data); + // Should include start, end, and the midpoint deviation + expect(wps.length).toBeGreaterThanOrEqual(3); + // The deviated midpoint should be preserved + const hasDeviation = wps.some((w) => Math.abs(w.lon - 14.5) < 0.01); + expect(hasDeviation).toBe(true); + }); + + it("returns empty for empty tracks", async () => { + const gpx = ` + + + `; + const data = await parseGpxAsync(gpx); + const wps = extractWaypoints(data); + expect(wps).toHaveLength(0); + }); +});