/** * 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 => ({ name: 'Somewhere', description: null, address: null, lat: 48.8566, lng: 2.3522, route_geometry: null, category: null, ...over, }); const day = (over: Partial = {}): 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 ', () => { const gpx = buildGpx({ tripTitle: 'Paris', places: [place({ name: 'Louvre', description: 'Go early', address: 'Rue de Rivoli', category: 'Museum' })], days: [], })!; expect(gpx).toContain(''); expect(gpx).toContain('Louvre'); expect(gpx).toContain('Museum'); // Description first, address after: a device usually shows only the first line. expect(gpx).toContain('Go early, Rue de Rivoli'); }); 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(''); expect(gpx).toContain('1830'); expect(gpx).toContain('1902'); }); 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(' { 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('2. Ancient city'); 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('3. 2026-05-01'); 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('4'); }); 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(''); }); 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(''); const noTrk = buildGpx(input, { tracks: false })!; expect(noTrk).not.toContain(''); // With tracks off, the geometry place still has coordinates, so it lands as a waypoint. expect(noTrk).toContain('Line'); const noRoutes = buildGpx(input, { dayRoutes: false })!; expect(noRoutes).not.toContain(''); }); 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 . const gpx = buildGpx({ tripTitle: 'T', places: [place({ route_geometry })], days: [] })!; expect(gpx).not.toContain(''); expect(gpx).toContain(' { 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 ' })], days: [] })!; expect(XMLValidator.validate(gpx)).toBe(true); expect(parser.parse(gpx).gpx.wpt[0].name).toBe('Bar & Grill '); }); }); 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 ')).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'); }); });