From 160bc89e78d756bba19fb59cd5a36cd5bc3584a9 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 14 Oct 2026 16:21:30 +0200 Subject: [PATCH] feat(ferratas): keep loaded via ferratas in a tile cache on server and device, group segments, own look and count badge --- client/scripts/rtl-disable-baseline.json | 3 +- .../Ferratas/FerrataDetailCard.test.tsx | 79 ++- .../components/Ferratas/FerrataDetailCard.tsx | 66 ++- .../Ferratas/FerrataDetailOverlay.test.tsx | 32 +- .../Ferratas/FerrataDetailOverlay.tsx | 46 +- .../components/Ferratas/FerrataLayerGL.tsx | 13 +- .../Ferratas/FerrataLayerLeaflet.test.tsx | 250 +++++++--- .../Ferratas/FerrataLayerLeaflet.tsx | 226 ++++++--- .../Ferratas/FerrataStatusPill.test.tsx | 39 -- .../components/Ferratas/FerrataStatusPill.tsx | 65 --- .../components/Ferratas/ferrataGroups.test.ts | 149 ++++++ .../src/components/Ferratas/ferrataGroups.ts | 270 +++++++++++ .../components/Ferratas/ferrataHoverLabel.ts | 70 +++ .../components/Ferratas/ferrataIcon.test.ts | 66 +++ client/src/components/Ferratas/ferrataIcon.ts | 79 +++ .../components/Ferratas/ferrataModel.test.ts | 57 +-- .../src/components/Ferratas/ferrataModel.ts | 98 ++-- .../components/Ferratas/ferrataPaint.test.ts | 51 +- .../src/components/Ferratas/ferrataPaint.ts | 80 +++- .../Ferratas/ferrataTileStore.test.ts | 295 ++++++++++++ .../components/Ferratas/ferrataTileStore.ts | 340 +++++++++++++ .../Ferratas/useFerrataLayerGL.test.tsx | 226 ++++++--- .../components/Ferratas/useFerrataLayerGL.ts | 285 ++++++++--- .../Ferratas/useFerrataMapLayer.test.ts | 109 +++-- .../components/Ferratas/useFerrataMapLayer.ts | 78 ++- .../Ferratas/useFerrataPaint.test.ts | 14 +- .../components/Ferratas/useFerrataPaint.ts | 26 +- .../Ferratas/useOutsidePressGuard.test.ts | 34 ++ .../Ferratas/useOutsidePressGuard.ts | 61 +++ .../Ferratas/useTripFerratas.test.ts | 257 +++++----- .../components/Ferratas/useTripFerratas.ts | 232 +++++---- .../components/Map/MapLayerSwitcher.test.tsx | 58 ++- .../src/components/Map/MapLayerSwitcher.tsx | 134 ++++-- .../components/Map/MapView.leaflet.test.tsx | 17 +- .../Map/MapViewGL.plannerWaypoints.test.tsx | 14 +- client/src/components/Map/mapViewContract.ts | 7 +- client/src/components/Planner/planParts.tsx | 2 +- .../Tours/terrain/TourTerrainMap.tsx | 73 +-- .../Tours/terrain/useControlRowFit.test.ts | 52 ++ .../Tours/terrain/useControlRowFit.ts | 68 +++ .../Tours/terrain/useTerrainFerratas.ts | 3 +- client/src/db/offlineDb.ts | 23 +- client/src/index.css | 5 + .../tripPlanner/TourPlannerMapStage.test.tsx | 94 +++- .../pages/tripPlanner/TourPlannerMapStage.tsx | 37 +- client/src/repo/ferrataRepo.test.ts | 70 ++- client/src/repo/ferrataRepo.ts | 79 ++- .../tests/unit/db/offlineDb.upgrade.test.ts | 17 + server/scripts/repository-owners.json | 1 + server/src/db/entities/FerrataTiles.entity.ts | 20 + server/src/db/entities/index.ts | 2 + server/src/db/json-columns.ts | 18 + server/src/db/kysely/FerrataTiles.table.ts | 8 + server/src/db/kysely/db.ts | 2 + ...200_via_ferrata_tiles_outlive_a_restart.ts | 27 ++ .../repositories/FerrataTiles.repository.ts | 30 ++ server/src/nest/ferratas/ferratas.helpers.ts | 117 ++++- server/src/nest/ferratas/ferratas.module.ts | 8 +- server/src/nest/ferratas/ferratas.service.ts | 328 ++++++++++--- server/tests/e2e/ferratas.e2e.test.ts | 16 + server/tests/unit/db/entities-index.test.ts | 9 +- .../tests/unit/db/entity-class-fields.test.ts | 4 +- .../unit/db/ferrata-tiles-migration.test.ts | 67 +++ server/tests/unit/db/known-migrations.test.ts | 1 + server/tests/unit/db/legacy-baseline.test.ts | 1 + .../FerrataTiles.repository.test.ts | 98 ++++ .../tests/unit/nest/ferratas.helpers.test.ts | 126 ++++- .../tests/unit/nest/ferratas.service.test.ts | 453 +++++++++++++++--- shared/src/ferratas/ferrataGrid.spec.ts | 124 +++++ shared/src/ferratas/ferrataGrid.ts | 187 ++++++++ shared/src/i18n/ar/ferratas.ts | 1 + shared/src/i18n/ar/tours.ts | 1 + shared/src/i18n/az/ferratas.ts | 1 + shared/src/i18n/az/tours.ts | 1 + shared/src/i18n/br/ferratas.ts | 1 + shared/src/i18n/br/tours.ts | 1 + shared/src/i18n/ca/ferratas.ts | 1 + shared/src/i18n/ca/tours.ts | 1 + shared/src/i18n/cs/ferratas.ts | 1 + shared/src/i18n/cs/tours.ts | 1 + shared/src/i18n/de/ferratas.ts | 1 + shared/src/i18n/de/tours.ts | 1 + shared/src/i18n/en/ferratas.ts | 1 + shared/src/i18n/en/tours.ts | 1 + shared/src/i18n/es/ferratas.ts | 1 + shared/src/i18n/es/tours.ts | 1 + shared/src/i18n/et/ferratas.ts | 1 + shared/src/i18n/et/tours.ts | 1 + shared/src/i18n/fr/ferratas.ts | 1 + shared/src/i18n/fr/tours.ts | 1 + shared/src/i18n/gr/ferratas.ts | 1 + shared/src/i18n/gr/tours.ts | 1 + shared/src/i18n/hu/ferratas.ts | 1 + shared/src/i18n/hu/tours.ts | 1 + shared/src/i18n/id/ferratas.ts | 1 + shared/src/i18n/id/tours.ts | 1 + shared/src/i18n/it/ferratas.ts | 1 + shared/src/i18n/it/tours.ts | 1 + shared/src/i18n/ja/ferratas.ts | 1 + shared/src/i18n/ja/tours.ts | 1 + shared/src/i18n/ko/ferratas.ts | 1 + shared/src/i18n/ko/tours.ts | 1 + shared/src/i18n/nl/ferratas.ts | 1 + shared/src/i18n/nl/tours.ts | 1 + shared/src/i18n/pl/ferratas.ts | 1 + shared/src/i18n/pl/tours.ts | 1 + shared/src/i18n/ru/ferratas.ts | 1 + shared/src/i18n/ru/tours.ts | 1 + shared/src/i18n/sk/ferratas.ts | 1 + shared/src/i18n/sk/tours.ts | 1 + shared/src/i18n/sv/ferratas.ts | 1 + shared/src/i18n/sv/tours.ts | 1 + shared/src/i18n/th/ferratas.ts | 1 + shared/src/i18n/th/tours.ts | 1 + shared/src/i18n/tours-locales.spec.ts | 4 +- shared/src/i18n/tr/ferratas.ts | 1 + shared/src/i18n/tr/tours.ts | 1 + shared/src/i18n/uk/ferratas.ts | 1 + shared/src/i18n/uk/tours.ts | 1 + shared/src/i18n/vi/ferratas.ts | 1 + shared/src/i18n/vi/tours.ts | 1 + shared/src/i18n/zh-TW/ferratas.ts | 1 + shared/src/i18n/zh-TW/tours.ts | 1 + shared/src/i18n/zh/ferratas.ts | 1 + shared/src/i18n/zh/tours.ts | 1 + shared/src/index.ts | 2 + 126 files changed, 5003 insertions(+), 1153 deletions(-) delete mode 100644 client/src/components/Ferratas/FerrataStatusPill.test.tsx delete mode 100644 client/src/components/Ferratas/FerrataStatusPill.tsx create mode 100644 client/src/components/Ferratas/ferrataGroups.test.ts create mode 100644 client/src/components/Ferratas/ferrataGroups.ts create mode 100644 client/src/components/Ferratas/ferrataHoverLabel.ts create mode 100644 client/src/components/Ferratas/ferrataIcon.test.ts create mode 100644 client/src/components/Ferratas/ferrataIcon.ts create mode 100644 client/src/components/Ferratas/ferrataTileStore.test.ts create mode 100644 client/src/components/Ferratas/ferrataTileStore.ts create mode 100644 client/src/components/Ferratas/useOutsidePressGuard.test.ts create mode 100644 client/src/components/Ferratas/useOutsidePressGuard.ts create mode 100644 client/src/components/Tours/terrain/useControlRowFit.test.ts create mode 100644 client/src/components/Tours/terrain/useControlRowFit.ts create mode 100644 server/src/db/entities/FerrataTiles.entity.ts create mode 100644 server/src/db/kysely/FerrataTiles.table.ts create mode 100644 server/src/db/migrations/Migration20200101043200_via_ferrata_tiles_outlive_a_restart.ts create mode 100644 server/src/db/repositories/FerrataTiles.repository.ts create mode 100644 server/tests/unit/db/ferrata-tiles-migration.test.ts create mode 100644 server/tests/unit/db/repositories/FerrataTiles.repository.test.ts create mode 100644 shared/src/ferratas/ferrataGrid.spec.ts create mode 100644 shared/src/ferratas/ferrataGrid.ts diff --git a/client/scripts/rtl-disable-baseline.json b/client/scripts/rtl-disable-baseline.json index 912ffcce2..c8cee7a9c 100644 --- a/client/scripts/rtl-disable-baseline.json +++ b/client/scripts/rtl-disable-baseline.json @@ -1,5 +1,4 @@ { "pages/tripPlanner/PlanMapStage.tsx": 4, - "mobile/components/trek3d/MascotClip.tsx": 1, - "components/Ferratas/FerrataDetailOverlay.tsx": 1 + "mobile/components/trek3d/MascotClip.tsx": 1 } diff --git a/client/src/components/Ferratas/FerrataDetailCard.test.tsx b/client/src/components/Ferratas/FerrataDetailCard.test.tsx index 586bcfe40..e51da9f5e 100644 --- a/client/src/components/Ferratas/FerrataDetailCard.test.tsx +++ b/client/src/components/Ferratas/FerrataDetailCard.test.tsx @@ -1,71 +1,98 @@ -// FE-FERRATA-CARD-001 to FE-FERRATA-CARD-005: the card for one picked via ferrata. +// FE-FERRATA-CARD-001 to FE-FERRATA-CARD-007: the card for one picked via ferrata. import { describe, expect, it, vi } from 'vitest'; import type { Ferrata } from '@trek/shared'; import { fireEvent, render, screen } from '../../../tests/helpers/render'; import FerrataDetailCard from './FerrataDetailCard'; +import { type FerrataGroup, groupFerratas } from './ferrataGroups'; -const FERRATA: Ferrata = { - id: 'way/77', +/** About 222 m of latitude per segment. */ +const segment = (id: number, from: number, overrides: Partial = {}): Ferrata => ({ + id: `way/${id}`, name: 'Via ferrata Pisciadù', grade: { raw: '5', level: 5, plus: '+' }, routeName: 'Sella group', geometry: [ - [46.53, 11.81], - [46.54, 11.82], + [from, 11.81], + [from + 0.002, 11.81], ], - osmUrl: 'https://www.openstreetmap.org/way/77', -}; + osmUrl: `https://www.openstreetmap.org/way/${id}`, + ...overrides, +}); + +const LOWER = segment(77, 46.53, { grade: { raw: '2', level: 2, plus: null } }); +const UPPER = segment(78, 46.532); + +function groupOf(...segments: Ferrata[]): FerrataGroup { + const [group] = groupFerratas(segments); + if (!group) throw new Error('no group'); + return group; +} describe('FerrataDetailCard', () => { - it('FE-FERRATA-CARD-001: shows the name, the route, the grade badge and the attribution', () => { - render(); + it('FE-FERRATA-CARD-001: shows the name, the route, the grade range and length as badges, and the attribution', () => { + render(); expect(screen.getByRole('heading', { name: 'Via ferrata Pisciadù' })).toBeInTheDocument(); expect(screen.getByText('Sella group')).toBeInTheDocument(); - expect(screen.getByText('K5+')).toHaveClass('text-danger'); + // A badge, not a line colour: the grade is neutral, never the tour's green to red. + expect(screen.getByText('K2 to K5+')).toHaveClass('text-content-secondary'); + expect(screen.getByText('445 m')).toBeInTheDocument(); expect(screen.getByText('Data © OpenStreetMap contributors')).toBeInTheDocument(); }); - it('FE-FERRATA-CARD-002: links to the way on openstreetmap.org in a new tab', () => { - render(); + it('FE-FERRATA-CARD-002: links to the way that was picked on openstreetmap.org, in a new tab', () => { + render(); const link = screen.getByRole('link', { name: /View in OpenStreetMap/ }); - expect(link).toHaveAttribute('href', 'https://www.openstreetmap.org/way/77'); + expect(link).toHaveAttribute('href', 'https://www.openstreetmap.org/way/78'); expect(link).toHaveAttribute('target', '_blank'); expect(link).toHaveAttribute('rel', 'noopener noreferrer'); }); - it('FE-FERRATA-CARD-003: an unnamed way takes its route name as title and does not repeat it', () => { - render(); + it('FE-FERRATA-CARD-003: an unnamed ferrata takes its route name as title and does not repeat it', () => { + const unnamed = segment(77, 46.53, { name: null }); + render(); expect(screen.getByRole('heading', { name: 'Sella group' })).toBeInTheDocument(); expect(screen.getAllByText('Sella group')).toHaveLength(1); }); - it('FE-FERRATA-CARD-004: a letter scale keeps its raw grade, no grade at all reads unknown, both neutral', () => { - const { rerender } = render( - - ); + it('FE-FERRATA-CARD-004: one grade reads once; a letter scale keeps its raw grade, none at all reads unknown', () => { + const { rerender } = render(); + expect(screen.getByText('K5+')).toBeInTheDocument(); + + const letter = segment(80, 46.53, { grade: { raw: 'C/D', level: null, plus: null } }); + rerender(); expect(screen.getByText('C/D')).toHaveClass('text-content-secondary'); - rerender( - - ); + const bare = segment(81, 46.53, { name: null, routeName: null, grade: { raw: null, level: null, plus: null } }); + rerender(); expect(screen.getByText('Grade unknown')).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Unnamed via ferrata' })).toBeInTheDocument(); }); it('FE-FERRATA-CARD-005: offers a close button only when it can be closed', () => { const onClose = vi.fn(); - const { rerender } = render(); + const group = groupOf(UPPER); + const { rerender } = render(); expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument(); - rerender(); + rerender(); fireEvent.click(screen.getByRole('button', { name: 'Close' })); expect(onClose).toHaveBeenCalledTimes(1); }); + + it('FE-FERRATA-CARD-006: separates nothing with a middle dot', () => { + const { container } = render(); + expect(container.textContent).not.toContain('·'); + }); + it('FE-FERRATA-CARD-007: a long name wraps onto a second line instead of being cut to one', () => { + const long = 'Herrmann von Barth Klettersteig über den Wetterstein'; + render(); + const heading = screen.getByRole('heading', { name: long }); + expect(heading).toHaveClass('line-clamp-2'); + expect(heading).not.toHaveClass('truncate'); + }); }); diff --git a/client/src/components/Ferratas/FerrataDetailCard.tsx b/client/src/components/Ferratas/FerrataDetailCard.tsx index c610b63a4..855a32061 100644 --- a/client/src/components/Ferratas/FerrataDetailCard.tsx +++ b/client/src/components/Ferratas/FerrataDetailCard.tsx @@ -1,42 +1,51 @@ -import { ExternalLink, Mountain, X } from 'lucide-react'; +import { ExternalLink, MoveHorizontal, Route, X } from 'lucide-react'; import type { Ferrata } from '@trek/shared'; import { useTranslation } from '../../i18n'; -import { type PillTone, SoftPill } from '../Planner/planParts'; -import { type FerrataTone, ferrataGradeLabel, ferrataGradeTone, ferrataOsmUrl } from './ferrataModel'; - -/** The badge tone per difficulty band; the same bands colour the line on the map. */ -const PILL_TONE: Record = { - easy: 'success', - medium: 'warning', - hard: 'danger', - unknown: 'neutral', -}; +import { useSettingsStore } from '../../store/settingsStore'; +import { formatDistance } from '../../utils/units'; +import { SoftPill } from '../Planner/planParts'; +import { type FerrataGroup, ferrataLabel } from './ferrataGroups'; +import { ferrataOsmUrl } from './ferrataModel'; /** - * One via ferrata, picked on the map: its name, the grade as a badge, the - * route it belongs to and the way back to the source. OpenStreetMap's data is - * ODbL, which asks for the attribution next to anything shown from it, so the - * card carries it rather than relying on the map's own corner. + * One via ferrata, picked on the map: its name, the route it belongs to, its + * grade range and total length as badges, and the way back to the source. + * `segment` is the OSM way that was clicked, which the source link opens. + * OpenStreetMap's data is ODbL, which asks for the attribution next to + * anything shown from it, so the card carries it rather than relying on the + * map's own corner. */ -export default function FerrataDetailCard({ ferrata, onClose }: { ferrata: Ferrata; onClose?: () => void }) { +export default function FerrataDetailCard({ + group, + segment, + onClose, +}: { + group: FerrataGroup; + segment: Ferrata; + onClose?: () => void; +}) { const { t } = useTranslation(); - const name = ferrata.name ?? ferrata.routeName ?? t('ferratas.unnamed'); + const unit = useSettingsStore((s) => s.settings.distance_unit); + const label = ferrataLabel(group, t); + const name = label.name; // The route line only adds something when it is not already the title. - const route = ferrata.routeName && ferrata.routeName !== name ? ferrata.routeName : null; - // A letter scale ("C/D") the contract keeps as `raw` still says more than "unknown". - const grade = ferrataGradeLabel(ferrata.grade) ?? ferrata.grade.raw ?? t('ferratas.unknownGrade'); + const route = group.routeName && group.routeName !== name ? group.routeName : null; + // Without any grade the badge says so, rather than leaving a gap. + const grade = label.grade ?? t('ferratas.unknownGrade'); return (
-
-
- {grade} +
+ {grade} + {group.lengthM > 0 && ( + + )}
{ it('FE-FERRATA-OVERLAY-001: shows nothing while no ferrata is picked', () => { - render(); + render(); expect(screen.queryByTestId('ferrata-detail-overlay')).toBeNull(); }); - it('FE-FERRATA-OVERLAY-002: anchors the card bottom right, clear of the right panel and the control row', () => { - render(); + it('FE-FERRATA-OVERLAY-002: anchors the card at the bottom of the inline end, clear of the panel and the control row', () => { + render(); const overlay = screen.getByTestId('ferrata-detail-overlay'); - expect(overlay.style.right).toBe('400px'); + expect(overlay.style.insetInlineEnd).toBe('400px'); + // Logical only: no physical side, so a right-to-left page mirrors it. + expect(overlay.style.right).toBe(''); + expect(overlay.style.left).toBe(''); expect(overlay.style.bottom).toBe('66px'); expect(overlay).toContainElement(screen.getByTestId('ferrata-detail-card')); }); - it('FE-FERRATA-OVERLAY-003: closes on a press outside it, not on one inside', () => { + it('FE-FERRATA-OVERLAY-003: closes on a press outside it, not on one inside, and reports the outside press', () => { const onClose = vi.fn(); + const onOutsidePress = vi.fn(); render(
- +
); fireEvent.pointerDown(screen.getByText('Via delle Trincee')); expect(onClose).not.toHaveBeenCalled(); + expect(onOutsidePress).not.toHaveBeenCalled(); fireEvent.pointerDown(screen.getByRole('button', { name: 'map' })); expect(onClose).toHaveBeenCalledTimes(1); + expect(onOutsidePress).toHaveBeenCalledTimes(1); }); it('FE-FERRATA-OVERLAY-004: closes on Escape and on its own close button', () => { const onClose = vi.fn(); - render(); + render(); fireEvent.keyDown(document, { key: 'Escape' }); expect(onClose).toHaveBeenCalledTimes(1); diff --git a/client/src/components/Ferratas/FerrataDetailOverlay.tsx b/client/src/components/Ferratas/FerrataDetailOverlay.tsx index 4389ddf4c..704172f7c 100644 --- a/client/src/components/Ferratas/FerrataDetailOverlay.tsx +++ b/client/src/components/Ferratas/FerrataDetailOverlay.tsx @@ -1,37 +1,43 @@ import { useEffect, useRef } from 'react'; -import type { Ferrata } from '@trek/shared'; - import FerrataDetailCard from './FerrataDetailCard'; +import type { FerrataSelection } from './useFerrataMapLayer'; /** - * The picked ferrata's card over the map, anchored bottom right above the row - * of map controls. `insetRight` is what the right panel covers plus the gap the - * layer pill keeps to the left one, so the card mirrors the pill and never - * slides under the panel; `bottom` clears the control row (the layer pill on - * the flat map, the dock and the grade legend in 3D). + * The picked ferrata's card over the map, anchored at the bottom of the inline + * end, above the row of map controls. `insetEnd` is what the panel on that + * side covers plus the gap the layer pill keeps to the panel on the other, so + * the card mirrors the pill and never slides under the panel; `bottom` clears + * the control row (the layer pill on the flat map, the dock and the grade + * legend in 3D). * * It closes on a press anywhere outside it and on Escape. A press on another - * ferrata closes this card first and the click then opens that one's. + * ferrata closes this card first and the click then opens that one's. The + * press outside is handed to `onOutsidePress` as well, so the map under it can + * leave the click that follows alone (see `useOutsidePressGuard`). */ export default function FerrataDetailOverlay({ - ferrata, + selection, onClose, - insetRight, + onOutsidePress, + insetEnd, bottom, }: { - ferrata: Ferrata | null; + selection: FerrataSelection | null; onClose: () => void; - insetRight: number; + onOutsidePress?: () => void; + insetEnd: number; bottom: number; }) { const ref = useRef(null); + const open = !!selection; useEffect(() => { - if (!ferrata) return; + if (!open) return; const onDown = (event: PointerEvent) => { const card = ref.current; if (card && event.target instanceof Node && card.contains(event.target)) return; + onOutsidePress?.(); onClose(); }; const onKey = (event: KeyboardEvent) => { @@ -43,21 +49,17 @@ export default function FerrataDetailOverlay({ document.removeEventListener('pointerdown', onDown, true); document.removeEventListener('keydown', onKey); }; - }, [ferrata, onClose]); + }, [open, onClose, onOutsidePress]); - if (!ferrata) return null; + if (!selection) return null; return (
- +
); } diff --git a/client/src/components/Ferratas/FerrataLayerGL.tsx b/client/src/components/Ferratas/FerrataLayerGL.tsx index e8253eb54..1c4ef1ba8 100644 --- a/client/src/components/Ferratas/FerrataLayerGL.tsx +++ b/client/src/components/Ferratas/FerrataLayerGL.tsx @@ -1,23 +1,24 @@ import type { Map } from 'mapbox-gl'; -import type { Ferrata } from '@trek/shared'; - +import type { FerrataGroup } from './ferrataGroups'; import { type FerrataSelectHandler, useFerrataLayerGL } from './useFerrataLayerGL'; /** * The via ferrata layer as a mountable piece for a GL map view: it renders * nothing itself, the lines live on `map`. See useFerrataLayerGL for the - * layering and the click handling. + * layering, the highlight and the click handling. */ export default function FerrataLayerGL({ map, - ferratas, + groups, onSelect, + selectedId = null, }: { map: Map | null; - ferratas: readonly Ferrata[]; + groups: readonly FerrataGroup[]; onSelect: FerrataSelectHandler; + selectedId?: string | null; }) { - useFerrataLayerGL(map, ferratas, onSelect); + useFerrataLayerGL(map, groups, onSelect, selectedId); return null; } diff --git a/client/src/components/Ferratas/FerrataLayerLeaflet.test.tsx b/client/src/components/Ferratas/FerrataLayerLeaflet.test.tsx index 4ae9ae3ef..d139667ee 100644 --- a/client/src/components/Ferratas/FerrataLayerLeaflet.test.tsx +++ b/client/src/components/Ferratas/FerrataLayerLeaflet.test.tsx @@ -1,22 +1,34 @@ /** - * FE-FERRATA-LEAFLET-001 to FE-FERRATA-LEAFLET-007: the via ferrata layer on + * FE-FERRATA-LEAFLET-001 to FE-FERRATA-LEAFLET-011: the via ferrata layer on * the Leaflet renderer. * - * Leaflet itself is not under test: the mock reflects each polyline's props - * back as data attributes, so the assertions read which lines are drawn, in - * which order and with what options. Casing and line stay non-interactive so - * they cannot swallow clicks meant for places; only the invisible hit line on - * top carries the tooltip and the click. + * Leaflet itself is not under test: the mock reflects each polyline's and + * marker's props back as data attributes, so the assertions read which lines + * are drawn, in which order and with what options. Casing and line stay + * non-interactive so they cannot swallow clicks meant for places; only the + * invisible hit line on top carries the tooltip, the hover and the click. */ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import type { ReactNode } from 'react'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Ferrata } from '@trek/shared'; import FerrataLayerLeaflet from './FerrataLayerLeaflet'; +import { groupFerratas } from './ferrataGroups'; -vi.mock('../../i18n', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +vi.mock('../../i18n', () => ({ + useTranslation: () => ({ + t: (key: string, params?: Record) => + params ? `${key}(${Object.values(params).join(',')})` : key, + }), +})); + +const zoom = { value: 12 }; +const zoomEnd: { handler: (() => void) | null } = { handler: null }; + +type Handlers = Record void) | undefined>; +const at = { latlng: { lat: 46.505, lng: 11.705 } }; vi.mock('react-leaflet', () => ({ Polyline: ({ @@ -28,10 +40,10 @@ vi.mock('react-leaflet', () => ({ children, }: { positions: unknown; - pathOptions: { color: string; weight: number; opacity: number; dashArray?: string }; + pathOptions: { color: string; weight: number; opacity: number; dashArray?: string; className?: string }; interactive?: boolean; bubblingMouseEvents?: boolean; - eventHandlers?: { click?: (event: { latlng: { lat: number; lng: number } }) => void }; + eventHandlers?: Handlers; children?: ReactNode; }) => (
({ data-weight={pathOptions.weight} data-opacity={pathOptions.opacity} data-dash={pathOptions.dashArray ?? ''} + data-class={pathOptions.className ?? ''} data-interactive={interactive === false ? 'false' : 'true'} data-bubbles={bubblingMouseEvents === false ? 'false' : 'true'} - onClick={() => eventHandlers?.click?.({ latlng: { lat: 46.505, lng: 11.705 } })} + onClick={() => eventHandlers?.click?.(at)} + onMouseOver={() => eventHandlers?.mouseover?.(at)} + onMouseOut={() => eventHandlers?.mouseout?.(at)} > {children}
), - Tooltip: ({ children }: { children: ReactNode }) => {children}, + Marker: ({ position, eventHandlers }: { position: [number, number]; eventHandlers?: Handlers }) => ( +