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 }) => ( +