mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
test(tours): follow the reworked planner, ferrata and map stage in their suites; keep the map's waypoint list stable between renders
This commit is contained in:
@@ -91,17 +91,18 @@ const ferrata = (
|
||||
id: string,
|
||||
name: string | null,
|
||||
level: number | null,
|
||||
options: { routeName?: string | null; lat?: number; plus?: '+' | null } = {}
|
||||
options: { routeName?: string | null; lat?: number; lng?: number; plus?: '+' | null } = {}
|
||||
): Ferrata => {
|
||||
const lat = options.lat ?? 46.5;
|
||||
const lng = options.lng ?? 11.7;
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
grade: { raw: level === null ? null : String(level), level, plus: options.plus ?? null },
|
||||
routeName: options.routeName ?? null,
|
||||
geometry: [
|
||||
[lat, 11.7],
|
||||
[lat + 0.01, 11.71],
|
||||
[lat, lng],
|
||||
[lat + 0.01, Math.round((lng + 0.01) * 1e6) / 1e6],
|
||||
],
|
||||
osmUrl: `https://www.openstreetmap.org/${id}`,
|
||||
};
|
||||
@@ -141,7 +142,8 @@ describe('FerrataLayerLeaflet', () => {
|
||||
it('FE-FERRATA-LEAFLET-003: the tooltip names the ferrata and its grade range, or falls back to the route and unnamed', () => {
|
||||
const groups = groupFerratas([
|
||||
ferrata('way/1', 'Pisciadù', 2),
|
||||
ferrata('way/2', 'Pisciadù', 4, { lat: 46.51, plus: '+' }),
|
||||
// Picks up where way/1 ends: one ferrata in two segments.
|
||||
ferrata('way/2', 'Pisciadù', 4, { lat: 46.51, lng: 11.71, plus: '+' }),
|
||||
ferrata('way/3', null, 3, { routeName: 'Sentiero attrezzato', lat: 47 }),
|
||||
ferrata('way/4', null, null, { lat: 48 }),
|
||||
]);
|
||||
@@ -183,7 +185,7 @@ describe('FerrataLayerLeaflet', () => {
|
||||
root.style.setProperty('--map-ferrata-lit', 'navy');
|
||||
const groups = groupFerratas([
|
||||
ferrata('way/1', 'Via A', 2),
|
||||
ferrata('way/2', 'Via A', 3, { lat: 46.51 }),
|
||||
ferrata('way/2', 'Via A', 3, { lat: 46.51, lng: 11.71 }),
|
||||
ferrata('way/3', 'Via B', 3, { lat: 47 }),
|
||||
]);
|
||||
const { rerender } = render(<FerrataLayerLeaflet groups={groups} onSelect={vi.fn()} />);
|
||||
|
||||
@@ -49,11 +49,11 @@ describe('routeFerrataCrossings', () => {
|
||||
});
|
||||
|
||||
it('FE-FERRATA-CROSS-004: a short gap in the data does not split the stretch', () => {
|
||||
const route = northFrom([47.4, 11.0], 20);
|
||||
// The route steps 25 m aside for two points, still within the join gap.
|
||||
route[10] = [route[10][0], 11.0004];
|
||||
route[11] = [route[11][0], 11.0004];
|
||||
expect(routeFerrataCrossings(route, groups)).toHaveLength(1);
|
||||
// Two pieces of one ferrata with 44 m missing between them: the route point
|
||||
// in the middle of the hole is out of reach, the ones beside it are not.
|
||||
const pieces = groupFerratas([ferrata(2, [47.4, 11.0], 10), ferrata(3, [47.4 + 14 * STEP, 11.0], 11)]);
|
||||
expect(pieces).toHaveLength(1);
|
||||
expect(routeFerrataCrossings(northFrom([47.4, 11.0], 25), pieces)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -37,14 +37,14 @@ const HIT = 'trek-ferratas-hit';
|
||||
const LABEL = 'trek-ferratas-label';
|
||||
const LAYERS = [CASING, LINE, HIGHLIGHT_CASING, HIGHLIGHT, HIT];
|
||||
|
||||
const ferrata = (id: string, lat: number, name: string): Ferrata => ({
|
||||
const ferrata = (id: string, lat: number, name: string, lng = 11.7): Ferrata => ({
|
||||
id,
|
||||
name,
|
||||
grade: { raw: '2', level: 2, plus: null },
|
||||
routeName: null,
|
||||
geometry: [
|
||||
[lat, 11.7],
|
||||
[lat + 0.01, 11.71],
|
||||
[lat, lng],
|
||||
[lat + 0.01, lng + 0.01],
|
||||
],
|
||||
osmUrl: `https://www.openstreetmap.org/${id}`,
|
||||
});
|
||||
@@ -52,7 +52,7 @@ const ferrata = (id: string, lat: number, name: string): Ferrata => ({
|
||||
/** Two ferratas: way/1 and way/2 are one (same name, touching), way/3 another. */
|
||||
const GROUPS = groupFerratas([
|
||||
ferrata('way/1', 46.5, 'Via A'),
|
||||
ferrata('way/2', 46.51, 'Via A'),
|
||||
ferrata('way/2', 46.51, 'Via A', 11.71),
|
||||
ferrata('way/3', 47, 'Via B'),
|
||||
]);
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('useFerrataMapLayer', () => {
|
||||
expect(result.current.layer.active).toBe(false);
|
||||
expect(result.current.layer.count).toBe(0);
|
||||
expect(result.current.selected).toBeNull();
|
||||
expect(lastCall()).toEqual([7, null, false, null]);
|
||||
expect(lastCall()).toEqual([7, null, false, null, []]);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-MAPLAYER-002: an area reported while off is remembered without a re-render, and used once it is on', () => {
|
||||
|
||||
@@ -61,9 +61,10 @@ describe('ToursSidebar', () => {
|
||||
render(<ToursSidebar tripId={1} days={[]} tours={[listed]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
|
||||
const row = screen.getByRole('option', { name: /Selected ridge walk/i })
|
||||
expect(row).toHaveTextContent('A quiet path above the lake')
|
||||
expect(row).toHaveTextContent('Komoot · komoot.com')
|
||||
expect(row).toHaveTextContent('Komoot')
|
||||
expect(row).not.toHaveTextContent('komoot.com')
|
||||
expect(row).toHaveTextContent('1 h 35 min')
|
||||
expect(row.querySelector('[data-testid="tour-description-preview"]')).toHaveStyle({ WebkitLineClamp: '2' })
|
||||
expect(row.querySelector('[data-testid="tour-description-preview"] > span')).toHaveStyle({ WebkitLineClamp: '2' })
|
||||
})
|
||||
|
||||
it('selects the tour from keyboard activation', () => {
|
||||
|
||||
@@ -116,7 +116,7 @@ describe('TourPlannerToursRail', () => {
|
||||
walkingDurationMinutes: 80, breakAdditionalMinutes: 50, plannedTotalMinutes: 130,
|
||||
})} tours={[tour]} {...railProps} />)
|
||||
|
||||
expect(screen.getByText('Walking time')).toBeInTheDocument()
|
||||
expect(screen.getByText(/^(Estimated walking time|Est. walking time)$/)).toBeInTheDocument()
|
||||
expect(screen.getByText('1 h 20 min')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -219,7 +219,7 @@ describe('TourPlannerToursRail', () => {
|
||||
const onViewGpxTour = vi.fn()
|
||||
render(<TourPlannerToursRail planner={controller} tours={[gpxTour]} {...railProps} onViewGpxTour={onViewGpxTour} />)
|
||||
|
||||
expect(screen.getByRole('option', { name: /Saved ridge walk/i })).toHaveTextContent('GPX import')
|
||||
expect(screen.getByRole('option', { name: /Saved ridge walk/i })).toHaveTextContent('GPX')
|
||||
fireEvent.click(screen.getByRole('option', { name: /Saved ridge walk/i }))
|
||||
expect(onViewGpxTour).not.toHaveBeenCalled()
|
||||
expect(controller.openTour).not.toHaveBeenCalled()
|
||||
@@ -366,20 +366,19 @@ describe('TourPlannerRail', () => {
|
||||
|
||||
expect(screen.queryByTestId('tour-planner-walking-time')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('tour-planner-time-grid')).toHaveClass('grid')
|
||||
expect(screen.getByTestId('tour-planner-time-grid')).toHaveClass('grid-cols-[repeat(auto-fit,minmax(min(100%,12rem),1fr))]')
|
||||
expect(screen.getByTestId('tour-planner-time-grid')).toHaveClass('grid-cols-2')
|
||||
const breaks = screen.getByLabelText('Breaks / additional time')
|
||||
expect(breaks).toHaveValue(35)
|
||||
// A duration field shows "1 h 35 min" and reads minutes, a clock or units back.
|
||||
expect(breaks).toHaveValue('35 min')
|
||||
fireEvent.change(breaks, { target: { value: '50' } })
|
||||
expect(setBreakAdditionalMinutes).toHaveBeenCalledWith(50)
|
||||
fireEvent.change(breaks, { target: { value: '' } })
|
||||
expect(setBreakAdditionalMinutes).toHaveBeenLastCalledWith(null)
|
||||
fireEvent.change(breaks, { target: { value: '50' } })
|
||||
const input = screen.getByLabelText('Planned total duration')
|
||||
expect(input).toHaveValue(95)
|
||||
expect(input).toHaveStyle({ width: '6rem' })
|
||||
expect(screen.getByText('Calculated total')).toBeInTheDocument()
|
||||
expect(input).toHaveValue('1 h 35 min')
|
||||
expect(controller.durationSeconds).toBe(3600)
|
||||
fireEvent.change(input, { target: { value: '125' } })
|
||||
fireEvent.change(input, { target: { value: '2:05' } })
|
||||
expect(setPlannedDurationMinutes).toHaveBeenCalledWith(125)
|
||||
|
||||
rerender(<TourPlannerRail planner={planner({
|
||||
@@ -388,8 +387,8 @@ describe('TourPlannerRail', () => {
|
||||
plannedTotalMinutes: 125, walkingDurationMinutes: 60, breakAdditionalMinutes: 35,
|
||||
})} />)
|
||||
const overriddenInput = screen.getByLabelText(/Planned total duration/)
|
||||
expect(overriddenInput).toHaveValue(125)
|
||||
expect(document.querySelector('label[for="tour-planner-planned-duration"]')).toHaveTextContent('Manual override')
|
||||
expect(overriddenInput).toHaveValue('2 h 5 min')
|
||||
expect(screen.getByTestId('tour-planner-planned-manual')).toHaveTextContent('Manual override')
|
||||
fireEvent.change(overriddenInput, { target: { value: '' } })
|
||||
expect(setPlannedDurationMinutes).toHaveBeenCalledWith(null)
|
||||
|
||||
@@ -602,7 +601,8 @@ describe('TourPlannerRail', () => {
|
||||
render(<TourPlannerRail planner={controller} />)
|
||||
|
||||
const save = screen.getByRole('button', { name: 'Save' })
|
||||
expect(save.parentElement?.parentElement).toHaveTextContent('Routing is planning assistance, not a safety guarantee.')
|
||||
// The safety note lives in the info dot beside the hiking difficulty, as its description.
|
||||
expect(screen.getByRole('button', { name: 'About safety' })).toHaveAccessibleDescription('Routing is planning assistance, not a safety guarantee.')
|
||||
expect(save).toHaveClass('bg-accent', 'text-accent-text')
|
||||
expect(save).toHaveClass('hover:opacity-90')
|
||||
const firstNumber = screen.getByLabelText('Waypoint 1')
|
||||
|
||||
@@ -1644,7 +1644,7 @@ describe('useTourPlanner', () => {
|
||||
expect(result.current.canSave).toBe(false)
|
||||
expect(localStorage.getItem('tour-draft-29')).toBeNull()
|
||||
expect(screen.getByText('Plan a tour')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Tour could not be saved. Your draft is still here.')).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('Tour could not be saved. Your draft is still here.')).not.toBeInTheDocument()
|
||||
await act(async () => { expect(await result.current.save()).toBeNull() })
|
||||
expect(updateTour).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -1669,6 +1669,6 @@ describe('useTourPlanner', () => {
|
||||
|
||||
expect(result.current.editingPlaceId).toBe(savedTour.place_id)
|
||||
expect(result.current.name).toBe('Keep this draft')
|
||||
expect(screen.getByText('Tour could not be saved. Your draft is still here.')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Tour could not be saved. Your draft is still here.')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { usePlaceLanguage } from '../../../hooks/usePlaceLanguage';
|
||||
import { reverseGeocodeRepo } from '../../../repo/reverseGeocodeRepo';
|
||||
|
||||
@@ -55,10 +55,13 @@ export function useWaypointPlaceNames(waypoints: WaypointCoordinate[]): Record<s
|
||||
};
|
||||
}, [lang, signature]);
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
for (const point of waypoints) {
|
||||
const entry = names[point.id];
|
||||
if (entry && entry.key === coordinateKey(point)) result[point.id] = entry.name;
|
||||
}
|
||||
return result;
|
||||
// The same object while the points and their names stay, so a list or a map built on it does not redraw.
|
||||
return useMemo(() => {
|
||||
const result: Record<string, string> = {};
|
||||
for (const point of waypoints) {
|
||||
const entry = names[point.id];
|
||||
if (entry && entry.key === coordinateKey(point)) result[point.id] = entry.name;
|
||||
}
|
||||
return result;
|
||||
}, [names, waypoints]);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ const gl = vi.hoisted(() => {
|
||||
const listeners = new Map<string, Handler[]>();
|
||||
const sources = new Map<string, Record<string, unknown>>();
|
||||
const markers: MarkerStub[] = [];
|
||||
const container = document.createElement('div');
|
||||
const listen = (type: string, fn: Handler) => listeners.set(type, [...(listeners.get(type) ?? []), fn]);
|
||||
const map = {
|
||||
on: vi.fn((type: string, fn: Handler) => {
|
||||
@@ -39,6 +40,14 @@ const gl = vi.hoisted(() => {
|
||||
listen(type, wrapped);
|
||||
return map;
|
||||
}),
|
||||
off: vi.fn((type: string, fn: Handler) => {
|
||||
listeners.set(
|
||||
type,
|
||||
(listeners.get(type) ?? []).filter((other) => other !== fn)
|
||||
);
|
||||
return map;
|
||||
}),
|
||||
getContainer: vi.fn(() => container),
|
||||
remove: vi.fn(),
|
||||
fitBounds: vi.fn(),
|
||||
jumpTo: vi.fn(),
|
||||
|
||||
@@ -144,7 +144,7 @@ describe('Tours English and German copy', () => {
|
||||
['saving', 'tours.planner.status.saving'],
|
||||
] as const) {
|
||||
rerender(<TourPlannerRail planner={planner({ mode: { type: 'new-draft' }, status, error: null })} />)
|
||||
expect(screen.getByText(toursText(de, key))).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(toursText(de, key))).toBeInTheDocument()
|
||||
assertNoRawToursKeys()
|
||||
}
|
||||
rerender(<TourPlannerRail planner={planner({ mode: { type: 'new-draft' }, status: 'ready', error: 'save' })} />)
|
||||
|
||||
@@ -73,9 +73,9 @@ describe('Tours permissions without database setup', () => {
|
||||
|
||||
it.each([[false, false], [true, false], [false, true], [true, true]])('update edit=%s assign=%s writes only with place rights and never assigns', async (canEdit, canAssign) => {
|
||||
vi.useFakeTimers()
|
||||
state.detail.mockResolvedValue({ tour, waypoints: [{ lat: 48, lng: 11, role: 'start' }, { lat: 49, lng: 12, role: 'end' }] })
|
||||
state.route.mockResolvedValue({ coordinates: [[48, 11], [49, 12]], distanceMeters: 1000, durationSeconds: 600 })
|
||||
state.elevation.mockResolvedValue([[48, 11, 500], [49, 12, 550]])
|
||||
state.detail.mockResolvedValue({ tour, waypoints: [{ lat: 48, lng: 11, role: 'start' }, { lat: 48.008, lng: 11.008, role: 'end' }] })
|
||||
state.route.mockResolvedValue({ coordinates: [[48, 11], [48.008, 11.008]], distanceMeters: 1000, durationSeconds: 600 })
|
||||
state.elevation.mockResolvedValue([[48, 11, 500], [48.008, 11.008, 550]])
|
||||
state.update.mockResolvedValue({ tour, waypoints: [] })
|
||||
const saved = vi.fn()
|
||||
const { result } = renderHook(() => useTourPlanner({ tripId: 7, canEdit, canAssign, onSaved: saved }))
|
||||
@@ -159,11 +159,11 @@ describe('Tours permissions without database setup', () => {
|
||||
})
|
||||
it.each([[false, false], [true, false], [false, true], [true, true]])('planner edit=%s assign=%s gates direct create and retained save after revocation', async (canEdit, canAssign) => {
|
||||
vi.useFakeTimers()
|
||||
state.route.mockResolvedValue({ coordinates: [[48, 11], [49, 12]], distanceMeters: 1000, durationSeconds: 600 })
|
||||
state.elevation.mockResolvedValue([[48, 11, 500], [49, 12, 550]])
|
||||
state.route.mockResolvedValue({ coordinates: [[48, 11], [48.008, 11.008]], distanceMeters: 1000, durationSeconds: 600 })
|
||||
state.elevation.mockResolvedValue([[48, 11, 500], [48.008, 11.008, 550]])
|
||||
state.create.mockResolvedValue({ tour, waypoints: [] })
|
||||
const { result, rerender } = renderHook(({ edit }) => useTourPlanner({ tripId: 7, canEdit: edit, canAssign }), { initialProps: { edit: canEdit } })
|
||||
act(() => { result.current.setName('Ridge'); result.current.addWaypoint(48, 11); result.current.addWaypoint(49, 12) })
|
||||
act(() => { result.current.setName('Ridge'); result.current.addWaypoint(48, 11); result.current.addWaypoint(48.008, 11.008) })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(450) })
|
||||
expect(result.current.canSave).toBe(canEdit)
|
||||
expect(result.current.waypoints.length).toBe(canEdit ? 2 : 0)
|
||||
|
||||
Reference in New Issue
Block a user