Files
TREK/client/src/components/Tours/useTourGpxImport.test.ts
T
Maurice 2e683f8609 fix(tours): keep the waypoints of a GPX/KML file that also holds a track
With the Tours addon on, a file picked or dropped on the places column goes
into the Tours import as soon as it holds one line, and that import reads
lines only. A Sygic or Trip-o-matic export (a route plus its stops) came in
as one tour and every place was dropped without a word.

The preview now counts a GPX file's waypoints as it already counted a KML
file's pins, and the dialog offers to add those points as places of the
trip, on by default. Confirming runs the Tours import for the lines and the
places import (points only) for the same files. A file whose tracks the
trip already holds can still bring its points.

Closes #2969
2026-11-03 20:19:43 +01:00

135 lines
6.1 KiB
TypeScript

// The Tours file preview's handling of a file without a line: named in a toast by
// default, handed on when the caller takes such files (the places column does).
// Without a translation provider `t` echoes its key.
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { resetAllStores } from '../../../tests/helpers/store';
import { placesApi } from '../../api/client';
import { tourRepo } from '../../repo/tourRepo';
import { useTripStore } from '../../store/tripStore';
import { useTourGpxImport } from './useTourGpxImport';
// The toasts are caught at useToast, so no window global has to be set for them.
const { addToast, toast } = vi.hoisted(() => {
const addToast = vi.fn((_message: string, _type?: string, _duration?: number) => 0);
const show = (type: string) => (message: string, duration?: number) => {
addToast(message, type, duration);
};
return {
addToast,
toast: { success: show('success'), error: show('error'), warning: show('warning'), info: show('info') },
};
});
vi.mock('../shared/Toast', () => ({ useToast: () => toast, default: () => toast }));
const file = new File(['<kml/>'], 'cafes.kml');
beforeEach(() => {
resetAllStores();
addToast.mockClear();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('useTourGpxImport without a line in the file', () => {
it('names the file in a toast when nothing takes such files', async () => {
vi.spyOn(tourRepo, 'previewGpx').mockRejectedValue({ response: { data: { code: 'no_track' } } });
const { result } = renderHook(() => useTourGpxImport({ tripId: 2, canEdit: true }));
await act(() => result.current.importFiles([file]));
expect(addToast).toHaveBeenCalledWith('tours.import.noTrackIn', 'error', undefined);
expect(result.current.preview).toBeNull();
});
it('reads a GPX answer by its message as well', async () => {
const onNoTrack = vi.fn();
vi.spyOn(tourRepo, 'previewGpx').mockRejectedValue({
response: { data: { error: 'No track or route found in GPX file' } },
});
const { result } = renderHook(() => useTourGpxImport({ tripId: 2, canEdit: true, onNoTrack }));
await act(() => result.current.importFiles([file]));
expect(onNoTrack).toHaveBeenCalledWith([file]);
expect(addToast).not.toHaveBeenCalled();
});
it('hands such files on, once, and still names any other failure', async () => {
const onNoTrack = vi.fn();
const broken = new File(['?'], 'broken.gpx');
vi.spyOn(tourRepo, 'previewGpx').mockImplementation(async (_tripId, picked) => {
throw picked === broken
? { response: { data: { code: 'malformed' } } }
: { response: { data: { code: 'no_track' } } };
});
const { result } = renderHook(() => useTourGpxImport({ tripId: 2, canEdit: true, onNoTrack }));
await act(() => result.current.importFiles([file, broken]));
expect(onNoTrack).toHaveBeenCalledTimes(1);
expect(onNoTrack).toHaveBeenCalledWith([file]);
expect(addToast).toHaveBeenCalledTimes(1);
expect(addToast).toHaveBeenCalledWith('tours.import.unreadableIn', 'error', undefined);
});
});
describe('useTourGpxImport with points beside the lines (#2969)', () => {
const route = new File(['<gpx/>'], 'sygic.gpx');
const pins = new File(['<kml/>'], 'pins.kml');
const line = {
name: 'Route',
geometry: [[47, 11] as [number, number], [47.01, 11] as [number, number]],
point_count: 2,
distance_km: 1,
elevation_gain_m: null,
elevation_loss_m: null,
duplicate: false,
};
beforeEach(() => {
useTripStore.setState({ refreshPlaces: vi.fn(async () => {}) } as never);
vi.spyOn(tourRepo, 'importGpx').mockResolvedValue({ tours: [], skipped: 0, caution: false } as never);
});
const previewed = async (preview: (picked: File) => object) => {
vi.spyOn(tourRepo, 'previewGpx').mockImplementation(async (_tripId, picked) => preview(picked as File) as never);
const hook = renderHook(() => useTourGpxImport({ tripId: 2, canEdit: true }));
await act(() => hook.result.current.importFiles([route, pins]));
return hook;
};
it('adds the points of each file as places through the places import, waypoints only', async () => {
const importGpx = vi.spyOn(placesApi, 'importGpx').mockResolvedValue({ count: 3 });
const importMap = vi.spyOn(placesApi, 'importMapFile').mockResolvedValue({ count: 2 });
const { result } = await previewed((picked) => ({
tracks: [line],
left_out: { points: picked === route ? 3 : 2, areas: 0, other: 0 },
}));
await act(() => result.current.confirm({ tour_type: 'hike' }, { points: true }));
expect(importGpx).toHaveBeenCalledWith(2, route, { waypoints: true, routes: false, tracks: false });
expect(importMap).toHaveBeenCalledWith(2, pins, { points: true, paths: false });
expect(tourRepo.importGpx).toHaveBeenCalledTimes(2);
expect(addToast).toHaveBeenCalledWith('tours.import.pointsImported', 'success', undefined);
});
it('leaves the points out when they are not asked for, and skips the Tours import for a file of known lines', async () => {
const importGpx = vi.spyOn(placesApi, 'importGpx').mockResolvedValue({ count: 3 });
const { result } = await previewed(() => ({
tracks: [{ ...line, duplicate: true }],
left_out: { points: 3, areas: 0, other: 0 },
}));
await act(() => result.current.confirm({ tour_type: 'hike' }));
expect(importGpx).not.toHaveBeenCalled();
expect(tourRepo.importGpx).not.toHaveBeenCalled();
});
it('names a file whose points could not be added, and still takes in its tour', async () => {
vi.spyOn(placesApi, 'importGpx').mockRejectedValue(new Error('offline'));
vi.spyOn(placesApi, 'importMapFile').mockResolvedValue({ count: 0 });
const { result } = await previewed((picked) => ({
tracks: [line],
...(picked === route ? { left_out: { points: 1, areas: 0, other: 0 } } : {}),
}));
await act(() => result.current.confirm({ tour_type: 'hike' }, { points: true }));
expect(addToast).toHaveBeenCalledWith('tours.import.pointsFailedIn', 'error', undefined);
expect(tourRepo.importGpx).toHaveBeenCalledTimes(2);
});
});