mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
782e613506
- GPX files drop onto the map or a day; the import lands on that day - wanderer connection per user in Integrations; the workspace offers the user's own trails only, behind an admin switch under the Tours addon - wanderer difficulty is stored on the SAC scale (T1 to T3), backfilled - a GPX or wanderer import can be planned as an editable tour, either in place (the recorded track is kept and can be restored) or as a new tour that remembers the import it came from - rails, rows and detail dialog follow the plan sidebar's cards and badges - admin addon shelves with more than one row fold, collapsed by default - reverse-geocoded waypoint names are cached on the device
275 lines
11 KiB
TypeScript
275 lines
11 KiB
TypeScript
import { act, renderHook } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import type { TourListItem } from '@trek/shared';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { render, screen } from '../../../../tests/helpers/render';
|
|
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
|
import { useSettingsStore } from '../../../store/settingsStore';
|
|
import type { TourPlannerController } from '../planner/useTourPlanner';
|
|
import TourConversionSection from './TourConversionSection';
|
|
import { useGpxConversion } from './useGpxConversion';
|
|
|
|
const routing = vi.hoisted(() => ({ route: vi.fn() }));
|
|
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
|
|
vi.mock('../planner/tourRouting', () => ({ routeWalkingTour: routing.route }));
|
|
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
|
|
|
const imported: TourListItem = {
|
|
place_id: 14,
|
|
name: 'Recorded ridge',
|
|
description: 'Above the lake',
|
|
website: null,
|
|
tour_type: 'hike',
|
|
distance: 2,
|
|
elevation_gain: 100,
|
|
elevation_loss: 100,
|
|
duration: null,
|
|
difficulty: 'moderate',
|
|
wanderer_ref: 'https://wanderer.test#abc',
|
|
match_confidence: 1,
|
|
max_hiking_difficulty: 2,
|
|
planned: false,
|
|
caution: false,
|
|
has_waypoints: false,
|
|
};
|
|
|
|
/** A straight track north, about two kilometres. */
|
|
const track: [number, number][] = Array.from({ length: 181 }, (_, i) => [47 + (i * 2) / 180 / 111.2, 11]);
|
|
|
|
function planner(overrides: Partial<TourPlannerController> = {}): TourPlannerController {
|
|
return {
|
|
canEdit: true,
|
|
mode: { type: 'view-gpx', placeId: imported.place_id, tour: imported },
|
|
readOnlyGpxTour: { tour: imported, routeGeometry: null },
|
|
readOnlyGpxAnalysis: { routeCoordinates: track },
|
|
startDerivedTour: vi.fn().mockReturnValue(true),
|
|
...overrides,
|
|
} as unknown as TourPlannerController;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resetAllStores();
|
|
vi.clearAllMocks();
|
|
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } });
|
|
routing.route.mockResolvedValue({ coordinates: track, distanceMeters: 2000, durationSeconds: 1800 });
|
|
});
|
|
|
|
describe('useGpxConversion', () => {
|
|
it('PR1-05A: analyses only when asked, then compares the routed suggestion to the track', async () => {
|
|
const { result } = renderHook(() => useGpxConversion(planner()));
|
|
expect(result.current.status).toBe('idle');
|
|
expect(result.current.available).toBe(true);
|
|
expect(routing.route).not.toHaveBeenCalled();
|
|
|
|
await act(async () => {
|
|
await result.current.analyze();
|
|
});
|
|
|
|
expect(routing.route).toHaveBeenCalledWith(result.current.points, expect.any(AbortSignal), 2);
|
|
expect(result.current.status).toBe('ready');
|
|
expect(result.current.comparison?.withinCorridor).toBe(1);
|
|
expect(result.current.line).toEqual(track);
|
|
});
|
|
|
|
it('PR1-05A: routes an import without a grade with T3 paths allowed', async () => {
|
|
const ungraded = { ...imported, max_hiking_difficulty: null };
|
|
const { result } = renderHook(() =>
|
|
useGpxConversion(
|
|
planner({
|
|
mode: { type: 'view-gpx', placeId: ungraded.place_id, tour: ungraded },
|
|
readOnlyGpxTour: { tour: ungraded, routeGeometry: null },
|
|
} as Partial<TourPlannerController>)
|
|
)
|
|
);
|
|
await act(async () => {
|
|
await result.current.analyze();
|
|
});
|
|
expect(routing.route).toHaveBeenLastCalledWith(expect.any(Array), expect.any(AbortSignal), 3);
|
|
});
|
|
|
|
it('PR1-05A: says so when no route is found, and stays retryable', async () => {
|
|
routing.route.mockResolvedValueOnce(null);
|
|
const { result } = renderHook(() => useGpxConversion(planner()));
|
|
await act(async () => {
|
|
await result.current.analyze();
|
|
});
|
|
expect(result.current.status).toBe('failed');
|
|
expect(result.current.error).toBe('routing');
|
|
|
|
await act(async () => {
|
|
await result.current.analyze();
|
|
});
|
|
expect(result.current.status).toBe('ready');
|
|
});
|
|
|
|
it('PR1-05A: a track too short to plan from fails without asking the router', async () => {
|
|
const { result } = renderHook(() =>
|
|
useGpxConversion(
|
|
planner({
|
|
readOnlyGpxAnalysis: {
|
|
routeCoordinates: [
|
|
[47, 11],
|
|
[47, 11],
|
|
],
|
|
},
|
|
} as Partial<TourPlannerController>)
|
|
)
|
|
);
|
|
await act(async () => {
|
|
await result.current.analyze();
|
|
});
|
|
// Two identical points simplify to start and end, which the router may still connect;
|
|
// a single point has nothing to plan from.
|
|
const single = renderHook(() =>
|
|
useGpxConversion(
|
|
planner({ readOnlyGpxAnalysis: { routeCoordinates: [[47, 11]] } } as Partial<TourPlannerController>)
|
|
)
|
|
);
|
|
expect(single.result.current.available).toBe(false);
|
|
});
|
|
|
|
it('PR1-05B: replacing hands the points to the planner as the import planned over in place', async () => {
|
|
const controller = planner();
|
|
const { result } = renderHook(() => useGpxConversion(controller));
|
|
await act(async () => {
|
|
await result.current.analyze();
|
|
});
|
|
let started = false;
|
|
act(() => {
|
|
started = result.current.adopt('replace');
|
|
});
|
|
expect(started).toBe(true);
|
|
expect(controller.startDerivedTour).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
name: 'Recorded ridge',
|
|
description: 'Above the lake',
|
|
difficulty: 2,
|
|
sourcePlaceId: 14,
|
|
inPlace: true,
|
|
})
|
|
);
|
|
expect(result.current.status).toBe('idle');
|
|
});
|
|
|
|
it('PR1-05B: a new tour beside the import remembers where it came from', async () => {
|
|
const controller = planner();
|
|
const { result } = renderHook(() => useGpxConversion(controller));
|
|
await act(async () => {
|
|
await result.current.analyze();
|
|
});
|
|
act(() => {
|
|
result.current.adopt('new');
|
|
});
|
|
expect(controller.startDerivedTour).toHaveBeenCalledWith(
|
|
expect.objectContaining({ sourcePlaceId: 14, inPlace: false })
|
|
);
|
|
});
|
|
|
|
it('PR1-05B: nothing is adopted before the suggestion is ready', () => {
|
|
const controller = planner();
|
|
const { result } = renderHook(() => useGpxConversion(controller));
|
|
expect(result.current.adopt('new')).toBe(false);
|
|
expect(controller.startDerivedTour).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('PR1-05A: another import in view starts idle again, and discarding forgets the analysis', async () => {
|
|
const { result, rerender } = renderHook(({ controller }) => useGpxConversion(controller), {
|
|
initialProps: { controller: planner() },
|
|
});
|
|
await act(async () => {
|
|
await result.current.analyze();
|
|
});
|
|
expect(result.current.status).toBe('ready');
|
|
|
|
const other = { ...imported, place_id: 15 };
|
|
rerender({
|
|
controller: planner({
|
|
mode: { type: 'view-gpx', placeId: 15, tour: other },
|
|
readOnlyGpxTour: { tour: other, routeGeometry: null },
|
|
} as Partial<TourPlannerController>),
|
|
});
|
|
expect(result.current.status).toBe('idle');
|
|
|
|
rerender({ controller: planner() });
|
|
await act(async () => {
|
|
await result.current.analyze();
|
|
});
|
|
act(() => result.current.cancel());
|
|
expect(result.current.status).toBe('idle');
|
|
});
|
|
|
|
it('PR1-05A: is not offered without the right to add tours', () => {
|
|
const { result } = renderHook(() => useGpxConversion(planner({ canEdit: false })));
|
|
expect(result.current.available).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('TourConversionSection', () => {
|
|
function Section({ controller }: { controller: TourPlannerController }) {
|
|
const conversion = useGpxConversion(controller);
|
|
return <TourConversionSection conversion={conversion} />;
|
|
}
|
|
|
|
it('PR1-05A: offers the analysis, then the comparison with both ways to take it over', async () => {
|
|
const controller = planner();
|
|
render(<Section controller={controller} />);
|
|
|
|
await userEvent.click(screen.getByRole('button', { name: 'Plan an editable version' }));
|
|
|
|
expect(await screen.findByRole('button', { name: 'Replace the import' })).toBeInTheDocument();
|
|
expect(screen.getByText('Within 50 m of the track')).toBeInTheDocument();
|
|
expect(screen.getByText('100 %')).toBeInTheDocument();
|
|
expect(
|
|
screen.getByText('The suggestion follows the track closely. It is routed anew, so it is not the same line.')
|
|
).toBeInTheDocument();
|
|
|
|
await userEvent.click(screen.getByRole('button', { name: 'Replace the import' }));
|
|
expect(controller.startDerivedTour).toHaveBeenCalledWith(expect.objectContaining({ inPlace: true }));
|
|
expect(toast.info).toHaveBeenCalledWith(
|
|
'Opened in the editor. Save it to replace the import; the recorded track stays restorable.'
|
|
);
|
|
});
|
|
|
|
it('PR1-05B: plans a new tour beside the import on request', async () => {
|
|
const controller = planner();
|
|
render(<Section controller={controller} />);
|
|
await userEvent.click(screen.getByRole('button', { name: 'Plan an editable version' }));
|
|
await userEvent.click(await screen.findByRole('button', { name: 'Plan as new tour' }));
|
|
expect(controller.startDerivedTour).toHaveBeenCalledWith(expect.objectContaining({ inPlace: false }));
|
|
expect(toast.info).toHaveBeenCalledWith('Opened as a new tour. Save it to keep it; the import stays as it is.');
|
|
});
|
|
|
|
it('PR1-05A: warns when the suggestion leaves the track for longer stretches', async () => {
|
|
const elsewhere: [number, number][] = track.map(([lat, lng]) => [lat, lng + 0.01]);
|
|
routing.route.mockResolvedValue({ coordinates: elsewhere, distanceMeters: 2000, durationSeconds: 1800 });
|
|
render(<Section controller={planner()} />);
|
|
await userEvent.click(screen.getByRole('button', { name: 'Plan an editable version' }));
|
|
expect(
|
|
await screen.findByText(
|
|
'The suggestion leaves the track over longer stretches. Check it on the map before you plan with it.'
|
|
)
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it('PR1-05A: reports a failed routing and retries it', async () => {
|
|
routing.route.mockResolvedValueOnce(null);
|
|
render(<Section controller={planner()} />);
|
|
await userEvent.click(screen.getByRole('button', { name: 'Plan an editable version' }));
|
|
expect(await screen.findByText('No route was found through the control points.')).toBeInTheDocument();
|
|
await userEvent.click(screen.getByRole('button', { name: 'Retry' }));
|
|
expect(await screen.findByRole('button', { name: 'Replace the import' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('PR1-05A: discarding returns to the offer', async () => {
|
|
render(<Section controller={planner()} />);
|
|
await userEvent.click(screen.getByRole('button', { name: 'Plan an editable version' }));
|
|
await userEvent.click(await screen.findByRole('button', { name: 'Discard' }));
|
|
expect(screen.getByRole('button', { name: 'Plan an editable version' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('PR1-05A: shows nothing to someone who may not add tours', () => {
|
|
const { container } = render(<Section controller={planner({ canEdit: false })} />);
|
|
expect(container).toBeEmptyDOMElement();
|
|
});
|
|
});
|