From dcc1c18fa977e7c5d7dced5f7296eacea5554755 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 18 Oct 2026 23:16:41 +0200 Subject: [PATCH] fix(tours): the lint the paddle work grew, back to the baseline The region load and obstacle helpers move out of their component files (paddleRegionLoad.ts, paddleObstaclePlace.ts), so those files export components only (react-refresh). TourPlannerMapStage takes the take-from-card rule out into paddlePlaceFromCard and works out the cards' end inset once (complexity 24, back under 20). The tour tests reach list items through at() and must() instead of non-null assertions, a position change in the shaping test is voided rather than left floating, and PLOAD-004 seeds its regions with Promise.all. --- .../paddle/PaddleObstaclePlaceButton.test.tsx | 3 +- .../paddle/PaddleObstaclePlaceButton.tsx | 23 +----------- .../Tours/paddle/PaddleRegionLoadRow.test.tsx | 3 +- .../Tours/paddle/PaddleRegionLoadRow.tsx | 31 ---------------- .../Tours/paddle/paddleObstaclePlace.ts | 23 ++++++++++++ .../Tours/paddle/paddleRegionLoad.ts | 35 +++++++++++++++++++ .../Tours/planner/TourWaypointList.test.tsx | 15 +++++--- .../Tours/planner/TourWaypointList.tsx | 3 +- .../Tours/planner/paddleRouting.test.ts | 11 ++++-- .../useTourPlanner.routeTools.test.tsx | 24 ++++++++----- .../pages/tripPlanner/TourPlannerMapStage.tsx | 32 +++++++++++------ .../paddle-region-load.service.test.ts | 8 +++-- 12 files changed, 128 insertions(+), 83 deletions(-) create mode 100644 client/src/components/Tours/paddle/paddleObstaclePlace.ts create mode 100644 client/src/components/Tours/paddle/paddleRegionLoad.ts diff --git a/client/src/components/Tours/paddle/PaddleObstaclePlaceButton.test.tsx b/client/src/components/Tours/paddle/PaddleObstaclePlaceButton.test.tsx index acd77fef5..cb483db20 100644 --- a/client/src/components/Tours/paddle/PaddleObstaclePlaceButton.test.tsx +++ b/client/src/components/Tours/paddle/PaddleObstaclePlaceButton.test.tsx @@ -1,7 +1,8 @@ // FE-PADDLE-PLACE-001 to 004: a put-in, slipway or egress set as the next waypoint from its card (PAD-05B). import { describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen } from '../../../../tests/helpers/render'; -import { PaddleObstaclePlaceButton, placeOf } from './PaddleObstaclePlaceButton'; +import { PaddleObstaclePlaceButton } from './PaddleObstaclePlaceButton'; +import { placeOf } from './paddleObstaclePlace'; describe('placeOf', () => { it('FE-PADDLE-PLACE-001: takes a point as it is, the middle of a way and the middle of an outline', () => { diff --git a/client/src/components/Tours/paddle/PaddleObstaclePlaceButton.tsx b/client/src/components/Tours/paddle/PaddleObstaclePlaceButton.tsx index bf93c635f..0e48c2ade 100644 --- a/client/src/components/Tours/paddle/PaddleObstaclePlaceButton.tsx +++ b/client/src/components/Tours/paddle/PaddleObstaclePlaceButton.tsx @@ -2,6 +2,7 @@ import type { PaddleGeometry, PaddleObstacleKind } from '@trek/shared'; import { MapPinPlus } from 'lucide-react'; import { useTranslation } from '../../../i18n'; import { fs } from '../../shared/DialogShell'; +import { placeOf } from './paddleObstaclePlace'; /** Where a paddler gets in or out: these places can become a waypoint from their card. */ const PLACEABLE: Partial> = { @@ -10,28 +11,6 @@ const PLACEABLE: Partial> = { egress: 'egress', }; -/** - * The spot a waypoint goes to: the place itself, the middle of a way (a - * slipway drawn as a ramp), or the middle of an area's outline. - */ -export function placeOf(geometry: PaddleGeometry): { lat: number; lng: number } | null { - const line = - geometry.type === 'Point' - ? [geometry.coordinates] - : geometry.type === 'LineString' - ? geometry.coordinates - : geometry.coordinates[0]; - if (!line?.length) return null; - if (geometry.type === 'Polygon') { - const ring = line.slice(0, -1); - const lng = ring.reduce((sum, [x]) => sum + x, 0) / ring.length; - const lat = ring.reduce((sum, [, y]) => sum + y, 0) / ring.length; - return { lat, lng }; - } - const [lng, lat] = line[Math.floor((line.length - 1) / 2)] ?? []; - return lat === undefined || lng === undefined ? null : { lat, lng }; -} - /** * PAD-05B: a put-in, a slipway or an egress taken over as the tour's next * waypoint from its card on the map, so the point sits where the paddler diff --git a/client/src/components/Tours/paddle/PaddleRegionLoadRow.test.tsx b/client/src/components/Tours/paddle/PaddleRegionLoadRow.test.tsx index 085be1586..88f49f3cd 100644 --- a/client/src/components/Tours/paddle/PaddleRegionLoadRow.test.tsx +++ b/client/src/components/Tours/paddle/PaddleRegionLoadRow.test.tsx @@ -2,7 +2,8 @@ import type { PaddleRegionLoadState } from '@trek/shared'; import { describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen } from '../../../../tests/helpers/render'; -import { isRegionLoadPending, PaddleRegionLoadRow, regionLoadFailure } from './PaddleRegionLoadRow'; +import { PaddleRegionLoadRow } from './PaddleRegionLoadRow'; +import { isRegionLoadPending, regionLoadFailure } from './paddleRegionLoad'; const REF = { id: 'oberpfalz', name: 'Oberpfalz' }; const t = (key: string, params?: Record) => `${key}${params ? JSON.stringify(params) : ''}`; diff --git a/client/src/components/Tours/paddle/PaddleRegionLoadRow.tsx b/client/src/components/Tours/paddle/PaddleRegionLoadRow.tsx index a44d70913..613889365 100644 --- a/client/src/components/Tours/paddle/PaddleRegionLoadRow.tsx +++ b/client/src/components/Tours/paddle/PaddleRegionLoadRow.tsx @@ -5,37 +5,6 @@ import { fs } from '../../shared/DialogShell'; type Translate = ReturnType['t']; -/** The states in which the data is on its way: the leg waits rather than fails. */ -export function isRegionLoadPending( - state: PaddleRegionLoadState | undefined -): state is Extract { - return state?.state === 'loading' || state?.state === 'ready' || state?.state === 'requested'; -} - -/** - * Why a leg stays unplanned once the data cannot come: declined, failed, no - * water near the place, nothing to load. Null where the leg's own reason says - * it best (loading is off, or nothing was asked yet). - */ -export function regionLoadFailure(state: PaddleRegionLoadState | undefined, t: Translate): string | null { - if (!state) return null; - const region = state.region?.name ?? ''; - switch (state.state) { - case 'declined': - return t('tours.paddle.load.declined', { region }); - case 'failed': - return t('tours.paddle.load.failed', { region }); - case 'no_water': - return t('tours.paddle.load.noWater', { region }); - case 'unavailable': - if (state.reason === 'no_extract') return t('tours.paddle.load.noExtract'); - if (state.reason === 'catalog_unavailable') return t('tours.paddle.load.catalogUnavailable'); - return null; - default: - return null; - } -} - function headline(state: Extract, t: Translate) { const region = state.region.name; if (state.state === 'ready') return t('tours.paddle.load.ready', { region }); diff --git a/client/src/components/Tours/paddle/paddleObstaclePlace.ts b/client/src/components/Tours/paddle/paddleObstaclePlace.ts new file mode 100644 index 000000000..f19147581 --- /dev/null +++ b/client/src/components/Tours/paddle/paddleObstaclePlace.ts @@ -0,0 +1,23 @@ +import type { PaddleGeometry } from '@trek/shared'; + +/** + * The spot a waypoint goes to: the place itself, the middle of a way (a + * slipway drawn as a ramp), or the middle of an area's outline. + */ +export function placeOf(geometry: PaddleGeometry): { lat: number; lng: number } | null { + const line = + geometry.type === 'Point' + ? [geometry.coordinates] + : geometry.type === 'LineString' + ? geometry.coordinates + : geometry.coordinates[0]; + if (!line?.length) return null; + if (geometry.type === 'Polygon') { + const ring = line.slice(0, -1); + const lng = ring.reduce((sum, [x]) => sum + x, 0) / ring.length; + const lat = ring.reduce((sum, [, y]) => sum + y, 0) / ring.length; + return { lat, lng }; + } + const [lng, lat] = line[Math.floor((line.length - 1) / 2)] ?? []; + return lat === undefined || lng === undefined ? null : { lat, lng }; +} diff --git a/client/src/components/Tours/paddle/paddleRegionLoad.ts b/client/src/components/Tours/paddle/paddleRegionLoad.ts new file mode 100644 index 000000000..e5789cb5f --- /dev/null +++ b/client/src/components/Tours/paddle/paddleRegionLoad.ts @@ -0,0 +1,35 @@ +import type { PaddleRegionLoadState } from '@trek/shared'; +import type { useTranslation } from '../../../i18n'; + +type Translate = ReturnType['t']; + +/** The states in which the data is on its way: the leg waits rather than fails. */ +export function isRegionLoadPending( + state: PaddleRegionLoadState | undefined +): state is Extract { + return state?.state === 'loading' || state?.state === 'ready' || state?.state === 'requested'; +} + +/** + * Why a leg stays unplanned once the data cannot come: declined, failed, no + * water near the place, nothing to load. Null where the leg's own reason says + * it best (loading is off, or nothing was asked yet). + */ +export function regionLoadFailure(state: PaddleRegionLoadState | undefined, t: Translate): string | null { + if (!state) return null; + const region = state.region?.name ?? ''; + switch (state.state) { + case 'declined': + return t('tours.paddle.load.declined', { region }); + case 'failed': + return t('tours.paddle.load.failed', { region }); + case 'no_water': + return t('tours.paddle.load.noWater', { region }); + case 'unavailable': + if (state.reason === 'no_extract') return t('tours.paddle.load.noExtract'); + if (state.reason === 'catalog_unavailable') return t('tours.paddle.load.catalogUnavailable'); + return null; + default: + return null; + } +} diff --git a/client/src/components/Tours/planner/TourWaypointList.test.tsx b/client/src/components/Tours/planner/TourWaypointList.test.tsx index 4d20820c5..0be1bc1e6 100644 --- a/client/src/components/Tours/planner/TourWaypointList.test.tsx +++ b/client/src/components/Tours/planner/TourWaypointList.test.tsx @@ -10,6 +10,13 @@ import type { PaddleTourRoute } from './paddleRouting'; import TourWaypointList from './TourWaypointList'; import type { TourPlannerController } from './useTourPlanner'; +/** The item at `i`, or a failure that names the index instead of a later undefined. */ +function at(list: readonly T[], i: number): T { + const item = list[i]; + if (item === undefined) throw new Error(`nothing at ${i}`); + return item; +} + vi.mock('./useWaypointPlaceNames', () => ({ useWaypointPlaceNames: () => ({}) })); const START = { id: 'a', lat: 49.1, lng: 12.1, role: 'start' as const }; @@ -67,7 +74,7 @@ describe('TourWaypointList', () => { render(); const rows = screen.getAllByTestId('tour-planner-waypoint-row'); expect(rows).toHaveLength(4); - expect(within(rows[1]!).getByLabelText('Shaping point')).toHaveAttribute('data-waypoint-shaping', 'true'); + expect(within(at(rows, 1)).getByLabelText('Shaping point')).toHaveAttribute('data-waypoint-shaping', 'true'); expect( [...document.querySelectorAll('[data-waypoint-number]')].map((el) => el.getAttribute('data-waypoint-number')) ).toEqual(['1', '2', '3']); @@ -77,7 +84,7 @@ describe('TourWaypointList', () => { it('FE-TOUR-WAYPOINT-LIST-002: the menu plans a routed leg again, draws it by hand, and turns a stop into a shaping point', async () => { const controller = planner({ selectedWaypointId: 'b' }); render(); - const row = screen.getAllByTestId('tour-planner-waypoint-row')[2]!; + const row = at(screen.getAllByTestId('tour-planner-waypoint-row'), 2); await userEvent.click(within(row).getByRole('button', { name: 'More actions' })); await userEvent.click(screen.getByRole('button', { name: 'Plan the next leg again' })); expect(controller.recomputeLeg).toHaveBeenCalledWith('b'); @@ -95,14 +102,14 @@ describe('TourWaypointList', () => { const controller = planner({ selectedWaypointId: 's' }); const { rerender } = render(); await userEvent.click( - within(screen.getAllByTestId('tour-planner-waypoint-row')[1]!).getByRole('button', { name: 'More actions' }) + within(at(screen.getAllByTestId('tour-planner-waypoint-row'), 1)).getByRole('button', { name: 'More actions' }) ); await userEvent.click(screen.getByRole('button', { name: 'Make it a stop' })); expect(controller.setWaypointShaping).toHaveBeenCalledWith('s', false); rerender(); await userEvent.click( - within(screen.getAllByTestId('tour-planner-waypoint-row')[0]!).getByRole('button', { name: 'More actions' }) + within(at(screen.getAllByTestId('tour-planner-waypoint-row'), 0)).getByRole('button', { name: 'More actions' }) ); expect(screen.queryByRole('button', { name: 'Make it a shaping point' })).not.toBeInTheDocument(); }); diff --git a/client/src/components/Tours/planner/TourWaypointList.tsx b/client/src/components/Tours/planner/TourWaypointList.tsx index a99ca9ebf..167734bd9 100644 --- a/client/src/components/Tours/planner/TourWaypointList.tsx +++ b/client/src/components/Tours/planner/TourWaypointList.tsx @@ -37,7 +37,8 @@ import { fs } from '../../shared/DialogShell'; import { Tooltip } from '../../shared/Tooltip'; import { LockIcon, PortageIcon } from '../paddle/PaddleGlyph'; import { AutoPointPill, PaddleLegFailureRow, PaddleLegNotes } from '../paddle/PaddleLegParts'; -import { isRegionLoadPending, PaddleRegionLoadRow, regionLoadFailure } from '../paddle/PaddleRegionLoadRow'; +import { isRegionLoadPending, regionLoadFailure } from '../paddle/paddleRegionLoad'; +import { PaddleRegionLoadRow } from '../paddle/PaddleRegionLoadRow'; import { LockRow, PaddleRolePill, PendingPortageRow, PortageLegRow } from '../paddle/PaddleWaypointParts'; import { usePaddleRegionLoad } from '../paddle/usePaddleRegionLoad'; import { usePaddleWaypointActions } from '../paddle/usePaddleWaypointActions'; diff --git a/client/src/components/Tours/planner/paddleRouting.test.ts b/client/src/components/Tours/planner/paddleRouting.test.ts index abdda712b..95748b54d 100644 --- a/client/src/components/Tours/planner/paddleRouting.test.ts +++ b/client/src/components/Tours/planner/paddleRouting.test.ts @@ -11,6 +11,13 @@ import { type PaddlePoint, } from './paddleRouting'; +/** The item at `i`, or a failure that names the index instead of a later undefined. */ +function at(list: readonly T[], i: number): T { + const item = list[i]; + if (item === undefined) throw new Error(`nothing at ${i}`); + return item; +} + vi.mock('../../../repo/paddleRouteRepo', () => ({ paddleRouteRepo: { plan: vi.fn(), forget: vi.fn() } })); const KAYAK = { boat: 'kayak' as const, lakeStyle: 'direct' as const }; @@ -83,7 +90,7 @@ describe('routePaddleTour', () => { [false, false], ]); expect(route.legLengths[1]).toBe(5500); - expect(route.distanceMeters).toBeCloseTo(route.legLengths[0]! + 5500); + expect(route.distanceMeters).toBeCloseTo(at(route.legLengths, 0) + 5500); expect(route.coordinates[0]).toEqual([A.lat, A.lng]); expect(route.coordinates[route.coordinates.length - 1]).toEqual([C.lat, C.lng]); }); @@ -171,7 +178,7 @@ describe('automatic points', () => { expect(pruneAutoPoints(points)).toEqual(points); const moved = points.map((point) => (point.id === 'b' ? { ...point, lat: point.lat + 0.01 } : point)); expect(pruneAutoPoints(moved).map((point) => point.id)).toEqual(['a', 'b', 'c']); - const shaped = [points[0]!, { id: 's', lat: 49.095, lng: 12.105, shaping: true }, ...points.slice(1)]; + const shaped = [at(points, 0), { id: 's', lat: 49.095, lng: 12.105, shaping: true }, ...points.slice(1)]; expect(pruneAutoPoints(shaped).map((point) => point.id)).toEqual(['a', 's', 'b', 'c']); }); diff --git a/client/src/components/Tours/planner/useTourPlanner.routeTools.test.tsx b/client/src/components/Tours/planner/useTourPlanner.routeTools.test.tsx index 2ab3f2d87..eb1f3a427 100644 --- a/client/src/components/Tours/planner/useTourPlanner.routeTools.test.tsx +++ b/client/src/components/Tours/planner/useTourPlanner.routeTools.test.tsx @@ -24,6 +24,12 @@ vi.mock('../useTourPermissions', () => ({ import { useTourPlanner } from './useTourPlanner'; +/** The value, or a failure saying it is missing instead of a later undefined. */ +function must(value: T | null | undefined): T { + if (value === null || value === undefined) throw new Error('expected a value'); + return value; +} + const routed = { coordinates: [ [48, 11], @@ -393,17 +399,17 @@ describe('useTourPlanner route tools', () => { const [start, end] = result.current.waypoints; let id: string | null = null; act(() => { - id = result.current.insertShapingPoint(start!.id, 48.01, 11.005); + id = result.current.insertShapingPoint(must(start).id, 48.01, 11.005); }); expect(result.current.waypoints.map((point) => !!point.shaping)).toEqual([false, true, false]); expect(result.current.waypoints[1]?.id).toBe(id); act(() => { - expect(result.current.insertShapingPoint(end!.id, 48.03, 11.03)).toBeNull(); - expect(result.current.insertShapingPoint(start!.id, Number.NaN, 11)).toBeNull(); + expect(result.current.insertShapingPoint(must(end).id, 48.03, 11.03)).toBeNull(); + expect(result.current.insertShapingPoint(must(start).id, Number.NaN, 11)).toBeNull(); }); - act(() => result.current.setWaypointShaping(id!, false)); + act(() => result.current.setWaypointShaping(must(id), false)); expect(result.current.waypoints[1]).not.toHaveProperty('shaping'); - act(() => result.current.setWaypointShaping(start!.id, true)); + act(() => result.current.setWaypointShaping(must(start).id, true)); expect(result.current.waypoints[0]).not.toHaveProperty('shaping'); // One undo takes back one change. act(() => result.current.undo()); @@ -425,11 +431,11 @@ describe('useTourPlanner route tools', () => { await settle(); expect(paddlePlan).toHaveBeenCalledTimes(1); const [start, end] = result.current.waypoints; - act(() => result.current.recomputeLeg(start!.id)); + act(() => result.current.recomputeLeg(must(start).id)); expect(paddleForget).toHaveBeenCalledWith(31, start, end, { boat: 'kayak', lakeStyle: 'direct' }); await settle(); expect(paddlePlan).toHaveBeenCalledTimes(2); - act(() => result.current.recomputeLeg(end!.id)); + act(() => result.current.recomputeLeg(must(end).id)); expect(paddleForget).toHaveBeenCalledTimes(1); }); @@ -502,7 +508,9 @@ describe('useTourPlanner route tools', () => { await settle(); await settle(); const end = result.current.waypoints[result.current.waypoints.length - 1]; - act(() => result.current.setWaypointPosition(end.id, 48.2, 11)); + act(() => { + void result.current.setWaypointPosition(end.id, 48.2, 11); + }); await settle(); expect(result.current.waypoints.filter((point) => point.auto).every((point) => point.auto?.to.lat === 48.2)).toBe( true diff --git a/client/src/pages/tripPlanner/TourPlannerMapStage.tsx b/client/src/pages/tripPlanner/TourPlannerMapStage.tsx index 5c3487320..afedb5c60 100644 --- a/client/src/pages/tripPlanner/TourPlannerMapStage.tsx +++ b/client/src/pages/tripPlanner/TourPlannerMapStage.tsx @@ -139,14 +139,7 @@ export function TourPlannerMapStage({ const closingPress = useOutsidePressGuard(); const paddleOpen = usePaddleObstacleStore((state) => state.selectedRef !== null); const adoptFerrata = useFerrataAdopt(tourPlanner, canPlaceEdit, ferrata.close); - // PAD-05B: a put-in, slipway or egress taken over from its card as the tour's next point. - const placeFromCard = - canPlaceEdit && tourPlanner.isPaddle && !tourPlanner.isSaving && tourPlanner.mode.type !== 'view-gpx' - ? (lat: number, lng: number) => { - tourPlanner.addWaypoint(lat, lng); - selectPaddleObstacle(null); - } - : undefined; + const placeFromCard = paddlePlaceFromCard(tourPlanner, canPlaceEdit); const ferrataOnTour = useMemo(() => new Set(ferrataCrossings.map(({ group }) => group.id)), [ferrataCrossings]); const distanceUnit = useSettingsStore((s) => s.settings.distance_unit); // The "set points" tool, per mode; only a real click places, never the end of a drag. @@ -165,6 +158,8 @@ export function TourPlannerMapStage({ // What each panel covers of the map, a collapsed one its rail. const leftInset = leftHidden ? 56 : leftPanelPx; const rightInset = rightHidden ? 56 : rightPanelPx; + // The cards over the map keep clear of the layer switcher, on the side the text ends. + const cardInsetEnd = (isRtlLanguage(language) ? leftInset : rightInset) + MAP_LAYER_SWITCHER_INSET; const bottomRowTop = terrain3d ? CONTROL_GAP + terrainControlsPx + CONTROL_GAP : FERRATA_CARD_BOTTOM; return ( <> @@ -240,14 +235,14 @@ export function TourPlannerMapStage({ selection={ferrataSelected} onClose={ferrata.close} onOutsidePress={closingPress.arm} - insetEnd={(isRtlLanguage(language) ? leftInset : rightInset) + MAP_LAYER_SWITCHER_INSET} + insetEnd={cardInsetEnd} {...ferrataCardPlacement(terrain3d, bottomRowTop, terrainLegend)} onAdopt={adoptFerrata} onTourIds={ferrataOnTour} /> ( <> @@ -319,3 +314,20 @@ export function TourPlannerMapStage({ ); } + +/** + * PAD-05B: a put-in, slipway or egress taken over from its card as the tour's + * next point; undefined where the tour cannot take one now (not a paddle tour, + * saving, a GPX shown read only, or no right to edit). + */ +function paddlePlaceFromCard( + tourPlanner: PageState['tourPlanner'], + canPlaceEdit: boolean +): ((lat: number, lng: number) => void) | undefined { + if (!canPlaceEdit || !tourPlanner.isPaddle || tourPlanner.isSaving || tourPlanner.mode.type === 'view-gpx') + return undefined; + return (lat, lng) => { + tourPlanner.addWaypoint(lat, lng); + selectPaddleObstacle(null); + }; +} diff --git a/server/tests/unit/nest/paddle-obstacles/paddle-region-load.service.test.ts b/server/tests/unit/nest/paddle-obstacles/paddle-region-load.service.test.ts index cb9198a9f..e7f26d7b2 100644 --- a/server/tests/unit/nest/paddle-obstacles/paddle-region-load.service.test.ts +++ b/server/tests/unit/nest/paddle-obstacles/paddle-region-load.service.test.ts @@ -171,9 +171,11 @@ describe('PaddleRegionLoadService', () => { }); it('PLOAD-004: holds TREK to a few regions a day and asks the admins for the next', async () => { - for (let i = 0; i < PADDLE_AUTO_REGIONS_PER_DAY; i += 1) { - await insertRow(orm(), PaddleRegions, { id: `auto-${i}`, name: `Auto ${i}`, origin: 'auto', status: 'ready' }); - } + await Promise.all( + Array.from({ length: PADDLE_AUTO_REGIONS_PER_DAY }, (_, i) => + insertRow(orm(), PaddleRegions, { id: `auto-${i}`, name: `Auto ${i}`, origin: 'auto', status: 'ready' }), + ), + ); const [answer] = await ask(paddler); expect(answer).toMatchObject({ state: 'requested', reason: 'limit' }); });