trails/apps/planner/app/lib/gpx-export.test.ts
Ullrich Schäfer 3b9672e0ff
planner: give the Yjs document a typed schema seam
The routeData Y.Map's ~15 string keys (geojson, coordinates,
segmentBoundaries, road metadata, profile, colorMode, baseLayer,
overlays, poiCategories) were read and written raw at ~30 call sites,
each with its own JSON parsing and casts; parseJsonArray existed twice
and waypoint extraction four times. GPX assembly was duplicated between
SaveToJournalButton and ExportButton, so the saved plan and the
exported file could silently diverge.

- new lib/route-data.ts owns the routeData (+ noGoAreas) schema:
  typed read/write, JSON encoding internal, ColorMode moves here
  (re-exported from ColoredRoute for existing importers)
- new lib/gpx-export.ts owns GPX assembly: buildRouteGpx /
  buildPlanGpx / buildDayGpxFiles / hasDayBreaks; multi-day splitting
  becomes a pure, tested function
- waypoint-ymap.ts gains extractWaypoints / extractWaypointData; the
  four hand-rolled copies (use-routing, use-waypoint-manager,
  WaypointSidebar, use-days) now share it, and WaypointSidebar's
  moveWaypoint reuses the round-trip helpers instead of re-listing
  every waypoint field
- all hooks/components consume the seam; no raw routeData key strings
  remain outside route-data.ts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 01:35:04 +02:00

144 lines
4.4 KiB
TypeScript

import { describe, it, expect } from "vitest";
import * as Y from "yjs";
import type { Waypoint } from "@trails-cool/types";
import { waypointToYMap } from "./waypoint-ymap.ts";
import {
buildDayGpxFiles,
buildPlanGpx,
buildRouteGpx,
hasDayBreaks,
type PlanDoc,
} from "./gpx-export.ts";
function createDoc(): PlanDoc & { doc: Y.Doc } {
const doc = new Y.Doc();
return {
doc,
routeData: doc.getMap("routeData"),
waypoints: doc.getArray<Y.Map<unknown>>("waypoints"),
noGoAreas: doc.getArray<Y.Map<unknown>>("noGoAreas"),
notes: doc.getText("notes"),
};
}
function addWaypoints(doc: PlanDoc, waypoints: Waypoint[]): void {
doc.waypoints.push(waypoints.map(waypointToYMap));
}
function setTrack(doc: PlanDoc, coords: [number, number, number][]): void {
doc.routeData.set("coordinates", JSON.stringify(coords));
}
const TRACK: [number, number, number][] = [
[13.4, 52.5, 35],
[13.41, 52.51, 40],
[13.42, 52.52, 38],
[13.43, 52.53, 42],
];
describe("buildRouteGpx", () => {
it("contains the track but no waypoints", () => {
const doc = createDoc();
setTrack(doc, TRACK);
addWaypoints(doc, [
{ lat: 52.5, lon: 13.4, name: "Start" },
{ lat: 52.53, lon: 13.43, name: "End" },
]);
const gpx = buildRouteGpx(doc);
expect(gpx).toContain("<trk>");
expect(gpx).toContain('lat="52.5"');
expect(gpx).not.toContain("<wpt");
});
it("produces a track-less GPX for an empty document", () => {
const gpx = buildRouteGpx(createDoc());
expect(gpx).toContain("<gpx");
expect(gpx).not.toContain("<trkpt");
});
});
describe("buildPlanGpx", () => {
it("includes waypoints, no-go areas, and notes", () => {
const doc = createDoc();
setTrack(doc, TRACK);
addWaypoints(doc, [
{ lat: 52.5, lon: 13.4, name: "Start" },
{ lat: 52.53, lon: 13.43, name: "End" },
]);
const area = new Y.Map<unknown>();
area.set("points", [
{ lat: 52.5, lon: 13.4 },
{ lat: 52.51, lon: 13.41 },
{ lat: 52.52, lon: 13.4 },
]);
doc.noGoAreas.push([area]);
doc.notes.insert(0, "Pack sunscreen");
const gpx = buildPlanGpx(doc);
expect(gpx).toContain("<wpt");
expect(gpx).toContain("Start");
expect(gpx).toContain("Pack sunscreen");
expect(gpx).toContain("trails:"); // no-go areas ride in the trails extension namespace
});
it("matches buildRouteGpx track content so save and export cannot diverge", () => {
const doc = createDoc();
setTrack(doc, TRACK);
const routeTrkpts = (buildRouteGpx(doc).match(/<trkpt/g) ?? []).length;
const planTrkpts = (buildPlanGpx(doc).match(/<trkpt/g) ?? []).length;
expect(planTrkpts).toBe(routeTrkpts);
expect(planTrkpts).toBe(TRACK.length);
});
});
describe("day exports", () => {
it("hasDayBreaks reflects overnight waypoints", () => {
const doc = createDoc();
addWaypoints(doc, [{ lat: 52.5, lon: 13.4 }, { lat: 52.53, lon: 13.43 }]);
expect(hasDayBreaks(doc)).toBe(false);
const withBreak = createDoc();
addWaypoints(withBreak, [
{ lat: 52.5, lon: 13.4 },
{ lat: 52.51, lon: 13.41, isDayBreak: true },
{ lat: 52.53, lon: 13.43 },
]);
expect(hasDayBreaks(withBreak)).toBe(true);
});
it("returns [] without a track or waypoints", () => {
expect(buildDayGpxFiles(createDoc())).toEqual([]);
});
it("exports a single route file when there are no day breaks", () => {
const doc = createDoc();
setTrack(doc, TRACK);
addWaypoints(doc, [{ lat: 52.5, lon: 13.4 }, { lat: 52.53, lon: 13.43 }]);
const files = buildDayGpxFiles(doc);
expect(files).toHaveLength(1);
expect(files[0]!.filename).toBe("route.gpx");
});
it("splits at day-break waypoints into per-day files", () => {
const doc = createDoc();
setTrack(doc, TRACK);
addWaypoints(doc, [
{ lat: 52.5, lon: 13.4, name: "Alpha" },
{ lat: 52.51, lon: 13.41, name: "Hut", isDayBreak: true },
{ lat: 52.53, lon: 13.43, name: "Omega" },
]);
const files = buildDayGpxFiles(doc);
expect(files).toHaveLength(2);
expect(files[0]!.filename).toBe("day-1-alpha.gpx");
expect(files[0]!.gpx).toContain("Day 1: Alpha - Hut");
expect(files[1]!.filename).toBe("day-2-hut.gpx");
expect(files[1]!.gpx).toContain("Day 2: Hut - Omega");
// Day 1 covers track points up to the hut; day 2 the rest.
expect((files[0]!.gpx.match(/<trkpt/g) ?? []).length).toBe(2);
expect((files[1]!.gpx.match(/<trkpt/g) ?? []).length).toBe(3);
});
});