Files
TREK/server/tests/unit/nest/gpx-export.helpers.test.ts
T
Maurice 9ce6eed9ad feat(places): export a trip as GPX (#1907)
* feat(places): hand a trip back as GPX

TREK reads GPX but never wrote it, so a planned trip could not reach the offline
map or the handheld it was planned for. The importer already fixes the mapping:
a wpt is a place, a rte or trk is a place with route_geometry, so writing is that
reversed. Days have no import counterpart and become routes of their stops in
order, which is the part that puts a plan on a device.

XMLBuilder comes from the same fast-xml-parser the importer parses with, so
nothing new is pulled in.

* feat(dayplan): a GPX button beside PDF and ICS

Scopes are worded by what someone wants on their device rather than by the GPX
element: the whole trip, just the places for an offline map, just the days to
follow a plan. An empty trip says so instead of reporting a failed download.

* feat(mobile): GPX in the export sheet, which is no longer calendar-only

The sheet held the PDF export next to the calendar entries and was still called
Export Calendar; with GPX beside them the name was simply wrong.

* i18n(dayplan): translate the GPX export into all locales

* docs(map): document the GPX export

* fix(dayplan): drop the GPX tooltip and centre both export menus

The tooltip sat under the cursor exactly where the menu opens, so hovering the
button produced both at once. The label lives on as an aria-label. Both menus
now open centred under their button instead of hanging off its right edge.
2026-08-12 12:45:29 +02:00

178 lines
7.0 KiB
TypeScript

/**
* GPX writer unit tests. The builder is pure, so everything here runs without a
* DB or a Nest container. The last block parses its own output back with the very
* parser the importer uses, which is the closest thing to "a real reader can open
* this" that does not need a device.
*/
import { describe, it, expect } from 'vitest';
import { XMLParser, XMLValidator } from 'fast-xml-parser';
import { buildGpx, gpxFilename, type GpxExportPlace, type GpxExportDay } from '../../../src/nest/places/gpx-export.helpers';
const place = (over: Partial<GpxExportPlace> = {}): GpxExportPlace => ({
name: 'Somewhere', description: null, address: null, lat: 48.8566, lng: 2.3522,
route_geometry: null, category: null, ...over,
});
const day = (over: Partial<GpxExportDay> = {}): GpxExportDay => ({
dayNumber: 1, date: '2026-05-01', title: null, points: [], ...over,
});
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
isArray: (name) => ['wpt', 'trkpt', 'rtept', 'trk', 'trkseg', 'rte'].includes(name),
});
describe('buildGpx', () => {
it('writes a place without geometry as a waypoint, with its category as <sym>', () => {
const gpx = buildGpx({
tripTitle: 'Paris',
places: [place({ name: 'Louvre', description: 'Go early', address: 'Rue de Rivoli', category: 'Museum' })],
days: [],
})!;
expect(gpx).toContain('<wpt lat="48.8566" lon="2.3522">');
expect(gpx).toContain('<name>Louvre</name>');
expect(gpx).toContain('<sym>Museum</sym>');
// Description first, address after: a device usually shows only the first line.
expect(gpx).toContain('<desc>Go early, Rue de Rivoli</desc>');
});
it('writes a place carrying geometry as a track, elevation included', () => {
const gpx = buildGpx({
tripTitle: 'Hike',
places: [place({ name: 'Ridge', route_geometry: JSON.stringify([[47.1, 11.2, 1830], [47.11, 11.21, 1902]]) })],
days: [],
})!;
expect(gpx).toContain('<trkpt lat="47.1" lon="11.2">');
expect(gpx).toContain('<ele>1830</ele>');
expect(gpx).toContain('<ele>1902</ele>');
});
it('does not also drop a waypoint on the start of a track', () => {
const gpx = buildGpx({
tripTitle: 'Hike',
places: [place({ name: 'Ridge', route_geometry: JSON.stringify([[47.1, 11.2], [47.11, 11.21]]) })],
days: [],
})!;
expect(gpx).not.toContain('<wpt');
});
it('writes each planned day as a route of its stops in order', () => {
const gpx = buildGpx({
tripTitle: 'Rome',
places: [],
days: [day({
dayNumber: 2, date: '2026-05-02', title: 'Ancient city',
points: [
{ name: 'Colosseum', lat: 41.89, lng: 12.49 },
{ name: 'Forum', lat: 41.892, lng: 12.485 },
],
})],
})!;
expect(gpx).toContain('<name>2. Ancient city</name>');
const order = [gpx.indexOf('Colosseum'), gpx.indexOf('Forum')];
expect(order[0]).toBeLessThan(order[1]);
});
it('falls back to the date, then the bare number, when a day has no title', () => {
const dated = buildGpx({ tripTitle: 'T', places: [], days: [day({ dayNumber: 3, points: [{ name: 'A', lat: 1, lng: 1 }, { name: 'B', lat: 2, lng: 2 }] })] })!;
expect(dated).toContain('<name>3. 2026-05-01</name>');
const bare = buildGpx({ tripTitle: 'T', places: [], days: [day({ dayNumber: 4, date: null, points: [{ name: 'A', lat: 1, lng: 1 }, { name: 'B', lat: 2, lng: 2 }] })] })!;
expect(bare).toContain('<name>4</name>');
});
it('skips a day with a single stop — one point is not a route', () => {
const gpx = buildGpx({
tripTitle: 'T',
places: [place()],
days: [day({ points: [{ name: 'Only stop', lat: 1, lng: 1 }] })],
})!;
expect(gpx).not.toContain('<rte>');
});
it('honours each flag on its own', () => {
const input = {
tripTitle: 'T',
places: [place({ name: 'Point' }), place({ name: 'Line', route_geometry: JSON.stringify([[1, 1], [2, 2]]) })],
days: [day({ points: [{ name: 'A', lat: 1, lng: 1 }, { name: 'B', lat: 2, lng: 2 }] })],
};
const noWpt = buildGpx(input, { waypoints: false })!;
expect(noWpt).not.toContain('<wpt');
expect(noWpt).toContain('<trk>');
const noTrk = buildGpx(input, { tracks: false })!;
expect(noTrk).not.toContain('<trk>');
// With tracks off, the geometry place still has coordinates, so it lands as a waypoint.
expect(noTrk).toContain('<name>Line</name>');
const noRoutes = buildGpx(input, { dayRoutes: false })!;
expect(noRoutes).not.toContain('<rte>');
});
it('returns null when the selection produced nothing', () => {
expect(buildGpx({ tripTitle: 'Empty', places: [], days: [] })).toBeNull();
expect(buildGpx({ tripTitle: 'T', places: [place({ lat: null, lng: null })], days: [] })).toBeNull();
expect(buildGpx({ tripTitle: 'T', places: [place()], days: [] }, { waypoints: false })).toBeNull();
});
it('ignores geometry that is not a usable coordinate list', () => {
const garbage = ['not json', '{}', '[]', JSON.stringify([['a', 'b']]), JSON.stringify([[1]])];
for (const route_geometry of garbage) {
// Falls through to the waypoint branch rather than emitting an empty <trkseg>.
const gpx = buildGpx({ tripTitle: 'T', places: [place({ route_geometry })], days: [] })!;
expect(gpx).not.toContain('<trk>');
expect(gpx).toContain('<wpt');
}
});
it('produces a document the GPX parser reads back', () => {
const gpx = buildGpx({
tripTitle: 'Round trip',
places: [
place({ name: 'Waypoint', lat: 10.5, lng: -3.25 }),
place({ name: 'Track', route_geometry: JSON.stringify([[1.5, 2.5], [1.6, 2.6]]) }),
],
days: [day({ points: [{ name: 'A', lat: 3, lng: 4 }, { name: 'B', lat: 5, lng: 6 }] })],
})!;
expect(XMLValidator.validate(gpx)).toBe(true);
const parsed = parser.parse(gpx).gpx;
expect(parsed['@_version']).toBe('1.1');
expect(parsed.metadata.name).toBe('Round trip');
expect(parsed.wpt).toHaveLength(1);
// Attributes come back as strings — the importer runs them through parseFloat too.
expect(parsed.wpt[0]['@_lat']).toBe('10.5');
expect(parsed.trk[0].trkseg[0].trkpt).toHaveLength(2);
expect(parsed.rte[0].rtept).toHaveLength(2);
});
it('escapes markup in names instead of breaking the document', () => {
const gpx = buildGpx({ tripTitle: 'T', places: [place({ name: 'Bar & Grill <best>' })], days: [] })!;
expect(XMLValidator.validate(gpx)).toBe(true);
expect(parser.parse(gpx).gpx.wpt[0].name).toBe('Bar & Grill <best>');
});
});
describe('gpxFilename', () => {
it('keeps a plain title readable', () => {
expect(gpxFilename('Berlin 2026')).toBe('Berlin-2026.gpx');
});
it('drops what a filesystem or a header would choke on', () => {
expect(gpxFilename('Trip: "Rome"/2026 <draft>')).toBe('Trip-Rome-2026-draft.gpx');
});
it('falls back when a title leaves nothing behind', () => {
expect(gpxFilename('///')).toBe('trip.gpx');
expect(gpxFilename('')).toBe('trip.gpx');
});
});