diff --git a/client/src/components/Ferratas/FerrataLayerLeaflet.test.tsx b/client/src/components/Ferratas/FerrataLayerLeaflet.test.tsx index c95695f50..8ecf46725 100644 --- a/client/src/components/Ferratas/FerrataLayerLeaflet.test.tsx +++ b/client/src/components/Ferratas/FerrataLayerLeaflet.test.tsx @@ -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(); diff --git a/client/src/components/Ferratas/ferrataCrossings.test.ts b/client/src/components/Ferratas/ferrataCrossings.test.ts index ed7d364d3..fd498f5ce 100644 --- a/client/src/components/Ferratas/ferrataCrossings.test.ts +++ b/client/src/components/Ferratas/ferrataCrossings.test.ts @@ -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); }); }); diff --git a/client/src/components/Ferratas/useFerrataLayerGL.test.tsx b/client/src/components/Ferratas/useFerrataLayerGL.test.tsx index 12180b63f..bedd48353 100644 --- a/client/src/components/Ferratas/useFerrataLayerGL.test.tsx +++ b/client/src/components/Ferratas/useFerrataLayerGL.test.tsx @@ -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'), ]); diff --git a/client/src/components/Ferratas/useFerrataMapLayer.test.ts b/client/src/components/Ferratas/useFerrataMapLayer.test.ts index d86e4851f..c076f655a 100644 --- a/client/src/components/Ferratas/useFerrataMapLayer.test.ts +++ b/client/src/components/Ferratas/useFerrataMapLayer.test.ts @@ -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', () => { diff --git a/client/src/components/Tours/ToursSidebar.test.tsx b/client/src/components/Tours/ToursSidebar.test.tsx index 6ca1c2d5a..0f8448c19 100644 --- a/client/src/components/Tours/ToursSidebar.test.tsx +++ b/client/src/components/Tours/ToursSidebar.test.tsx @@ -61,9 +61,10 @@ describe('ToursSidebar', () => { render() 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', () => { diff --git a/client/src/components/Tours/planner/TourPlannerPanels.test.tsx b/client/src/components/Tours/planner/TourPlannerPanels.test.tsx index 8dceacdc5..7d1464bb7 100644 --- a/client/src/components/Tours/planner/TourPlannerPanels.test.tsx +++ b/client/src/components/Tours/planner/TourPlannerPanels.test.tsx @@ -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() - 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( { 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() 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') diff --git a/client/src/components/Tours/planner/useTourPlanner.test.tsx b/client/src/components/Tours/planner/useTourPlanner.test.tsx index 5ecf42f03..287422662 100644 --- a/client/src/components/Tours/planner/useTourPlanner.test.tsx +++ b/client/src/components/Tours/planner/useTourPlanner.test.tsx @@ -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() }) }) diff --git a/client/src/components/Tours/planner/useWaypointPlaceNames.ts b/client/src/components/Tours/planner/useWaypointPlaceNames.ts index f0857d46c..bdbac1926 100644 --- a/client/src/components/Tours/planner/useWaypointPlaceNames.ts +++ b/client/src/components/Tours/planner/useWaypointPlaceNames.ts @@ -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 = {}; - 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 = {}; + 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]); } diff --git a/client/src/components/Tours/terrain/TourTerrainMap.test.tsx b/client/src/components/Tours/terrain/TourTerrainMap.test.tsx index 259dd9e99..df09f3e40 100644 --- a/client/src/components/Tours/terrain/TourTerrainMap.test.tsx +++ b/client/src/components/Tours/terrain/TourTerrainMap.test.tsx @@ -22,6 +22,7 @@ const gl = vi.hoisted(() => { const listeners = new Map(); const sources = new Map>(); 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(), diff --git a/client/src/components/Tours/toursI18n.test.tsx b/client/src/components/Tours/toursI18n.test.tsx index fa62e5734..f074238e4 100644 --- a/client/src/components/Tours/toursI18n.test.tsx +++ b/client/src/components/Tours/toursI18n.test.tsx @@ -144,7 +144,7 @@ describe('Tours English and German copy', () => { ['saving', 'tours.planner.status.saving'], ] as const) { rerender() - expect(screen.getByText(toursText(de, key))).toBeInTheDocument() + expect(screen.getByLabelText(toursText(de, key))).toBeInTheDocument() assertNoRawToursKeys() } rerender() diff --git a/client/src/components/Tours/toursPermissions.test.tsx b/client/src/components/Tours/toursPermissions.test.tsx index 2bcc9cd28..8df225979 100644 --- a/client/src/components/Tours/toursPermissions.test.tsx +++ b/client/src/components/Tours/toursPermissions.test.tsx @@ -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) diff --git a/client/src/pages/TripPlannerPage.tours.wiring.test.tsx b/client/src/pages/TripPlannerPage.tours.wiring.test.tsx index e052a5711..b21fbebe8 100644 --- a/client/src/pages/TripPlannerPage.tours.wiring.test.tsx +++ b/client/src/pages/TripPlannerPage.tours.wiring.test.tsx @@ -203,7 +203,7 @@ describe('TripPlannerPage — shell', () => { toursEnabled: true, places: [], }); - const input = screen.getByRole('combobox', { name: 'reservations.searchLocation' }); + const input = screen.getByRole('combobox', { name: 'tours.planner.mapSearch' }); const mapElement = screen.getByTestId('map-view'); await user.type(input, 'Kyoto'); @@ -245,6 +245,6 @@ describe('TripPlannerPage — shell', () => { view.unmount(); renderPage({ activeTab: 'plan', showPlaceForm: true }); - expect(screen.queryByRole('combobox', { name: 'reservations.searchLocation' })).not.toBeInTheDocument(); + expect(screen.queryByRole('combobox', { name: 'tours.planner.mapSearch' })).not.toBeInTheDocument(); }); }); diff --git a/client/src/pages/tripPlanner/TourPlannerMapStage.test.tsx b/client/src/pages/tripPlanner/TourPlannerMapStage.test.tsx index 7cf071069..3c023bcf1 100644 --- a/client/src/pages/tripPlanner/TourPlannerMapStage.test.tsx +++ b/client/src/pages/tripPlanner/TourPlannerMapStage.test.tsx @@ -5,6 +5,7 @@ import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/ import { useTripFerratas } from '../../components/Ferratas/useTripFerratas'; import type { MapViewProps } from '../../components/Map/mapViewContract'; import { useSettingsStore } from '../../store/settingsStore'; +import { initialTourPlaceToolState, useTourPlaceToolStore } from '../../store/tourPlaceToolStore'; import { TourPlannerMapStage, type TourPlannerMapStageProps } from './TourPlannerMapStage'; type Handler = (event?: unknown) => void; @@ -19,6 +20,21 @@ const stage = vi.hoisted(() => { once: vi.fn(() => map), off: vi.fn(() => map), loaded: vi.fn(() => false), + // The style never finishes in these tests: the ferrata layer waits for it and draws nothing. + isStyleLoaded: vi.fn(() => false), + getContainer: vi.fn(() => document.body), + getLayer: vi.fn(() => undefined), + hasImage: vi.fn(() => false), + addImage: vi.fn(), + removeImage: vi.fn(), + addSource: vi.fn(), + addLayer: vi.fn(), + removeLayer: vi.fn(), + removeSource: vi.fn(), + setFilter: vi.fn(), + setPaintProperty: vi.fn(), + queryRenderedFeatures: vi.fn(() => []), + getCanvas: vi.fn(() => ({ style: {} })), getBounds: vi.fn(() => ({ getSouth: () => 46.5, getWest: () => 11.7, @@ -153,6 +169,7 @@ async function openTerrain() { beforeEach(() => { window.sessionStorage.clear(); + useTourPlaceToolStore.setState(initialTourPlaceToolState()); stage.listeners.clear(); stage.flatMap.props = null; stage.search.props = null; @@ -251,6 +268,7 @@ describe('TourPlannerMapStage', () => { null, false, { south: 46, west: 7, north: 46, east: 7 }, + [], ]); }); @@ -277,8 +295,8 @@ describe('TourPlannerMapStage', () => { const overlay = screen.getByTestId('ferrata-detail-overlay'); // The right panel's 380px plus the 20px the layer pill keeps to the left one. expect(overlay.style.insetInlineEnd).toBe('400px'); - // Above the bottom row: its 12px gap, the 42px pill, the same gap again. - expect(overlay.style.bottom).toBe('66px'); + // On the flat map nothing is under its corner: it rests at the edge gap. + expect(overlay.style.bottom).toBe('12px'); expect(overlay).toHaveTextContent('Via delle Trincee'); expect(stage.flatMap.props?.ferrataLayer?.selectedId).toBe(group.id); diff --git a/client/src/pages/tripPlanner/b4a-wiring.test.ts b/client/src/pages/tripPlanner/b4a-wiring.test.ts index f9000b762..38325b414 100644 --- a/client/src/pages/tripPlanner/b4a-wiring.test.ts +++ b/client/src/pages/tripPlanner/b4a-wiring.test.ts @@ -16,6 +16,8 @@ const hookText = readFileSync(resolve(process.cwd(), 'src/pages/tripPlanner/useT const pageControllerText = readFileSync(resolve(process.cwd(), 'src/pages/tripPlanner/useTripPlannerPage.ts'), 'utf8') // The Tours map and its edit gates live in the map stage the page renders. const mapStageText = readFileSync(resolve(process.cwd(), 'src/pages/tripPlanner/TourPlannerMapStage.tsx'), 'utf8') +// Which clicks place a point is decided by the map stage's points hook. +const mapPointsText = readFileSync(resolve(process.cwd(), 'src/pages/tripPlanner/useTourMapPoints.ts'), 'utf8') const parse = (file: string, text: string) => ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true) const page = parse(pagePath, pageText) const hook = parse(hookPath, hookText) @@ -187,17 +189,18 @@ test('B4A desktop page delegates Tours state and permissions to its page hook', const mapClickGate = collect(mapStage, node => ts.isJsxAttribute(node) && node.name.getText(mapStage) === 'onMapClick' && node.initializer && ts.isJsxExpression(node.initializer) - && !!node.initializer.expression && /canEditRoute|canPlaceEdit/.test(node.initializer.expression.getText(mapStage))) + && !!node.initializer.expression && /onAdd/.test(node.initializer.expression.getText(mapStage))) assert.ok(mapClickGate.length > 0) // The gate the 2D and 3D maps share is derived from the place permission. - assert.match(mapStageText, /const canEditRoute = canPlaceEdit && /) + assert.match(mapStageText, /useTourMapPoints\(\{[\s\S]*?canPlaceEdit,/) + assert.match(mapPointsText, /const canEditRoute = canPlaceEdit && /) }) test('B4A dedicated desktop controller retains GPX, draft, focus, and saved rail wiring', () => { assert.ok(callNamed(pageController, 'useTourPlanner')) assert.ok(pageText.includes('const pageState = useTripPlannerPage()')) assert.ok(pageControllerText.includes("tourPlanner.mode.type === 'view-gpx'")) - for (const mode of ['new-draft', 'edit-saved']) assert.ok(mapStageText.includes(`tourPlanner.mode.type === '${mode}'`)) + for (const mode of ['new-draft', 'edit-saved']) assert.ok(mapPointsText.includes(`tourPlanner.mode.type === '${mode}'`)) assert.match(pageControllerText, /readOnlyGpxAnalysis\?\.routeCoordinates/) assert.match(mapStageText, /routeProfileFocus=\{tourPlanner\.routeProfileFocus\}/) assert.match(pageText, /tourPlanner\.viewGpxTour\(tour, place\?\.route_geometry \?\? null\)/) diff --git a/client/src/pages/tripPlanner/useTourMapPoints.ts b/client/src/pages/tripPlanner/useTourMapPoints.ts index eae305963..e3bb7db3f 100644 --- a/client/src/pages/tripPlanner/useTourMapPoints.ts +++ b/client/src/pages/tripPlanner/useTourMapPoints.ts @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import type { useOutsidePressGuard } from '../../components/Ferratas/useOutsidePressGuard'; import type { TourPlannerController } from '../../components/Tours/planner/useTourPlanner'; import { useWaypointPlaceNames } from '../../components/Tours/planner/useWaypointPlaceNames'; @@ -5,6 +6,7 @@ import { useTranslation } from '../../i18n'; import { usePlacedPointHint } from './usePlacedPointHint'; import { useTourMapPlacing, type TourMapMode } from './useTourMapPlacing'; +const NO_POINTS: never[] = []; const ROLE_KEY = { start: 'tours.planner.start', via: 'tours.planner.via', @@ -44,7 +46,7 @@ export function useTourMapPoints({ tourPlanner, canPlaceEdit, mode, ferrataOpen, const { t } = useTranslation(); const editing = tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved'; // The same lookup as the waypoint list, so a pin is named on hover as its row is. - const placeNames = useWaypointPlaceNames(editing ? tourPlanner.waypoints : []); + const placeNames = useWaypointPlaceNames(editing ? tourPlanner.waypoints : NO_POINTS); const canEditRoute = canPlaceEdit && !tourPlanner.isSaving && editing; const { active, toggle, gate, attach } = useTourMapPlacing(mode); const placeOn = canEditRoute && active; @@ -61,16 +63,23 @@ export function useTourMapPoints({ tourPlanner, canPlaceEdit, mode, ferrataOpen, addWaypoint(lat, lng); }; const onAdd = guard(gate(placeOn ? addPoint : undefined)); - - return { - waypoints: { - points: editing - ? tourPlanner.waypoints.map((point, index) => ({ + // Kept by identity while nothing about the points changed, so the map does not redraw its pins on every render. + const { waypoints } = tourPlanner; + const points = useMemo( + () => + editing + ? waypoints.map((point, index) => ({ ...point, name: placeNames[point.id] ?? t('tours.planner.waypointLabel', { n: index + 1 }), detail: t(ROLE_KEY[point.role]), })) - : [], + : NO_POINTS, + [editing, waypoints, placeNames, t] + ); + + return { + waypoints: { + points, selectedId: editing ? tourPlanner.selectedWaypointId : null, onSelect: tourPlanner.setSelectedWaypointId, onMove: canEditRoute ? tourPlanner.setWaypointPosition : undefined, diff --git a/client/tests/helpers/storeRegistry.ts b/client/tests/helpers/storeRegistry.ts index 9173b7bef..876269d98 100644 --- a/client/tests/helpers/storeRegistry.ts +++ b/client/tests/helpers/storeRegistry.ts @@ -51,4 +51,6 @@ export const UNRESET_STORES: Record = { useServerVersionStore: NOT_YET, useStudioStore: NOT_YET, useSystemNoticeStore: NOT_YET, + useTourFerrataCrossingStore: 'set by the tour map stage; the tests that read it reset it in their own beforeEach', + useTourPlaceToolStore: 'reset in the tour tests themselves to initialTourPlaceToolState()', };