diff --git a/client/src/components/Admin/paddle/PaddleRegionsPanel.test.tsx b/client/src/components/Admin/paddle/PaddleRegionsPanel.test.tsx new file mode 100644 index 000000000..538479878 --- /dev/null +++ b/client/src/components/Admin/paddle/PaddleRegionsPanel.test.tsx @@ -0,0 +1,320 @@ +// FE-ADMIN-PADDLEREG-001 to FE-ADMIN-PADDLEREG-021: the paddle regions card in the admin area, drawn from a +// controlled usePaddleRegionsAdmin (the hook has its own test). +// FE-ADMIN-PADDLEREG-MODEL-001 to FE-ADMIN-PADDLEREG-MODEL-003: the helpers in paddleRegionsModel. +import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared'; +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, within } from '../../../../tests/helpers/render'; +import { isReading, progressPercent, regionStatusTone } from './paddleRegionsModel'; +import PaddleRegionsPanel from './PaddleRegionsPanel'; +import type { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin'; + +type Admin = ReturnType; + +const hook = vi.hoisted(() => ({ usePaddleRegionsAdmin: vi.fn<() => Admin>() })); +vi.mock('./usePaddleRegionsAdmin', () => ({ usePaddleRegionsAdmin: hook.usePaddleRegionsAdmin })); + +const HINT = 'Weirs, locks and paddling rules from OpenStreetMap, read per region and again every month.'; +const ATTRIBUTION = + 'Data © OpenStreetMap contributors (ODbL), regional extracts by Geofabrik. Read again on the 3rd of every month.'; +const EMPTY = 'No region yet. Paddle tours then show that no obstacle data is known.'; +const CATALOG_DOWN = 'Geofabrik cannot be reached right now. Try again later.'; + +/** A region read once, its data current to the 3rd of October (midday, so every time zone keeps the day). */ +function region(overrides: Partial = {}): PaddleRegion { + return { + id: 'oberpfalz', + name: 'Oberpfalz', + status: 'ready', + source_timestamp: '2026-10-03T12:00:00Z', + imported_at: '2026-10-04T08:00:00Z', + feature_count: 1234, + error: null, + progress: null, + ...overrides, + }; +} + +/** A region before its first read: no data and no date yet. */ +const unread = (overrides: Partial): PaddleRegion => + region({ source_timestamp: null, imported_at: null, feature_count: 0, ...overrides }); + +const READY = region(); +const WAITING = unread({ id: 'donau', name: 'Donau', status: 'queued' }); +const FAILED_AGAIN = region({ id: 'saale', name: 'Saale', status: 'failed', error: 'Download broke off.' }); +const FAILED_FIRST = unread({ id: 'mosel', name: 'Mosel', status: 'failed', error: 'Not a PBF file.' }); + +/** Geofabrik's answers for "Limburg": the plain names told apart by the regions they lie in. */ +const HITS: PaddleRegionCatalogEntry[] = [ + { id: 'germany/hessen/limburg-weilburg', name: 'Limburg-Weilburg', path: ['Europe', 'Germany', 'Hessen'] }, + { id: 'netherlands/limburg', name: 'Limburg', path: ['Europe', 'Netherlands'] }, + { id: 'belgium/limburg', name: 'Limburg', path: ['Europe', 'Belgium'] }, + { id: 'limburg', name: 'Limburg', path: [] }, +]; + +function adminState(overrides: Partial = {}, search: Partial = {}): Admin { + return { + toursOn: true, + offline: false, + regions: [], + loading: false, + error: null, + retry: vi.fn(), + added: new Set(), + busyId: null, + add: vi.fn(async () => {}), + refresh: vi.fn(async () => {}), + removing: null, + askRemove: vi.fn(), + cancelRemove: vi.fn(), + confirmRemove: vi.fn(), + ...overrides, + search: { query: '', setQuery: vi.fn(), hits: null, searching: false, error: null, ...search }, + }; +} + +function show(overrides: Partial = {}, search: Partial = {}) { + const state = adminState(overrides, search); + hook.usePaddleRegionsAdmin.mockReturnValue(state); + return { state, ...render() }; +} + +/** The card's title row, where the number of regions sits beside the title. */ +function titleRow(): HTMLElement { + const row = screen.getByRole('heading', { name: 'Paddle water data' }).parentElement; + if (!row) throw new Error('no title row'); + return row; +} + +function rowOf(name: string): HTMLElement { + const row = screen.getByText(name).closest('[data-testid="paddle-region-row"]'); + if (!row) throw new Error(`no row for ${name}`); + return row; +} + +describe('PaddleRegionsPanel', () => { + it('FE-ADMIN-PADDLEREG-001: renders nothing while the Tours addon is off', () => { + const { container } = show({ toursOn: false, regions: [READY] }); + expect(container).toBeEmptyDOMElement(); + }); + + it('FE-ADMIN-PADDLEREG-002: shows the card with its hint, the attribution and the empty state', () => { + show(); + expect(screen.getByRole('heading', { name: 'Paddle water data' })).toBeInTheDocument(); + expect(screen.getByText(HINT)).toBeInTheDocument(); + expect(screen.getByText(EMPTY)).toBeInTheDocument(); + expect(screen.getByText(ATTRIBUTION)).toBeInTheDocument(); + // No count beside the title while there is no region. + expect(titleRow()).toHaveTextContent(/^Paddle water data$/); + const search = screen.getByRole('searchbox', { name: 'Add a region' }); + expect(search).toBeEnabled(); + expect(search).toHaveAttribute('placeholder', 'Search Geofabrik, for example Oberpfalz'); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.queryByTestId('paddle-region-row')).not.toBeInTheDocument(); + }); + + it('FE-ADMIN-PADDLEREG-003: says the list is loading instead of calling it empty', () => { + show({ loading: true }); + expect(screen.getByRole('status')).toHaveTextContent('Loading...'); + expect(screen.queryByText(EMPTY)).not.toBeInTheDocument(); + }); + + it('FE-ADMIN-PADDLEREG-004: offline it says why and locks the search and every row, without the list error', () => { + show({ offline: true, error: 'Network Error', regions: [READY] }); + expect(screen.getByRole('status')).toHaveTextContent('Regions can only be managed with a connection.'); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.queryByText('Network Error')).not.toBeInTheDocument(); + expect(screen.getByRole('searchbox', { name: 'Add a region' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Read again Oberpfalz' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Remove region Oberpfalz' })).toBeDisabled(); + }); + + it('FE-ADMIN-PADDLEREG-005: a failed list read shows its error with a retry that asks the hook again', () => { + const { state } = show({ error: 'Server unreachable' }); + const alert = screen.getByRole('alert'); + expect(alert).toHaveTextContent('Server unreachable'); + fireEvent.click(within(alert).getByRole('button', { name: 'Try again' })); + expect(state.retry).toHaveBeenCalledTimes(1); + }); + + it('FE-ADMIN-PADDLEREG-006: the search shows the query and hands every change to the hook', () => { + const { state } = show({}, { query: 'Lim' }); + const search = screen.getByRole('searchbox', { name: 'Add a region' }); + expect(search).toHaveValue('Lim'); + fireEvent.change(search, { target: { value: 'Limb' } }); + expect(state.search.setQuery).toHaveBeenCalledWith('Limb'); + }); + + it('FE-ADMIN-PADDLEREG-007: while Geofabrik is searched it says so and shows no verdict yet', () => { + show({}, { query: 'Limburg', searching: true }); + expect(screen.getByRole('status')).toHaveTextContent('Loading...'); + expect(screen.queryByText('No region by that name')).not.toBeInTheDocument(); + expect(screen.queryByTestId('paddle-region-hit')).not.toBeInTheDocument(); + }); + + it('FE-ADMIN-PADDLEREG-008: a failed search shows its error under the field, without the list retry', () => { + show({}, { query: 'Limburg', error: CATALOG_DOWN }); + expect(screen.getByRole('alert')).toHaveTextContent(CATALOG_DOWN); + expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument(); + }); + + it('FE-ADMIN-PADDLEREG-009: a search without hits says no region has that name', () => { + show({}, { query: 'Atlantis', hits: [] }); + expect(screen.getByText('No region by that name')).toBeInTheDocument(); + expect(screen.queryByTestId('paddle-region-hit')).not.toBeInTheDocument(); + }); + + it('FE-ADMIN-PADDLEREG-010: hits name the regions they lie in, outermost first, and mark the ones added', () => { + show({ added: new Set(['germany/hessen/limburg-weilburg']) }, { query: 'Limburg', hits: HITS }); + const [weilburg, dutch, belgian, bare] = screen.getAllByTestId('paddle-region-hit'); + const path = within(weilburg).getAllByText(/^(Europe|Germany|Hessen)$/); + expect(path.map((part) => part.textContent)).toEqual(['Europe', 'Germany', 'Hessen']); + expect(within(weilburg).getByText('Added')).toBeInTheDocument(); + expect(within(weilburg).queryByRole('button', { name: 'Add' })).not.toBeInTheDocument(); + expect(within(dutch).getByText('Netherlands')).toBeInTheDocument(); + expect(within(dutch).getByRole('button', { name: 'Add' })).toBeEnabled(); + expect(within(belgian).getByText('Belgium')).toBeInTheDocument(); + // A region at the top of Geofabrik's tree has nothing above it to name. + expect(within(bare).getByText('Limburg').nextElementSibling).toBeNull(); + }); + + it('FE-ADMIN-PADDLEREG-011: adding a hit hands its Geofabrik id to the hook', () => { + const { state } = show({}, { query: 'Limburg', hits: HITS }); + const dutch = screen.getAllByTestId('paddle-region-hit')[1]; + fireEvent.click(within(dutch).getByRole('button', { name: 'Add' })); + expect(state.add).toHaveBeenCalledWith('netherlands/limburg'); + }); + + it('FE-ADMIN-PADDLEREG-012: while one region is added every add waits, and the one under way spins', () => { + show({ busyId: 'netherlands/limburg' }, { query: 'Limburg', hits: HITS }); + const [, dutch, belgian] = screen.getAllByTestId('paddle-region-hit'); + const adding = within(dutch).getByRole('button', { name: 'Add' }); + const waiting = within(belgian).getByRole('button', { name: 'Add' }); + expect(adding).toBeDisabled(); + expect(waiting).toBeDisabled(); + expect(adding.querySelector('.animate-spin')).not.toBeNull(); + expect(waiting.querySelector('.animate-spin')).toBeNull(); + }); + + it('FE-ADMIN-PADDLEREG-013: a waiting region reads Waiting, has no data yet and cannot be read again', () => { + show({ regions: [WAITING] }); + const row = rowOf('Donau'); + expect(within(row).getByText('Waiting')).toHaveClass('border-edge-faint'); + expect(within(row).queryByText(/objects?$/)).not.toBeInTheDocument(); + expect(within(row).queryByText(/^As of/)).not.toBeInTheDocument(); + expect(within(row).getByRole('button', { name: 'Read again Donau' })).toBeDisabled(); + expect(within(row).getByRole('button', { name: 'Remove region Donau' })).toBeEnabled(); + }); + + it('FE-ADMIN-PADDLEREG-014: a region being read shows its phase and share, the phase alone while the size is unknown', () => { + show({ + regions: [ + unread({ id: 'elbe', name: 'Elbe', status: 'importing', progress: { phase: 'download', fraction: 0.416 } }), + unread({ id: 'main', name: 'Main', status: 'importing', progress: { phase: 'read', fraction: 0 } }), + unread({ id: 'isar', name: 'Isar', status: 'importing', progress: { phase: 'download', fraction: null } }), + unread({ id: 'naab', name: 'Naab', status: 'importing' }), + ], + }); + const elbe = rowOf('Elbe'); + expect(within(elbe).getByText('Downloading 42%')).toHaveClass('bg-accent'); + expect(within(elbe).getByRole('button', { name: 'Read again Elbe' })).toBeDisabled(); + expect(within(elbe).getByRole('button', { name: 'Remove region Elbe' })).toBeEnabled(); + // A read just begun says 0 %, not nothing. + expect(within(rowOf('Main')).getByText('Reading 0%')).toBeInTheDocument(); + expect(within(rowOf('Isar')).getByText('Downloading')).toBeInTheDocument(); + expect(within(rowOf('Naab')).getByText('Reading')).toBeInTheDocument(); + }); + + it('FE-ADMIN-PADDLEREG-015: a ready region shows its object count and the date its data is current to', () => { + show({ regions: [READY, region({ id: 'regen', name: 'Regen', feature_count: 1, source_timestamp: null })] }); + const row = rowOf('Oberpfalz'); + expect(within(row).getByText('Ready')).toHaveClass('bg-success-soft'); + expect(within(row).getByText('1234 objects')).toBeInTheDocument(); + expect(within(row).getByText('As of Oct 3, 2026')).toBeInTheDocument(); + expect(within(row).getByRole('button', { name: 'Read again Oberpfalz' })).toBeEnabled(); + // One object reads singular; without a source date there is no date to show. + const regen = rowOf('Regen'); + expect(within(regen).getByText('1 object')).toBeInTheDocument(); + expect(within(regen).queryByText(/^As of/)).not.toBeInTheDocument(); + }); + + it('FE-ADMIN-PADDLEREG-016: a failed read shows its error, and that the earlier data stays in use when there is some', () => { + show({ regions: [FAILED_AGAIN, FAILED_FIRST] }); + const again = rowOf('Saale'); + expect(within(again).getByText('Failed')).toHaveClass('bg-danger-soft'); + const error = within(again).getByText('Download broke off. The data read before stays in use.'); + expect(error).toHaveAttribute('title', 'Download broke off.'); + const first = rowOf('Mosel'); + expect(within(first).getByText('Not a PBF file.')).toBeInTheDocument(); + expect(within(first).queryByText(/stays in use/)).not.toBeInTheDocument(); + expect(within(first).queryByText(/objects?$/)).not.toBeInTheDocument(); + expect(within(first).getByRole('button', { name: 'Read again Mosel' })).toBeEnabled(); + }); + + it('FE-ADMIN-PADDLEREG-017: Read again and Remove hand the region to the hook', () => { + const { state } = show({ regions: [READY] }); + fireEvent.click(screen.getByRole('button', { name: 'Read again Oberpfalz' })); + expect(state.refresh).toHaveBeenCalledWith('oberpfalz'); + fireEvent.click(screen.getByRole('button', { name: 'Remove region Oberpfalz' })); + expect(state.askRemove).toHaveBeenCalledWith(READY); + // The question opens only once the hook holds the region to remove. + expect(screen.queryByRole('heading', { name: 'Remove region' })).not.toBeInTheDocument(); + }); + + it('FE-ADMIN-PADDLEREG-018: while one region is busy its row dims and no row can be read again or removed', () => { + show({ regions: [READY, FAILED_AGAIN], busyId: 'oberpfalz' }); + expect(rowOf('Oberpfalz')).toHaveClass('opacity-60'); + expect(rowOf('Saale')).not.toHaveClass('opacity-60'); + for (const name of ['Oberpfalz', 'Saale']) { + expect(screen.getByRole('button', { name: `Read again ${name}` })).toBeDisabled(); + expect(screen.getByRole('button', { name: `Remove region ${name}` })).toBeDisabled(); + } + }); + + it('FE-ADMIN-PADDLEREG-019: the title carries the number of regions', () => { + show({ regions: [READY, WAITING, FAILED_FIRST] }); + expect(within(titleRow()).getByText('3')).toBeInTheDocument(); + expect(screen.getAllByTestId('paddle-region-row')).toHaveLength(3); + }); + + it('FE-ADMIN-PADDLEREG-020: removing asks first, naming the region, and confirming hands it to the hook', () => { + const { state } = show({ regions: [READY], removing: READY }); + const message = 'Oberpfalz and its obstacle data are removed. Paddle tours there then show no data.'; + expect(screen.getByRole('heading', { name: 'Remove region' })).toBeInTheDocument(); + expect(screen.getByText(message)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Remove' })); + expect(state.confirmRemove).toHaveBeenCalledTimes(1); + }); + + it('FE-ADMIN-PADDLEREG-021: Cancel and Escape take the question back without removing', () => { + const { state } = show({ regions: [READY], removing: READY }); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(state.cancelRemove).toHaveBeenCalledTimes(2); + expect(state.confirmRemove).not.toHaveBeenCalled(); + }); +}); + +describe('paddleRegionsModel', () => { + it('FE-ADMIN-PADDLEREG-MODEL-001: a region waiting for its read or being read counts as reading', () => { + expect(isReading({ status: 'queued' })).toBe(true); + expect(isReading({ status: 'importing' })).toBe(true); + expect(isReading({ status: 'ready' })).toBe(false); + expect(isReading({ status: 'failed' })).toBe(false); + }); + + it('FE-ADMIN-PADDLEREG-MODEL-002: the share of a read is a whole percent, null while its size is unknown', () => { + expect(progressPercent({ progress: null })).toBeNull(); + expect(progressPercent({ progress: { phase: 'download', fraction: null } })).toBeNull(); + expect(progressPercent({ progress: { phase: 'download', fraction: 0 } })).toBe(0); + expect(progressPercent({ progress: { phase: 'read', fraction: 0.416 } })).toBe(42); + expect(progressPercent({ progress: { phase: 'read', fraction: 1 } })).toBe(100); + }); + + it('FE-ADMIN-PADDLEREG-MODEL-003: the badge tone follows the state of the read, never the water', () => { + expect(regionStatusTone('queued')).toBe('neutral'); + expect(regionStatusTone('importing')).toBe('accent'); + expect(regionStatusTone('ready')).toBe('success'); + expect(regionStatusTone('failed')).toBe('danger'); + }); +}); diff --git a/client/src/components/Tours/paddle/PaddleLayerLeaflet.test.tsx b/client/src/components/Tours/paddle/PaddleLayerLeaflet.test.tsx new file mode 100644 index 000000000..26c6d6b45 --- /dev/null +++ b/client/src/components/Tours/paddle/PaddleLayerLeaflet.test.tsx @@ -0,0 +1,315 @@ +/** + * FE-PADDLE-LEAFLET-001 to FE-PADDLE-LEAFLET-006: the paddle obstacles on the + * Leaflet renderer. + * + * Leaflet itself is not under test: the mock reflects each polyline's, + * marker's and tooltip's props back as data attributes, so the assertions read + * what is drawn, in which order and with what options. Pinned: the objects only + * in the area wait for PADDLE_AREA_MIN_ZOOM unless one is picked, every line + * sits on a white casing that takes no clicks, a line only near the route is + * dashed, the markers grow and rise by how they stand to the route, and a click + * on a marker or a line picks the object without reaching the map underneath. + */ +import L from 'leaflet'; +import type { ReactNode } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { PaddleFeature, PaddleGeometry, PaddleObstacleKind, RelatedObstacle } from '@trek/shared'; + +import { act, fireEvent, render, screen } from '../../../../tests/helpers/render'; +import { PaddleLayerLeaflet } from './PaddleLayerLeaflet'; +import { + PADDLE_AREA_MIN_ZOOM, + paddleMapItems, + paddleMarkerHtml, + type PaddleMapItem, + type PaddleMapLayer, +} from './paddleMapLayer'; +import type { PaddlePaint } from './paddlePaint'; + +/** Test colours standing in for the theme tokens (styles/paddle.css), distinct so each can be told apart. */ +const PAINT: PaddlePaint = { + obstacle: '#334155', + access: '#4338ca', + landing: '#0f766e', + portage: '#9a6233', + disc: '#ffffff', + shadow: 'rgba(15, 23, 42, 0.35)', +}; + +const zoom = { value: 12 }; +const zoomEnd: { handler: (() => void) | null } = { handler: null }; + +/** What Leaflet hands a click handler; stopPropagation marks its original event. */ +const leafletClick = { originalEvent: new MouseEvent('click') }; +type Handlers = Record void) | undefined>; + +vi.mock('react-leaflet', () => ({ + Polyline: ({ + positions, + pathOptions, + interactive, + bubblingMouseEvents, + eventHandlers, + }: { + positions: [number, number][]; + pathOptions: { color: string; weight: number; opacity: number; dashArray?: string; lineCap?: string }; + interactive?: boolean; + bubblingMouseEvents?: boolean; + eventHandlers?: Handlers; + }) => ( +
eventHandlers?.click?.(leafletClick)} + /> + ), + Marker: ({ + position, + icon, + zIndexOffset, + eventHandlers, + children, + }: { + position: [number, number]; + icon: L.DivIcon; + zIndexOffset: number; + eventHandlers: Handlers; + children: ReactNode; + }) => ( + + ), + Tooltip: ({ + children, + direction, + offset, + opacity, + className, + }: { + children: ReactNode; + direction: string; + offset: [number, number]; + opacity: number; + className: string; + }) => ( + + {children} + + ), + useMap: () => ({ getZoom: () => zoom.value }), + useMapEvents: (handlers: { zoomend?: () => void }) => { + zoomEnd.handler = handlers.zoomend ?? null; + return null; + }, +})); + +const feature = ( + ref: string, + kind: PaddleObstacleKind, + geometry: PaddleGeometry, + name: string | null = null +): PaddleFeature => ({ + ref, + kind, + group_key: null, + name, + geometry, + tags: {}, + tag_hash: ref, + region_id: 'oberpfalz', +}); +const lineString = (...coordinates: [number, number][]): PaddleGeometry => ({ type: 'LineString', coordinates }); +const polygon = (...ring: [number, number][]): PaddleGeometry => ({ type: 'Polygon', coordinates: [ring] }); + +/** In GeoJSON [lng, lat]: a weir the route crosses, a lock beside it, a put-in and an access area further off. */ +const WEIR = feature('way/1', 'weir', lineString([12.1, 49.1], [12.1005, 49.1005], [12.101, 49.101]), 'Pielmühle'); +const LOCK = feature('way/2', 'lock', lineString([12.11, 49.11], [12.111, 49.111])); +const PUT_IN = feature('node/3', 'put_in', { type: 'Point', coordinates: [12.12, 49.12] }, 'Etterzhausen'); +const ACCESS = feature('way/4', 'access', polygon([12.13, 49.13], [12.131, 49.13], [12.131, 49.131], [12.13, 49.13])); +const RELATED: RelatedObstacle[] = [ + { feature: WEIR, relation: 'on_route', distanceM: 0, alongM: 1200 }, + { feature: LOCK, relation: 'near', distanceM: 90, alongM: 2400 }, +]; +const ITEMS = paddleMapItems([WEIR, LOCK, PUT_IN, ACCESS], RELATED, PAINT); +const ALL_LABELS = ['Weir: Pielmühle', 'Lock', 'Put-in: Etterzhausen', 'Access rule']; +const ROUTE_LABELS = ['Weir: Pielmühle', 'Lock']; + +const layerOf = ( + items: PaddleMapItem[], + selectedRef: string | null = null, + onSelect: (ref: string) => void = vi.fn() +): PaddleMapLayer => ({ items, selectedRef, onSelect }); + +const lines = () => screen.queryAllByTestId('polyline'); +const pins = () => screen.queryAllByTestId('pin'); +const labels = () => screen.queryAllByTestId('tooltip').map((tip) => tip.textContent); +const attr = (elements: HTMLElement[], name: string) => elements.map((element) => element.dataset[name]); + +const zoomTo = (value: number) => { + zoom.value = value; + act(() => zoomEnd.handler?.()); +}; + +beforeEach(() => { + zoom.value = PADDLE_AREA_MIN_ZOOM - 1; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('PaddleLayerLeaflet', () => { + it('FE-PADDLE-LEAFLET-001: draws nothing without a layer, without items, or with only area objects while zoomed out', () => { + const { container, rerender } = render(); + expect(container).toBeEmptyDOMElement(); + + rerender(); + expect(container).toBeEmptyDOMElement(); + + rerender(); + expect(container).toBeEmptyDOMElement(); + }); + + it('FE-PADDLE-LEAFLET-002: area objects wait for PADDLE_AREA_MIN_ZOOM, join on a zoomend and leave again when zoomed out', () => { + render(); + expect(labels()).toEqual(ROUTE_LABELS); + // A casing and a line each for the weir and the lock. + expect(lines()).toHaveLength(4); + + zoomTo(PADDLE_AREA_MIN_ZOOM); + expect(labels()).toEqual(ALL_LABELS); + // The put-in is a point and draws no line; the access area adds its outline. + expect(lines()).toHaveLength(6); + + zoomTo(PADDLE_AREA_MIN_ZOOM - 1); + expect(labels()).toEqual(ROUTE_LABELS); + expect(lines()).toHaveLength(4); + }); + + it('FE-PADDLE-LEAFLET-003: a picked area object shows at any zoom, larger and on top, until it is no longer picked', () => { + zoom.value = PADDLE_AREA_MIN_ZOOM - 3; + const { rerender } = render(); + expect(labels()).toEqual([...ROUTE_LABELS, 'Access rule']); + expect(lines()).toHaveLength(6); + + const pin = screen.getByRole('button', { name: 'Access rule' }); + expect(pin.dataset.size).toBe('[36,36]'); + expect(pin.dataset.anchor).toBe('[18,18]'); + expect(pin.dataset.z).toBe('900'); + expect(pin.dataset.html).toBe(paddleMarkerHtml(ITEMS[3], true)); + + rerender(); + expect(labels()).toEqual(ROUTE_LABELS); + expect(lines()).toHaveLength(4); + }); + + it('FE-PADDLE-LEAFLET-004: markers sit on the point or mid-line, grow and rise by their relation, and name kind and object', () => { + zoom.value = PADDLE_AREA_MIN_ZOOM; + const { rerender } = render(); + + expect(attr(pins(), 'position')).toEqual([ + '[49.1005,12.1005]', + '[49.111,12.111]', + '[49.12,12.12]', + '[49.131,12.131]', + ]); + expect(attr(pins(), 'size')).toEqual(['[30,30]', '[26,26]', '[22,22]', '[22,22]']); + expect(attr(pins(), 'anchor')).toEqual(['[15,15]', '[13,13]', '[11,11]', '[11,11]']); + expect(attr(pins(), 'z')).toEqual(['600', '300', '300', '300']); + expect(attr(pins(), 'class')).toEqual(['trek-paddle-pin', 'trek-paddle-pin', 'trek-paddle-pin', 'trek-paddle-pin']); + expect(attr(pins(), 'html')).toEqual(ITEMS.map((item) => paddleMarkerHtml(item, false))); + + // The kind, with the object's name when it has one. + expect(labels()).toEqual(ALL_LABELS); + const tip = screen.getAllByTestId('tooltip')[0]; + expect(tip.dataset.direction).toBe('top'); + expect(tip.dataset.offset).toBe('[0,-12]'); + expect(tip.dataset.opacity).toBe('1'); + expect(tip.dataset.class).toBe('map-tooltip'); + + // Picking the weir draws its marker larger and above the rest; the others keep theirs. + rerender(); + expect(attr(pins(), 'size')).toEqual(['[36,36]', '[26,26]', '[22,22]', '[22,22]']); + expect(attr(pins(), 'z')).toEqual(['900', '300', '300', '300']); + expect(pins()[0].dataset.html).toBe(paddleMarkerHtml(ITEMS[0], true)); + }); + + it('FE-PADDLE-LEAFLET-005: every line runs on a white casing that takes no clicks, dashed only while near the route', () => { + zoom.value = PADDLE_AREA_MIN_ZOOM; + render(); + // The casings of the weir, the lock and the access outline first, then their lines on top. + const casings = lines().slice(0, 3); + const drawn = lines().slice(3); + expect(drawn).toHaveLength(3); + + expect(attr(casings, 'color')).toEqual(['#ffffff', '#ffffff', '#ffffff']); + expect(attr(casings, 'weight')).toEqual(['7', '6', '5']); + expect(attr(casings, 'dash')).toEqual(['', '', '']); + expect(attr(casings, 'interactive')).toEqual(['false', 'false', 'false']); + + expect(attr(drawn, 'color')).toEqual([PAINT.obstacle, PAINT.obstacle, PAINT.access]); + expect(attr(drawn, 'weight')).toEqual(['4', '3', '2']); + expect(attr(drawn, 'dash')).toEqual(['', '6 5', '']); + expect(attr(drawn, 'cap')).toEqual(['round', 'round', 'round']); + expect(attr(drawn, 'interactive')).toEqual(['true', 'true', 'true']); + expect(attr(drawn, 'bubbles')).toEqual(['false', 'false', 'false']); + + // Casing and line fade together, by how the object stands to the route. + expect(attr(casings, 'opacity')).toEqual(['1', '0.85', '0.5']); + expect(attr(drawn, 'opacity')).toEqual(['1', '0.85', '0.5']); + + // Both follow the same geometry, turned into [lat, lng]. + expect(attr(casings, 'positions')).toEqual(attr(drawn, 'positions')); + expect(attr(drawn, 'positions')).toEqual([ + '[[49.1,12.1],[49.1005,12.1005],[49.101,12.101]]', + '[[49.11,12.11],[49.111,12.111]]', + '[[49.13,12.13],[49.13,12.131],[49.131,12.131],[49.13,12.13]]', + ]); + }); + + it('FE-PADDLE-LEAFLET-006: a click on a marker or a line picks the object and goes no further, so no waypoint lands under it', () => { + const stop = vi.spyOn(L.DomEvent, 'stopPropagation'); + const onSelect = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Lock' })); + expect(onSelect).toHaveBeenCalledWith('way/2'); + expect(stop).toHaveBeenCalledWith(leafletClick); + expect(stop.mock.invocationCallOrder[0]).toBeLessThan(onSelect.mock.invocationCallOrder[0]); + + // Two casings, then the weir's line and the lock's. + fireEvent.click(lines()[2]); + expect(onSelect).toHaveBeenLastCalledWith('way/1'); + expect(stop).toHaveBeenCalledTimes(2); + + // A casing carries no handler of its own. + fireEvent.click(lines()[0]); + expect(onSelect).toHaveBeenCalledTimes(2); + expect(stop).toHaveBeenCalledTimes(2); + }); +}); diff --git a/client/src/components/Tours/paddle/PaddleObstacleCard.test.tsx b/client/src/components/Tours/paddle/PaddleObstacleCard.test.tsx new file mode 100644 index 000000000..669ebedea --- /dev/null +++ b/client/src/components/Tours/paddle/PaddleObstacleCard.test.tsx @@ -0,0 +1,292 @@ +// FE-PADDLE-CARD-001 to FE-PADDLE-CARD-015: the card for one paddle object picked on the map or in the checklist. +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { PaddleFeature, RelatedObstacle } from '@trek/shared'; + +import { buildSettings } from '../../../../tests/helpers/factories'; +import { fireEvent, render, screen } from '../../../../tests/helpers/render'; +import { seedStore } from '../../../../tests/helpers/store'; +import { useSettingsStore } from '../../../store/settingsStore'; +import PaddleObstacleCard from './PaddleObstacleCard'; +import { paddleObstacleDetail } from './paddleObstacleModel'; + +type Relation = Omit; + +/** Noon UTC, so the date reads the same in every time zone the suite runs in. */ +const AS_OF = '2026-09-01T12:00:00Z'; +const ON_ROUTE: Relation = { relation: 'on_route', distanceM: 0, alongM: 1200 }; +const NEAR: Relation = { relation: 'near', distanceM: 80, alongM: 640 }; + +function weir(overrides: Partial = {}): PaddleFeature { + return { + ref: 'way/29256405', + kind: 'weir', + group_key: null, + name: 'Wehr Pielenhofen', + geometry: { + type: 'LineString', + coordinates: [ + [11.9561, 49.0567], + [11.9566, 49.0571], + ], + }, + tags: { waterway: 'weir' }, + tag_hash: 'h-29256405', + region_id: 'oberpfalz', + ...overrides, + }; +} + +/** What the card is handed for the picked object, worked out by the model the way the store does it. */ +function detailOf(feature: PaddleFeature, related: Relation | null, asOf: string | null = AS_OF) { + const detail = paddleObstacleDetail({ + features: [feature], + related: related ? [{ feature, ...related }] : [], + regions: [{ id: 'oberpfalz', name: 'Oberpfalz', source_timestamp: asOf, imported_at: null }], + selectedRef: feature.ref, + }); + if (!detail) throw new Error(`${feature.ref} is not picked`); + return detail; +} + +/** The weir on the route, carrying only the given tags. */ +function withTags(tags: Record) { + return detailOf(weir({ tags }), ON_ROUTE); +} + +/** The badge a label sits in, which its tooltip hangs on. */ +function badgeOf(label: string): HTMLElement { + const badge = screen.getByText(label).parentElement; + if (!badge) throw new Error(`no badge around ${label}`); + return badge; +} + +/** The white badge beside a label: it holds the value and carries the tone. */ +function valueBeside(label: string): Element { + const value = screen.getByText(label).nextElementSibling; + if (!value) throw new Error(`no value beside ${label}`); + return value; +} + +describe('PaddleObstacleCard', () => { + beforeEach(() => { + // No distance unit chosen: the card reads metric. + seedStore(useSettingsStore, { settings: buildSettings() }); + }); + + it('FE-PADDLE-CARD-001: names the object, says what it is and how far along the route it lies', async () => { + const { container } = render(); + + expect(screen.getByRole('heading', { name: 'Wehr Pielenhofen' })).toBeInTheDocument(); + expect(screen.getByText('Weir')).toBeInTheDocument(); + expect(container.querySelector('[data-paddle-glyph]')).toHaveAttribute('data-paddle-glyph', 'weir'); + expect(valueBeside('On the route at').textContent).toBe('1.2 km'); + expect(screen.getByTestId('paddle-obstacle-relation')).not.toHaveTextContent('Link unclear'); + + fireEvent.mouseEnter(badgeOf('On the route at')); + expect(await screen.findByRole('tooltip')).toHaveTextContent('How far along the route it lies, from the start'); + }); + + it('FE-PADDLE-CARD-002: an unnamed object takes its kind as title and does not repeat it', () => { + const gate = weir({ ref: 'node/77', kind: 'lock_gate', name: null, tags: { 'seamark:type': 'gate' } }); + const { container } = render(); + + expect(screen.getByRole('heading', { name: 'Lock gate' })).toHaveAttribute('title', 'Lock gate'); + expect(screen.getAllByText('Lock gate')).toHaveLength(1); + // A gate is drawn as part of its lock. + expect(container.querySelector('[data-paddle-glyph]')).toHaveAttribute('data-paddle-glyph', 'lock'); + }); + + it('FE-PADDLE-CARD-003: an object only near the route gives its distance and says the link is unclear', async () => { + render(); + + expect(valueBeside('Near the route').textContent).toBe('80 m'); + expect(screen.getByText('Link unclear')).toBeInTheDocument(); + expect(screen.queryByText('On the route at')).not.toBeInTheDocument(); + + fireEvent.mouseEnter(badgeOf('Near the route')); + expect(await screen.findByRole('tooltip')).toHaveTextContent( + 'How far from the route it lies. It may be on another arm of the water.' + ); + }); + + it('FE-PADDLE-CARD-004: an object only in the area gives no distance at all', () => { + render(); + + const relation = screen.getByTestId('paddle-obstacle-relation'); + expect(relation).toHaveTextContent('In the area'); + expect(relation).not.toHaveTextContent('On the route at'); + expect(relation).not.toHaveTextContent('Near the route'); + expect(relation).not.toHaveTextContent('Link unclear'); + }); + + it('FE-PADDLE-CARD-005: reads canoe before boat and words the known values, marking a ban or private water', () => { + const { rerender } = render(); + expect(valueBeside('Canoe').textContent).toBe('no'); + expect(valueBeside('Canoe')).toHaveClass('text-warning'); + expect(screen.queryByText('Boat')).not.toBeInTheDocument(); + + rerender(); + expect(valueBeside('Boat').textContent).toBe('tolerated'); + expect(valueBeside('Boat')).toHaveClass('text-content'); + expect(valueBeside('Boat')).not.toHaveClass('text-warning'); + + rerender(); + expect(valueBeside('Access').textContent).toBe('private'); + expect(valueBeside('Access')).toHaveClass('text-warning'); + + // A value the card has no words for is shown as OpenStreetMap has it. + rerender(); + expect(valueBeside('Canoe').textContent).toBe('portage'); + expect(valueBeside('Canoe')).not.toHaveClass('text-warning'); + }); + + it('FE-PADDLE-CARD-006: no paddling rule reads as unknown, never as allowed', () => { + const bare = withTags({}); + const { rerender } = render(); + const access = screen.getByTestId('paddle-obstacle-access'); + expect(access).toHaveTextContent('No paddling rule known'); + expect(access).not.toHaveTextContent('yes'); + + // A rule that names its tag but carries no value says nothing either. + const unsure = { ...bare.access, source: 'canoe' as const, value: null }; + rerender(); + expect(screen.getByText('No paddling rule known')).toBeInTheDocument(); + expect(screen.queryByText('Canoe')).not.toBeInTheDocument(); + }); + + it('FE-PADDLE-CARD-007: a conditional rule is shown as written, alone or beside its rule', async () => { + const conditional = 'no @ (Nov 01-Mar 31)'; + const { rerender } = render(); + expect(valueBeside('Condition').textContent).toBe(conditional); + expect(screen.queryByText('No paddling rule known')).not.toBeInTheDocument(); + + fireEvent.mouseEnter(badgeOf('Condition')); + expect(await screen.findByRole('tooltip')).toHaveTextContent(conditional); + fireEvent.mouseLeave(badgeOf('Condition')); + + rerender(); + expect(valueBeside('Canoe').textContent).toBe('yes'); + expect(valueBeside('Condition').textContent).toBe(conditional); + }); + + it('FE-PADDLE-CARD-008: names the facts worth knowing, a bare length in the user unit', async () => { + const lock = weir({ + ref: 'way/4711', + kind: 'lock', + name: 'Schleuse Regensburg', + tags: { + height: '3', + width: '12', + opening_hours: 'Mo-Su 06:00-22:00', + seasonal: 'summer', + fee: 'yes', + operator: 'WSA Donau MDK', + 'seamark:type': 'lock_basin', + }, + }); + const { rerender } = render(); + expect(valueBeside('Height').textContent).toBe('3 m'); + expect(valueBeside('Width').textContent).toBe('12 m'); + expect(valueBeside('Hours').textContent).toBe('Mo-Su 06:00-22:00'); + expect(valueBeside('Seasonal').textContent).toBe('summer'); + expect(valueBeside('Fee').textContent).toBe('yes'); + expect(valueBeside('Operator').textContent).toBe('WSA Donau MDK'); + // A tag the card has no label for stays behind. + expect(screen.queryByText('lock_basin')).not.toBeInTheDocument(); + + // The tooltip keeps the tag as OpenStreetMap holds it. + fireEvent.mouseEnter(badgeOf('Width')); + expect(await screen.findByRole('tooltip')).toHaveTextContent(/^12$/); + fireEvent.mouseLeave(badgeOf('Width')); + + const rapids = weir({ + kind: 'rapids', + name: null, + tags: { 'whitewater:section_grade': 'III', height: '1.2 m' }, + }); + rerender(); + expect(valueBeside('Grade').textContent).toBe('III'); + // A length with a unit of its own is not read as metres. + expect(valueBeside('Height').textContent).toBe('1.2 m'); + }); + + it('FE-PADDLE-CARD-009: a portage recorded as private warns that it is a private way', () => { + const portage = weir({ ref: 'way/815', kind: 'portage', name: null, tags: { 'portage:access': 'private' } }); + const { rerender } = render(); + expect(screen.getByRole('heading', { name: 'Portage' })).toBeInTheDocument(); + expect(screen.getByText('Private way')).toHaveClass('text-warning'); + + rerender(); + expect(screen.queryByText('Private way')).not.toBeInTheDocument(); + }); + + it('FE-PADDLE-CARD-010: shows the description OpenStreetMap holds, or else its note, as written', () => { + const description = 'Fish pass on the left bank, the boat chute is closed.'; + const { rerender } = render(); + expect(screen.getByText(description)).toHaveAttribute('title', description); + + rerender(); + expect(screen.getByText('Carry round on the right.')).toBeInTheDocument(); + + rerender(); + expect(screen.queryByText('Carry round on the right.')).not.toBeInTheDocument(); + }); + + it('FE-PADDLE-CARD-011: links to the object on openstreetmap.org and says how current the data is', () => { + const { rerender } = render(); + const link = screen.getByRole('link', { name: 'View in OpenStreetMap' }); + expect(link).toHaveAttribute('href', 'https://www.openstreetmap.org/way/29256405'); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + const dated = 'OpenStreetMap data as of Sep 1, 2026, not a check on site. Data © OpenStreetMap contributors'; + expect(screen.getByText(dated)).toBeInTheDocument(); + + rerender(); + const undated = 'OpenStreetMap data of an unknown date, not a check on site. Data © OpenStreetMap contributors'; + expect(screen.getByText(undated)).toBeInTheDocument(); + }); + + it('FE-PADDLE-CARD-012: offers a close button only when it can be closed', () => { + const onClose = vi.fn(); + const detail = detailOf(weir(), ON_ROUTE); + const { rerender } = render(); + expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument(); + + rerender(); + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('FE-PADDLE-CARD-013: takes the width it is given, and sizes itself to its content without one', () => { + const detail = detailOf(weir(), ON_ROUTE); + const { rerender } = render(); + expect(screen.getByTestId('paddle-obstacle-card')).toHaveClass('w-max'); + + rerender(); + const card = screen.getByTestId('paddle-obstacle-card'); + expect(card).toHaveStyle({ width: '320px' }); + expect(card).not.toHaveClass('w-max'); + }); + + it('FE-PADDLE-CARD-014: puts what the checklist adds below the facts and above the source link', () => { + render( + + + + ); + const added = screen.getByRole('button', { name: 'Portage round it' }); + const facts = screen.getByTestId('paddle-obstacle-access'); + expect(facts.compareDocumentPosition(added)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + expect(added.compareDocumentPosition(screen.getByRole('link'))).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + }); + + it('FE-PADDLE-CARD-015: gives the distances in the unit the user reads', () => { + seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'imperial' }) }); + const { rerender } = render(); + expect(valueBeside('On the route at').textContent).toBe('2 mi'); + + rerender(); + expect(valueBeside('Near the route').textContent).toBe('0.5 mi'); + }); +}); diff --git a/client/src/components/Tours/paddle/PaddleObstacleNotice.test.tsx b/client/src/components/Tours/paddle/PaddleObstacleNotice.test.tsx new file mode 100644 index 000000000..815174123 --- /dev/null +++ b/client/src/components/Tours/paddle/PaddleObstacleNotice.test.tsx @@ -0,0 +1,187 @@ +// FE-COMP-PADDLENOTICE-001 to FE-COMP-PADDLENOTICE-011: the one line on what TREK knows about the water. +import type { PaddleCoverageRegion, RelatedObstacle } from '@trek/shared'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { act, render, screen } from '../../../../tests/helpers/render'; +import { resetAllStores, seedStore } from '../../../../tests/helpers/store'; +import { clearPaddleObstacles, publishPaddleObstacles } from '../../../store/paddleObstacleStore'; +import { useSettingsStore } from '../../../store/settingsStore'; +import { PaddleObstacleNotice } from './PaddleObstacleNotice'; + +type Published = Parameters[0]; + +const LOADING = 'Loading the obstacles along the route …'; +const OFFLINE = 'Without a connection TREK loads no obstacle data. Check the water yourself.'; +const FAILED = 'The obstacle data could not be loaded. Check the water yourself.'; +const NO_DATA = 'No obstacle data for this area. Check weirs, locks and portages here yourself.'; +const TRUNCATED = 'TREK shows only part of the objects here; the area holds more. Check the water yourself.'; +const UNKNOWN_DATE = 'an unknown date'; + +const partialLine = (date: string): string => + `Obstacle data covers only part of the route (as of ${date}). Check the rest yourself.`; +const coveredLine = (count: number, date: string): string => + `${count} obstacles and access points along the route, from OpenStreetMap as of ${date}. The data can have gaps, so check the water yourself.`; +const coveredOneLine = (date: string): string => + `1 obstacle or access point along the route, from OpenStreetMap as of ${date}. The data can have gaps, so check the water yourself.`; +const coveredEmptyLine = (date: string): string => + `No known obstacles along the route (OpenStreetMap as of ${date}). Check the water yourself all the same.`; + +/** + * A region TREK holds data for. Its date is local noon, so it reads as the + * same day in whatever time zone the suite runs. + */ +const region = (id: string, sourceTimestamp: string | null): PaddleCoverageRegion => ({ + id, + name: `Region ${id}`, + source_timestamp: sourceTimestamp, + imported_at: '2026-10-01T08:00:00Z', +}); + +/** A weir the route runs over, `alongM` metres from its start. */ +const weir = (ref: string, alongM: number): RelatedObstacle => ({ + feature: { + ref, + kind: 'weir', + group_key: null, + name: null, + geometry: { type: 'Point', coordinates: [13.4, 52.5] }, + tags: { waterway: 'weir' }, + tag_hash: `hash-${ref}`, + region_id: 'brandenburg', + }, + relation: 'on_route', + distanceM: 0, + alongM, +}); + +/** Publishes a ready answer for a route one region covers wholly, with nothing on it; `overrides` change that. */ +function publish(overrides: Partial = {}): void { + publishPaddleObstacles({ + status: 'ready', + features: [], + related: [], + routeLengthM: 8000, + coverage: 'covered', + regions: [region('brandenburg', '2026-06-15T12:00:00')], + truncated: false, + ...overrides, + }); +} + +beforeEach(() => { + resetAllStores(); + // The data date reads in the user's language. + seedStore(useSettingsStore, { settings: { language: 'en' } }); +}); + +describe('PaddleObstacleNotice', () => { + it('FE-COMP-PADDLENOTICE-001: before anything is read it says the obstacles are loading, as a status with a spinner', () => { + render(); + + expect(screen.getByRole('status')).toHaveTextContent(LOADING); + expect(screen.getByRole('status').querySelector('svg.animate-spin')).not.toBeNull(); + }); + + it('FE-COMP-PADDLENOTICE-002: offline it says no data is loaded, even while the store still holds a covered answer', () => { + publish({ status: 'offline', related: [weir('node/1', 500)] }); + render(); + + expect(screen.getByText(OFFLINE)).toBeInTheDocument(); + // A warning, not a status line: there is nothing to rely on. + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + it('FE-COMP-PADDLENOTICE-003: a failed read says the data could not be loaded', () => { + publish({ status: 'error' }); + render(); + + expect(screen.getByText(FAILED)).toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + it('FE-COMP-PADDLENOTICE-004: an area without data says to check the water there instead of reading as open water', () => { + publish({ coverage: 'none', regions: [], truncated: true }); + render(); + + expect(screen.getByText(NO_DATA)).toBeInTheDocument(); + expect(screen.queryByText(TRUNCATED)).not.toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + it('FE-COMP-PADDLENOTICE-005: a partly covered route names the oldest date among its regions', () => { + publish({ + coverage: 'partial', + // Out of order and one without a date: the route is only as current as its oldest dated part. + regions: [region('spree', '2026-06-20T12:00:00'), region('havel', '2026-05-10T12:00:00'), region('oder', null)], + truncated: true, + }); + render(); + + expect(screen.getByText(partialLine('May 10, 2026'))).toBeInTheDocument(); + // That only part of the route is covered matters more than an answer cut at its limit. + expect(screen.queryByText(TRUNCATED)).not.toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + it('FE-COMP-PADDLENOTICE-006: without a dated region the date reads as unknown', () => { + publish({ coverage: 'partial', regions: [region('spree', null), region('havel', '')] }); + render(); + + expect(screen.getByText(partialLine(UNKNOWN_DATE))).toBeInTheDocument(); + }); + + it('FE-COMP-PADDLENOTICE-007: an answer cut at its limit says the area holds more instead of counting', () => { + publish({ truncated: true, related: [weir('node/1', 500), weir('node/2', 2500)] }); + render(); + + expect(screen.getByText(TRUNCATED)).toBeInTheDocument(); + expect(screen.queryByText(/obstacles and access points/)).not.toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + it('FE-COMP-PADDLENOTICE-008: a covered route counts what lies on or near it, with the date, as a status', () => { + const related: RelatedObstacle[] = [ + weir('node/1', 500), + weir('way/2', 2500), + { ...weir('node/3', 6000), relation: 'near', distanceM: 90 }, + ]; + // A fourth object is read in the area but lies off the route, so it is not counted. + publish({ features: [...related.map((item) => item.feature), weir('node/4', 0).feature], related }); + render(); + + expect(screen.getByRole('status')).toHaveTextContent(coveredLine(3, 'Jun 15, 2026')); + }); + + it('FE-COMP-PADDLENOTICE-009: one object on the route reads in the singular', () => { + publish({ related: [weir('node/1', 500)] }); + render(); + + expect(screen.getByRole('status')).toHaveTextContent(coveredOneLine('Jun 15, 2026')); + }); + + it('FE-COMP-PADDLENOTICE-010: a covered route with nothing on it still says to check the water, dated or not', () => { + publish(); + render(); + expect(screen.getByRole('status')).toHaveTextContent(coveredEmptyLine('Jun 15, 2026')); + + act(() => { + publish({ regions: [region('brandenburg', null)] }); + }); + expect(screen.getByRole('status')).toHaveTextContent(coveredEmptyLine(UNKNOWN_DATE)); + }); + + it('FE-COMP-PADDLENOTICE-011: follows the store from loading to the answer, and back when the tour closes', () => { + publish({ status: 'loading' }); + render(); + expect(screen.getByRole('status')).toHaveTextContent(LOADING); + + act(() => { + publish({ related: [weir('node/1', 500)] }); + }); + expect(screen.getByRole('status')).toHaveTextContent(coveredOneLine('Jun 15, 2026')); + + act(() => { + clearPaddleObstacles(); + }); + expect(screen.getByRole('status')).toHaveTextContent(LOADING); + }); +}); diff --git a/client/src/components/Tours/paddle/PaddleObstacleOverlay.test.tsx b/client/src/components/Tours/paddle/PaddleObstacleOverlay.test.tsx new file mode 100644 index 000000000..ffb69cc17 --- /dev/null +++ b/client/src/components/Tours/paddle/PaddleObstacleOverlay.test.tsx @@ -0,0 +1,233 @@ +// FE-COMP-PADDLEOVERLAY-001 to FE-COMP-PADDLEOVERLAY-008: the picked paddle object's card over the planner map, +// opened and closed through the obstacle store, and the ways it closes. +import type { ComponentProps } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { PaddleCoverageRegion, PaddleFeature, RelatedObstacle } from '@trek/shared'; + +import { act, fireEvent, render, screen } from '../../../../tests/helpers/render'; +import { resetAllStores } from '../../../../tests/helpers/store'; +import { + publishPaddleObstacles, + selectPaddleObstacle, + usePaddleObstacleStore, +} from '../../../store/paddleObstacleStore'; +import PaddleObstacleOverlay from './PaddleObstacleOverlay'; + +const REGION: PaddleCoverageRegion = { + id: 'hessen', + name: 'Hessen', + source_timestamp: '2026-06-01T12:00:00Z', + imported_at: '2026-06-02T08:00:00Z', +}; + +const WEIR: PaddleFeature = { + ref: 'way/101', + kind: 'weir', + group_key: null, + name: 'Hammer weir', + geometry: { + type: 'LineString', + coordinates: [ + [8.1, 50.1], + [8.101, 50.1], + ], + }, + tags: { waterway: 'weir' }, + tag_hash: 'weir-hash', + region_id: 'hessen', +}; + +/** Unnamed and from a region the answer does not hold, so its card reads its kind and an unknown date. */ +const SLIPWAY: PaddleFeature = { + ref: 'node/202', + kind: 'slipway', + group_key: null, + name: null, + geometry: { type: 'Point', coordinates: [8.2, 50.2] }, + tags: { leisure: 'slipway' }, + tag_hash: 'slipway-hash', + region_id: 'elsewhere', +}; + +const WEIR_ON_ROUTE: RelatedObstacle = { feature: WEIR, relation: 'on_route', distanceM: 4, alongM: 2500 }; + +function publish(features: PaddleFeature[], related: RelatedObstacle[] = []): void { + publishPaddleObstacles({ + status: 'ready', + features, + related, + routeLengthM: 12_000, + coverage: 'covered', + regions: [REGION], + truncated: false, + }); +} + +function renderOverlay(props: Partial> = {}): void { + render( +
+ + +
+ ); +} + +const overlay = () => screen.queryByTestId('paddle-obstacle-overlay'); +const selectedRef = () => usePaddleObstacleStore.getState().selectedRef; +const pressMap = () => fireEvent.pointerDown(screen.getByRole('button', { name: 'map' })); + +describe('PaddleObstacleOverlay', () => { + beforeEach(() => { + resetAllStores(); + }); + + it('FE-COMP-PADDLEOVERLAY-001: shows nothing and leaves presses and keys alone while no known object is picked', () => { + const onOutsidePress = vi.fn(); + publish([WEIR]); + renderOverlay({ onOutsidePress }); + expect(overlay()).toBeNull(); + + // A pick the store holds no object for opens nothing either. + act(() => selectPaddleObstacle('node/999')); + expect(overlay()).toBeNull(); + + pressMap(); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(onOutsidePress).not.toHaveBeenCalled(); + expect(selectedRef()).toBe('node/999'); + }); + + it('FE-COMP-PADDLEOVERLAY-002: anchors the card of the pick at the bottom of the inline end, clear of the panel', () => { + publish([WEIR], [WEIR_ON_ROUTE]); + selectPaddleObstacle(WEIR.ref); + renderOverlay({ width: 320 }); + + const shown = screen.getByTestId('paddle-obstacle-overlay'); + expect(shown.style.insetInlineEnd).toBe('400px'); + // Logical only: no physical side, so a right-to-left page mirrors it. + expect(shown.style.right).toBe(''); + expect(shown.style.left).toBe(''); + expect(shown.style.bottom).toBe('66px'); + expect(shown.style.maxWidth).toBe('calc(100% - 432px)'); + + const card = screen.getByTestId('paddle-obstacle-card'); + expect(shown).toContainElement(card); + expect(card.style.width).toBe('320px'); + expect(screen.getByRole('heading', { name: 'Hammer weir' })).toBeInTheDocument(); + expect(screen.getByText('Weir')).toBeInTheDocument(); + // The relation and the date of the data come out of the same store as the pick. + expect(screen.getByText('On the route at')).toBeInTheDocument(); + expect(screen.getByText(/^OpenStreetMap data as of /)).toBeInTheDocument(); + }); + + it('FE-COMP-PADDLEOVERLAY-003: closes on a press outside it and hands that press on, never on a press inside it', () => { + const onOutsidePress = vi.fn(); + publish([WEIR]); + selectPaddleObstacle(WEIR.ref); + renderOverlay({ onOutsidePress }); + + fireEvent.pointerDown(screen.getByRole('heading', { name: 'Hammer weir' })); + expect(onOutsidePress).not.toHaveBeenCalled(); + expect(selectedRef()).toBe(WEIR.ref); + expect(overlay()).toBeInTheDocument(); + + pressMap(); + expect(onOutsidePress).toHaveBeenCalledTimes(1); + expect(selectedRef()).toBeNull(); + expect(overlay()).toBeNull(); + + // Once closed it stops listening, so the next press on the map is the map's alone. + pressMap(); + expect(onOutsidePress).toHaveBeenCalledTimes(1); + }); + + it('FE-COMP-PADDLEOVERLAY-004: closes on a press outside it without anyone to hand the press to', () => { + publish([WEIR]); + selectPaddleObstacle(WEIR.ref); + renderOverlay(); + + fireEvent.pointerDown(document.body); + expect(selectedRef()).toBeNull(); + expect(overlay()).toBeNull(); + }); + + it('FE-COMP-PADDLEOVERLAY-005: closes on Escape, not on another key nor on an Escape something else already took', () => { + const onOutsidePress = vi.fn(); + publish([WEIR]); + selectPaddleObstacle(WEIR.ref); + renderOverlay({ onOutsidePress }); + + fireEvent.keyDown(document, { key: 'Enter' }); + expect(overlay()).toBeInTheDocument(); + + // A dialog above the map that handles its own Escape keeps the card open. + const takeEscape = (event: KeyboardEvent) => { + event.preventDefault(); + }; + document.addEventListener('keydown', takeEscape, true); + fireEvent.keyDown(document.body, { key: 'Escape' }); + document.removeEventListener('keydown', takeEscape, true); + expect(overlay()).toBeInTheDocument(); + expect(selectedRef()).toBe(WEIR.ref); + + fireEvent.keyDown(document, { key: 'Escape' }); + expect(selectedRef()).toBeNull(); + expect(overlay()).toBeNull(); + // A key is not a press: the map has no click to leave alone. + expect(onOutsidePress).not.toHaveBeenCalled(); + }); + + it('FE-COMP-PADDLEOVERLAY-006: closes from its own close button without reporting a press outside', () => { + const onOutsidePress = vi.fn(); + publish([WEIR]); + selectPaddleObstacle(WEIR.ref); + renderOverlay({ onOutsidePress }); + + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + expect(selectedRef()).toBeNull(); + expect(overlay()).toBeNull(); + expect(onOutsidePress).not.toHaveBeenCalled(); + }); + + it('FE-COMP-PADDLEOVERLAY-007: puts the checklist part for the picked object inside the card, where a press keeps it open', () => { + const onOutsidePress = vi.fn(); + publish([WEIR], [WEIR_ON_ROUTE]); + selectPaddleObstacle(WEIR.ref); + renderOverlay({ + onOutsidePress, + extra: (detail) => , + }); + + const decide = screen.getByRole('button', { name: 'Decide way/101 on_route' }); + expect(screen.getByTestId('paddle-obstacle-card')).toContainElement(decide); + + fireEvent.pointerDown(decide); + expect(onOutsidePress).not.toHaveBeenCalled(); + expect(selectedRef()).toBe(WEIR.ref); + expect(overlay()).toBeInTheDocument(); + }); + + it('FE-COMP-PADDLEOVERLAY-008: follows the pick made elsewhere and closes when the object leaves the store', () => { + const onOutsidePress = vi.fn(); + publish([WEIR, SLIPWAY]); + renderOverlay({ onOutsidePress }); + expect(overlay()).toBeNull(); + + act(() => selectPaddleObstacle(WEIR.ref)); + expect(screen.getByRole('heading', { name: 'Hammer weir' })).toBeInTheDocument(); + + // The checklist picks another object: the same card shows that one, named by its kind. + act(() => selectPaddleObstacle(SLIPWAY.ref)); + expect(screen.getByRole('heading', { name: 'Slipway' })).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Hammer weir' })).toBeNull(); + expect(screen.getByText(/^OpenStreetMap data of an unknown date/)).toBeInTheDocument(); + + // A refresh without the object drops the pick and the card with it, and the card stops listening. + act(() => publish([WEIR])); + expect(selectedRef()).toBeNull(); + expect(overlay()).toBeNull(); + pressMap(); + expect(onOutsidePress).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/Tours/paddle/PaddleRouteStrip.test.tsx b/client/src/components/Tours/paddle/PaddleRouteStrip.test.tsx new file mode 100644 index 000000000..7062a04fb --- /dev/null +++ b/client/src/components/Tours/paddle/PaddleRouteStrip.test.tsx @@ -0,0 +1,235 @@ +// FE-COMP-PADDLE-STRIP-001 to FE-COMP-PADDLE-STRIP-011: the band of a paddle tour, water and portages to scale, +// with its locks and the obstacles on or near the route. +import type { PaddleFeature, PaddleLayout, PaddleObstacleKind, PaddleRelation, RelatedObstacle } from '@trek/shared'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { buildSettings } from '../../../../tests/helpers/factories'; +import { act, fireEvent, render, screen } from '../../../../tests/helpers/render'; +import { resetAllStores, seedStore } from '../../../../tests/helpers/store'; +import { + publishPaddleObstacles, + selectPaddleObstacle, + usePaddleObstacleStore, +} from '../../../store/paddleObstacleStore'; +import { useSettingsStore } from '../../../store/settingsStore'; +import { PaddleRouteStrip } from './PaddleRouteStrip'; +import { paddleKindCss, PORTAGE_CSS_COLOR } from './paddlePaint'; + +/** Eight kilometres: water, a carried kilometre, water, a 40 m carry round the second lock, water. */ +const LAYOUT: PaddleLayout = { + totalM: 8000, + stretches: [ + { kind: 'water', fromM: 0, toM: 4000, extraMinutes: null, startIndex: 0 }, + { kind: 'portage', fromM: 4000, toM: 5000, extraMinutes: 10, startIndex: 2 }, + { kind: 'water', fromM: 5000, toM: 6000, extraMinutes: null, startIndex: 3 }, + { kind: 'portage', fromM: 6000, toM: 6040, extraMinutes: null, startIndex: 4 }, + { kind: 'water', fromM: 6040, toM: 8000, extraMinutes: null, startIndex: 5 }, + ], + locks: [ + { wait_minutes: 10, transit_minutes: 15, atM: 2000, bypassed: false, index: 1 }, + { wait_minutes: null, transit_minutes: null, atM: 6000, bypassed: true, index: null }, + ], +}; + +function feature(ref: string, kind: PaddleObstacleKind, name: string | null = null): PaddleFeature { + return { + ref, + kind, + group_key: null, + name, + geometry: { type: 'Point', coordinates: [12.1, 49.1] }, + tags: {}, + tag_hash: ref, + region_id: 'bayern', + }; +} + +function relate(entry: PaddleFeature, relation: PaddleRelation, alongM: number): RelatedObstacle { + return { feature: entry, relation, distanceM: relation === 'on_route' ? 0 : 90, alongM }; +} + +/** Measured along a route of 4 km, so each one sits on the 8 km band at twice its distance. */ +const RELATED: RelatedObstacle[] = [ + relate(feature('node/1', 'put_in', 'Bootsrampe'), 'on_route', 0), + relate(feature('way/2', 'weir', 'Wehr Regensburg'), 'on_route', 1500), + relate(feature('way/3', 'access', 'Naturschutzgebiet'), 'near', 1800), + relate(feature('node/4', 'rapids'), 'near', 2500), + relate(feature('way/5', 'dam', 'Staustufe'), 'on_route', 4400), +]; + +function publish(related: RelatedObstacle[], routeLengthM = 4000): void { + publishPaddleObstacles({ + status: 'ready', + features: related.map((entry) => entry.feature), + related, + routeLengthM, + coverage: 'covered', + regions: [], + truncated: false, + }); +} + +const marks = () => screen.queryAllByTestId('paddle-route-strip-obstacle'); +const locks = () => screen.queryAllByTestId('paddle-route-strip-lock'); +const stretchesOf = () => + Array.from(screen.getByTestId('paddle-route-strip').querySelectorAll('[data-stretch]')); + +/** The tooltip a hover opens on the element, closed again afterwards. */ +async function tooltipOf(element: HTMLElement): Promise { + fireEvent.mouseEnter(element); + const tip = await screen.findByRole('tooltip'); + const text = tip.textContent; + fireEvent.mouseLeave(element); + return text; +} + +describe('PaddleRouteStrip', () => { + beforeEach(() => { + resetAllStores(); + }); + + it('FE-COMP-PADDLE-STRIP-001: draws water and portages in order and to scale, a short carry at its least width', () => { + render(); + + const parts = stretchesOf(); + expect(parts.map((part) => part.dataset.stretch)).toEqual(['water', 'portage', 'water', 'portage', 'water']); + // Half, an eighth and an eighth of the 8 km; the 40 m carry is drawn at the 1.5 floor instead of 0.5. + expect(parts.slice(0, 4).map((part) => part.style.flexGrow)).toEqual(['50', '12.5', '12.5', '1.5']); + expect(parts[0]).toHaveClass('bg-info'); + expect(parts[1]).not.toHaveClass('bg-info'); + expect(parts[1]?.getAttribute('style')).toContain(`background: ${PORTAGE_CSS_COLOR}`); + // The scale runs from the put-in to the take-out. + expect(screen.getByText('0 m')).toBeInTheDocument(); + expect(screen.getByText('8 km')).toBeInTheDocument(); + }); + + it('FE-COMP-PADDLE-STRIP-002: names each stretch with its length on hover', async () => { + render(); + const parts = stretchesOf(); + + expect(await tooltipOf(parts[0])).toBe('On the water 4 km'); + expect(await tooltipOf(parts[1])).toBe('Portage 1 km'); + expect(await tooltipOf(parts[3])).toBe('Portage 40 m'); + }); + + it('FE-COMP-PADDLE-STRIP-003: marks each lock at its place, one a portage carries round faded', async () => { + render(); + const [open, carried] = locks(); + + expect(locks()).toHaveLength(2); + expect(open.style.insetInlineStart).toBe('25%'); + expect(open).not.toHaveClass('opacity-45'); + expect(open.querySelector('[data-paddle-glyph="lock"]')).toBeInTheDocument(); + expect(carried.style.insetInlineStart).toBe('75%'); + expect(carried).toHaveClass('opacity-45'); + expect(await tooltipOf(open)).toBe('Lock 2 km'); + expect(await tooltipOf(carried)).toBe('Lock 6 km'); + }); + + it('FE-COMP-PADDLE-STRIP-004: places the obstacles to decide about by their share of the measured route', async () => { + publish(RELATED); + render(); + + // The put-in helps and the access rule runs along a stretch: neither becomes a mark. + expect(marks()).toHaveLength(3); + const [weir, rapids, dam] = marks(); + expect(weir).toHaveAccessibleName('Weir: Wehr Regensburg'); + // 1.5 of the 4 km measured is 3 of the 8 km drawn. + expect(weir.style.insetInlineStart).toBe('37.5%'); + expect(weir.querySelector('[data-paddle-glyph="weir"]')).toBeInTheDocument(); + expect(weir?.getAttribute('style')).toContain(`color: ${paddleKindCss('weir')}`); + expect(rapids).toHaveAccessibleName('Rapids'); + expect(rapids.style.insetInlineStart).toBe('62.5%'); + // Measured past the end of the route, it still sits at the take-out. + expect(dam).toHaveAccessibleName('Dam: Staustufe'); + expect(dam.style.insetInlineStart).toBe('100%'); + expect(await tooltipOf(weir)).toBe('Weir: Wehr Regensburg 3 km'); + expect(await tooltipOf(rapids)).toBe('Rapids 5 km'); + expect(await tooltipOf(dam)).toBe('Dam: Staustufe 8 km'); + }); + + it('FE-COMP-PADDLE-STRIP-005: rings an obstacle on the route solid and dims one only near it, dashed', () => { + publish(RELATED); + render(); + const [weir, rapids] = marks(); + + expect(weir.style.border).toContain('solid'); + expect(weir).not.toHaveClass('opacity-60'); + expect(rapids.style.border).toContain('dashed'); + expect(rapids).toHaveClass('opacity-60'); + }); + + it('FE-COMP-PADDLE-STRIP-006: a click picks an obstacle for its card and a second click puts it back', () => { + publish(RELATED); + render(); + const rapids = screen.getByRole('button', { name: 'Rapids' }); + expect(rapids).toHaveAttribute('aria-pressed', 'false'); + + fireEvent.click(rapids); + expect(usePaddleObstacleStore.getState().selectedRef).toBe('node/4'); + expect(rapids).toHaveAttribute('aria-pressed', 'true'); + expect(rapids).toHaveClass('scale-125', 'z-30'); + // Picked, a near one shows in full: no dimming and a solid ring. + expect(rapids).not.toHaveClass('opacity-60'); + expect(rapids.style.border).toContain('solid'); + expect(screen.getByRole('button', { name: 'Weir: Wehr Regensburg' })).toHaveAttribute('aria-pressed', 'false'); + + fireEvent.click(rapids); + expect(usePaddleObstacleStore.getState().selectedRef).toBeNull(); + expect(rapids).toHaveAttribute('aria-pressed', 'false'); + expect(rapids).toHaveClass('z-20', 'opacity-60'); + }); + + it('FE-COMP-PADDLE-STRIP-007: shows the obstacle picked elsewhere as picked, and a click moves the pick', () => { + publish(RELATED); + render(); + + act(() => selectPaddleObstacle('way/5')); + expect(screen.getByRole('button', { name: 'Dam: Staustufe' })).toHaveAttribute('aria-pressed', 'true'); + + fireEvent.click(screen.getByRole('button', { name: 'Weir: Wehr Regensburg' })); + expect(usePaddleObstacleStore.getState().selectedRef).toBe('way/2'); + expect(screen.getByRole('button', { name: 'Dam: Staustufe' })).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByRole('button', { name: 'Weir: Wehr Regensburg' })).toHaveAttribute('aria-pressed', 'true'); + }); + + it('FE-COMP-PADDLE-STRIP-008: places no obstacle while the route they were measured along has no length', () => { + publish(RELATED, 0); + render(); + + expect(marks()).toHaveLength(0); + expect(locks()).toHaveLength(2); + }); + + it('FE-COMP-PADDLE-STRIP-009: compact, it draws the band alone, without locks, obstacles or the scale', () => { + publish(RELATED); + render(); + + expect(stretchesOf()).toHaveLength(5); + expect(locks()).toHaveLength(0); + expect(marks()).toHaveLength(0); + expect(screen.queryByText('0 m')).not.toBeInTheDocument(); + expect(screen.queryByText('8 km')).not.toBeInTheDocument(); + }); + + it('FE-COMP-PADDLE-STRIP-010: draws nothing for a tour without length or without stretches', () => { + publish(RELATED); + const { rerender } = render(); + expect(screen.queryByTestId('paddle-route-strip')).not.toBeInTheDocument(); + + rerender(); + expect(screen.queryByTestId('paddle-route-strip')).not.toBeInTheDocument(); + expect(marks()).toHaveLength(0); + }); + + it('FE-COMP-PADDLE-STRIP-011: reads its lengths in the unit the user chose, metric when none is set', async () => { + seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'imperial' }) }); + render(); + + expect(screen.getByText('0 mi')).toBeInTheDocument(); + expect(screen.getByText('5 mi')).toBeInTheDocument(); + expect(await tooltipOf(locks()[0])).toBe('Lock 1.2 mi'); + + act(() => seedStore(useSettingsStore, { settings: buildSettings() })); + expect(screen.getByText('8 km')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Tours/paddle/paddleMapLayer.test.ts b/client/src/components/Tours/paddle/paddleMapLayer.test.ts new file mode 100644 index 000000000..79e520b26 --- /dev/null +++ b/client/src/components/Tours/paddle/paddleMapLayer.test.ts @@ -0,0 +1,361 @@ +// FE-PADDLE-MAP-001 to FE-PADDLE-MAP-015: the paddle objects as the planner maps draw them, one model for Leaflet and GL. +import { describe, expect, it } from 'vitest'; + +import { + type PaddleFeature, + type PaddleGeometry, + type PaddleObstacleKind, + paddleObstacleKindSchema, + type RelatedObstacle, +} from '@trek/shared'; + +import { OBSTACLE_GLYPH, PADDLE_GLYPH_PATHS, paddleGlyphSvg } from './paddleGlyphPaths'; +import { + isAreaOnly, + markerSize, + PADDLE_AREA_MIN_ZOOM, + paddleItemsAtZoom, + type PaddleMapItem, + paddleMapItems, + type PaddleMapRelation, + paddleMarkerHtml, + paddleMarkerImageId, + paddleMarkerSvg, +} from './paddleMapLayer'; +import { paddleKindPaint, type PaddlePaint } from './paddlePaint'; + +const KINDS = paddleObstacleKindSchema.options; +const RELATIONS: readonly PaddleMapRelation[] = ['on_route', 'near', 'area']; +/** Test colours standing in for the theme tokens (styles/paddle.css), distinct so each can be told apart. */ +const PAINT: PaddlePaint = { + obstacle: '#334155', + access: '#4338ca', + landing: '#0f766e', + portage: '#9a6233', + disc: '#ffffff', + shadow: 'rgba(15, 23, 42, 0.35)', +}; +const { obstacle: SLATE, access: INDIGO, landing: TEAL, portage: BROWN } = PAINT; + +const point = (lng: number, lat: number): PaddleGeometry => ({ type: 'Point', coordinates: [lng, lat] }); + +function feature(ref: string, kind: PaddleObstacleKind, geometry: PaddleGeometry = point(12.1, 49.1)): PaddleFeature { + return { ref, kind, group_key: null, name: null, geometry, tags: {}, tag_hash: ref, region_id: 'oberpfalz' }; +} + +function related(entry: PaddleFeature, relation: RelatedObstacle['relation']): RelatedObstacle { + return { feature: entry, relation, distanceM: relation === 'on_route' ? 0 : 80, alongM: 1200 }; +} + +/** One object as the map draws it, standing to the route as `relation` says. */ +function item(relation: PaddleMapRelation, kind: PaddleObstacleKind = 'weir', ref = 'way/1'): PaddleMapItem { + const entry = feature(ref, kind); + const [made] = paddleMapItems([entry], relation === 'area' ? [] : [related(entry, relation)], PAINT); + return made; +} + +/** The marker markup as Leaflet's divIcon puts it on the page. */ +function parseHtml(html: string): Element { + const host = document.createElement('div'); + host.innerHTML = html; + const marker = host.firstElementChild; + if (!marker || host.children.length !== 1) throw new Error(`not one marker element: ${html}`); + return marker; +} + +/** An image as a GL map decodes it: well-formed XML or nothing. */ +function parseSvg(svg: string): Element { + const root = new DOMParser().parseFromString(svg, 'image/svg+xml').documentElement; + if (root.localName !== 'svg') throw new Error(`not well-formed SVG: ${svg}`); + return root; +} + +function pathsOf(svg: Element): Array { + return Array.from(svg.getElementsByTagName('path'), (path) => path.getAttribute('d')); +} + +function htmlLook(html: string) { + const marker = parseHtml(html); + const glyph = marker.querySelector('svg'); + return { + style: marker.getAttribute('style') ?? '', + glyph: glyph?.getAttribute('width') ?? null, + stroke: glyph?.getAttribute('stroke') ?? null, + paths: pathsOf(marker), + }; +} + +function svgLook(svg: string) { + const root = parseSvg(svg); + const disc = root.getElementsByTagName('circle').item(0); + const glyph = root.getElementsByTagName('svg').item(0); + return { + size: root.getAttribute('width'), + opacity: root.getAttribute('opacity'), + r: disc?.getAttribute('r') ?? null, + ring: disc?.getAttribute('stroke-width') ?? null, + dash: disc?.getAttribute('stroke-dasharray') ?? null, + glyph: glyph?.getAttribute('width') ?? null, + x: glyph?.getAttribute('x') ?? null, + }; +} + +describe('paddle map layer', () => { + it('FE-PADDLE-MAP-001: sorts every kind into one of four distinct colours by what the object is', () => { + const kindsIn = (color: string) => KINDS.filter((kind) => paddleKindPaint(kind, PAINT) === color); + expect(kindsIn(SLATE)).toEqual(['weir', 'dam', 'lock', 'lock_gate', 'waterfall', 'rapids', 'hazard']); + expect(kindsIn(INDIGO)).toEqual(['access']); + expect(kindsIn(TEAL)).toEqual(['put_in', 'egress', 'slipway']); + expect(kindsIn(BROWN)).toEqual(['portage']); + expect(new Set([SLATE, INDIGO, TEAL, BROWN]).size).toBe(4); + }); + + it('FE-PADDLE-MAP-002: draws every kind with a glyph of well-formed paths, sharing one where the paddler meets the same thing', () => { + const glyphs = new Set(KINDS.map((kind) => OBSTACLE_GLYPH[kind])); + expect(glyphs).toEqual(new Set(Object.keys(PADDLE_GLYPH_PATHS))); + expect(OBSTACLE_GLYPH.lock_gate).toBe('lock'); + expect(OBSTACLE_GLYPH.slipway).toBe('put_in'); + expect(OBSTACLE_GLYPH.egress).toBe('take_out'); + for (const glyph of glyphs) { + expect(PADDLE_GLYPH_PATHS[glyph].length, glyph).toBeGreaterThan(0); + expect(pathsOf(parseSvg(paddleGlyphSvg(glyph, 24, SLATE))), glyph).toEqual(PADDLE_GLYPH_PATHS[glyph]); + } + for (const d of Object.values(PADDLE_GLYPH_PATHS).flat()) expect(d).toMatch(/^M/); + }); + + it('FE-PADDLE-MAP-003: places a point where it is, as the route relates it, and the rest of the area quietly', () => { + const weir = feature('node/1', 'weir', point(12.1, 49.1)); + const slipway = feature('node/2', 'slipway', point(12.2, 49.2)); + const gate = feature('node/3', 'lock_gate', point(12.3, 49.3)); + const elsewhere = feature('node/4', 'dam', point(13.1, 50.1)); + const items = paddleMapItems( + [weir, slipway, gate], + [related(gate, 'near'), related(weir, 'on_route'), related(elsewhere, 'on_route')], + PAINT + ); + expect(items).toEqual([ + { + feature: weir, + relation: 'on_route', + color: SLATE, + disc: PAINT.disc, + shadow: PAINT.shadow, + glyph: 'weir', + marker: [49.1, 12.1], + line: null, + }, + { + feature: slipway, + relation: 'area', + color: TEAL, + disc: PAINT.disc, + shadow: PAINT.shadow, + glyph: 'put_in', + marker: [49.2, 12.2], + line: null, + }, + { + feature: gate, + relation: 'near', + color: SLATE, + disc: PAINT.disc, + shadow: PAINT.shadow, + glyph: 'lock', + marker: [49.3, 12.3], + line: null, + }, + ]); + expect(items[0].feature).toBe(weir); + expect(paddleMapItems([], [related(weir, 'on_route')], PAINT)).toEqual([]); + }); + + it('FE-PADDLE-MAP-004: turns a line into [lat, lng] pairs and sets its marker halfway along', () => { + const weir = feature('way/1', 'weir', { + type: 'LineString', + coordinates: [ + [12.1, 49.1], + [12.2, 49.2], + [12.3, 49.3], + ], + }); + const carry = feature('way/2', 'portage', { + type: 'LineString', + coordinates: [ + [12.1, 49.1], + [12.2, 49.2], + [12.3, 49.3], + [12.4, 49.4], + ], + }); + const [crossing, portage] = paddleMapItems([weir, carry], [], PAINT); + expect(crossing.line).toEqual([ + [49.1, 12.1], + [49.2, 12.2], + [49.3, 12.3], + ]); + expect(crossing.marker).toEqual([49.2, 12.2]); + expect(portage.marker).toEqual([49.3, 12.3]); + expect(portage).toMatchObject({ relation: 'area', color: BROWN, glyph: 'portage' }); + }); + + it('FE-PADDLE-MAP-005: outlines an area by its outer ring, and puts a shape that came without points at the origin', () => { + const lake = feature('way/1', 'access', { + type: 'Polygon', + coordinates: [ + [ + [12.1, 49.1], + [12.2, 49.1], + [12.2, 49.2], + [12.1, 49.2], + [12.1, 49.1], + ], + [ + [12.14, 49.14], + [12.16, 49.14], + [12.16, 49.16], + [12.14, 49.14], + ], + ], + }); + const noRing = feature('way/2', 'access', { type: 'Polygon', coordinates: [] }); + const noLine = feature('way/3', 'weir', { type: 'LineString', coordinates: [] }); + const [area, ringless, lineless] = paddleMapItems([lake, noRing, noLine], [], PAINT); + expect(area.line).toEqual([ + [49.1, 12.1], + [49.1, 12.2], + [49.2, 12.2], + [49.2, 12.1], + [49.1, 12.1], + ]); + expect(area.marker).toEqual([49.2, 12.2]); + expect(area.color).toBe(INDIGO); + expect(ringless).toMatchObject({ line: [], marker: [0, 0] }); + expect(lineless).toMatchObject({ line: [], marker: [0, 0] }); + }); + + describe('at a zoom', () => { + const onRoute = item('on_route', 'weir', 'way/1'); + const near = item('near', 'lock', 'way/2'); + const quiet = item('area', 'put_in', 'node/3'); + const picked = item('area', 'access', 'way/4'); + const all = [onRoute, near, quiet, picked]; + const refs = (items: readonly PaddleMapItem[]) => items.map((entry) => entry.feature.ref); + + it('FE-PADDLE-MAP-006: counts only an unpicked object off the route as one of the quiet ones of the area', () => { + expect(isAreaOnly(quiet, null)).toBe(true); + expect(isAreaOnly(quiet, 'way/4')).toBe(true); + expect(isAreaOnly(picked, 'way/4')).toBe(false); + expect(isAreaOnly(onRoute, null)).toBe(false); + expect(isAreaOnly(near, null)).toBe(false); + }); + + it('FE-PADDLE-MAP-007: keeps an overview to the route and the picked object, and shows the whole area from the threshold on', () => { + expect(refs(paddleItemsAtZoom(all, PADDLE_AREA_MIN_ZOOM - 1, null))).toEqual(['way/1', 'way/2']); + expect(refs(paddleItemsAtZoom(all, PADDLE_AREA_MIN_ZOOM - 0.5, 'way/4'))).toEqual(['way/1', 'way/2', 'way/4']); + const zoomedIn = paddleItemsAtZoom(all, PADDLE_AREA_MIN_ZOOM, null); + expect(refs(zoomedIn)).toEqual(['way/1', 'way/2', 'node/3', 'way/4']); + expect(zoomedIn).not.toBe(all); + expect(paddleItemsAtZoom([], PADDLE_AREA_MIN_ZOOM - 1, 'way/4')).toEqual([]); + }); + }); + + it('FE-PADDLE-MAP-008: sizes a marker by how it stands to the route, and a picked one largest of all', () => { + expect(RELATIONS.map((relation) => markerSize(relation, false))).toEqual([30, 26, 22]); + expect(RELATIONS.map((relation) => markerSize(relation, true))).toEqual([36, 36, 36]); + }); + + it('FE-PADDLE-MAP-009: draws a Leaflet marker solid on the route, dashed near it and faded in the area', () => { + const onRoute = htmlLook(paddleMarkerHtml(item('on_route'), false)); + expect(onRoute.style).toContain('width:30px;height:30px;'); + expect(onRoute.style).toContain(`border:2px solid ${SLATE};`); + expect(onRoute.style).toMatch(/opacity:1$/); + expect(onRoute).toMatchObject({ glyph: '17', stroke: SLATE, paths: PADDLE_GLYPH_PATHS.weir }); + + const near = htmlLook(paddleMarkerHtml(item('near', 'lock'), false)); + expect(near.style).toContain('width:26px;height:26px;'); + expect(near.style).toContain(`border:2px dashed ${SLATE};`); + expect(near.style).toMatch(/opacity:1$/); + expect(near).toMatchObject({ glyph: '15', stroke: SLATE, paths: PADDLE_GLYPH_PATHS.lock }); + + const quiet = htmlLook(paddleMarkerHtml(item('area', 'put_in'), false)); + expect(quiet.style).toContain('width:22px;height:22px;'); + expect(quiet.style).toContain(`border:2px solid ${TEAL};`); + expect(quiet.style).toMatch(/opacity:0\.6$/); + expect(quiet).toMatchObject({ glyph: '13', stroke: TEAL, paths: PADDLE_GLYPH_PATHS.put_in }); + }); + + it('FE-PADDLE-MAP-010: draws a picked Leaflet marker larger, with a heavier solid ring and at full strength', () => { + for (const relation of RELATIONS) { + const picked = htmlLook(paddleMarkerHtml(item(relation, 'access'), true)); + expect(picked.style, relation).toContain('width:36px;height:36px;'); + expect(picked.style, relation).toContain(`border:3px solid ${INDIGO};`); + expect(picked.style, relation).toMatch(/opacity:1$/); + expect(picked.glyph, relation).toBe('21'); + } + }); + + it('FE-PADDLE-MAP-011: draws the GL image of a marker at twice its size, the ring dashed near the route and faded in the area', () => { + const onRoute = svgLook(paddleMarkerSvg(item('on_route'), false)); + const near = svgLook(paddleMarkerSvg(item('near', 'lock'), false)); + const quiet = svgLook(paddleMarkerSvg(item('area', 'put_in'), false)); + expect(onRoute).toEqual({ size: '60', opacity: '1', r: '26', ring: '4', dash: null, glyph: '35', x: '12.5' }); + expect(near).toEqual({ size: '52', opacity: '1', r: '22', ring: '4', dash: '6 5', glyph: '30', x: '11' }); + expect(quiet).toEqual({ size: '44', opacity: '0.6', r: '18', ring: '4', dash: null, glyph: '26', x: '9' }); + + const weir = parseSvg(paddleMarkerSvg(item('on_route'), false)); + expect(weir.namespaceURI).toBe('http://www.w3.org/2000/svg'); + expect(weir.getElementsByTagName('circle').item(0)?.getAttribute('stroke')).toBe(SLATE); + expect(weir.getElementsByTagName('svg').item(0)?.getAttribute('stroke')).toBe(SLATE); + expect(pathsOf(weir)).toEqual(PADDLE_GLYPH_PATHS.weir); + }); + + it('FE-PADDLE-MAP-012: draws a picked GL image larger and solid, every look at twice the size of its Leaflet twin', () => { + const picked = { size: '72', opacity: '1', r: '31', ring: '6', dash: null, glyph: '42', x: '15' }; + for (const relation of RELATIONS) { + expect(svgLook(paddleMarkerSvg(item(relation), true)), relation).toEqual(picked); + for (const selected of [false, true]) { + const { size } = svgLook(paddleMarkerSvg(item(relation), selected)); + expect(size, relation).toBe(String(markerSize(relation, selected) * 2)); + } + } + }); + + it('FE-PADDLE-MAP-013: names one GL image per look, which two kinds that look alike share', () => { + expect(paddleMarkerImageId(item('on_route'), false)).toBe('trek-paddle-weir-334155ffffff-on_route'); + expect(paddleMarkerImageId(item('on_route'), true)).toBe('trek-paddle-weir-334155ffffff-on_route-sel'); + expect(paddleMarkerImageId(item('near', 'egress'), false)).toBe('trek-paddle-take_out-0f766effffff-near'); + + const slipway = item('area', 'slipway'); + const putIn = item('area', 'put_in', 'node/9'); + expect(paddleMarkerImageId(slipway, false)).toBe(paddleMarkerImageId(putIn, false)); + expect(paddleMarkerSvg(slipway, false)).toBe(paddleMarkerSvg(putIn, false)); + + const looks = RELATIONS.flatMap((relation) => + [false, true].map((selected) => paddleMarkerImageId(item(relation), selected)) + ); + expect(new Set(looks).size).toBe(6); + }); + + it('FE-PADDLE-MAP-014: renders a glyph as standalone SVG in the size and colour it is asked for', () => { + const glyph = parseSvg(paddleGlyphSvg('lock', 20, INDIGO)); + expect(glyph.getAttribute('width')).toBe('20'); + expect(glyph.getAttribute('height')).toBe('20'); + expect(glyph.getAttribute('viewBox')).toBe('0 0 24 24'); + expect(glyph.getAttribute('fill')).toBe('none'); + expect(glyph.getAttribute('stroke')).toBe(INDIGO); + expect(pathsOf(glyph)).toEqual(PADDLE_GLYPH_PATHS.lock); + }); + + it('FE-PADDLE-MAP-015: hands the map no part of the object data, so a hostile name in OSM cannot reach the markup', () => { + const hostile = ''; + const entry = { ...feature('way/1', 'weir'), name: hostile, tags: { name: hostile, note: hostile } }; + const [drawn] = paddleMapItems([entry], [related(entry, 'near')], PAINT); + const markup = [false, true].flatMap((selected) => [ + paddleMarkerHtml(drawn, selected), + paddleMarkerSvg(drawn, selected), + paddleMarkerImageId(drawn, selected), + ]); + for (const text of markup) expect(text).not.toContain('onerror'); + expect(parseHtml(paddleMarkerHtml(drawn, true)).querySelector('img')).toBeNull(); + }); +}); diff --git a/client/src/components/Tours/paddle/paddleObstacleModel.test.ts b/client/src/components/Tours/paddle/paddleObstacleModel.test.ts new file mode 100644 index 000000000..2fc8fb62f --- /dev/null +++ b/client/src/components/Tours/paddle/paddleObstacleModel.test.ts @@ -0,0 +1,219 @@ +// FE-PADDLE-OBSMODEL-001 to FE-PADDLE-OBSMODEL-016: what the obstacle card and the route band show of a paddle object. +import { describe, expect, it } from 'vitest'; + +import { + paddleObstacleKindSchema, + type PaddleCoverageRegion, + type PaddleFeature, + type RelatedObstacle, +} from '@trek/shared'; + +import { + isBlockingKind, + metresOf, + OBSTACLE_FACT_KEYS, + obstacleKeyOf, + osmUrlOf, + paddleObstacleDetail, + routeStripObstacles, +} from './paddleObstacleModel'; + +/** A weir in the Upper Palatinate unless told otherwise. */ +function feature(ref: string, overrides: Partial = {}): PaddleFeature { + return { + ref, + kind: 'weir', + group_key: null, + name: null, + geometry: { type: 'Point', coordinates: [12.1301, 49.1231] }, + tags: {}, + tag_hash: ref, + region_id: 'oberpfalz', + ...overrides, + }; +} + +/** How the route meets an object: on it where it crosses, or near it, 80 m off. */ +function relate(target: PaddleFeature, relation: RelatedObstacle['relation'], alongM: number): RelatedObstacle { + return { feature: target, relation, distanceM: relation === 'on_route' ? 0 : 80, alongM }; +} + +function region(id: string, sourceTimestamp: string | null): PaddleCoverageRegion { + return { id, name: id, source_timestamp: sourceTimestamp, imported_at: '2026-10-02T06:00:00Z' }; +} + +/** The card's view of one picked object among the loaded ones. */ +function detailOf( + picked: PaddleFeature, + context: { related?: RelatedObstacle[]; regions?: PaddleCoverageRegion[] } = {} +) { + return paddleObstacleDetail({ + features: [feature('node/999'), picked], + related: context.related ?? [], + regions: context.regions ?? [], + selectedRef: picked.ref, + }); +} + +describe('osmUrlOf and obstacleKeyOf', () => { + it('FE-PADDLE-OBSMODEL-001: links the object in OpenStreetMap by its own reference', () => { + expect(osmUrlOf('way/29256405')).toBe('https://www.openstreetmap.org/way/29256405'); + expect(osmUrlOf('node/17')).toBe('https://www.openstreetmap.org/node/17'); + }); + + it('FE-PADDLE-OBSMODEL-002: decides about the parts of a lock together and about everything else on its own', () => { + expect(obstacleKeyOf({ ref: 'way/2', group_key: 'lock:way/1' })).toBe('lock:way/1'); + expect(obstacleKeyOf({ ref: 'node/3', group_key: null })).toBe('node/3'); + }); +}); + +describe('isBlockingKind', () => { + it('FE-PADDLE-OBSMODEL-003: counts obstacles and access rules, never the places and ways that help', () => { + const blocking = paddleObstacleKindSchema.options.filter((kind) => isBlockingKind(kind)); + const helping = paddleObstacleKindSchema.options.filter((kind) => !isBlockingKind(kind)); + expect(blocking).toEqual(['weir', 'dam', 'lock', 'lock_gate', 'waterfall', 'rapids', 'hazard', 'access']); + expect(helping).toEqual(['put_in', 'egress', 'slipway', 'portage']); + }); +}); + +describe('paddleObstacleDetail', () => { + it('FE-PADDLE-OBSMODEL-004: knows nothing when nothing is picked or the picked object is no longer loaded', () => { + const state = { features: [feature('node/1')], related: [], regions: [] }; + expect(paddleObstacleDetail({ ...state, selectedRef: null })).toBeNull(); + expect(paddleObstacleDetail({ ...state, selectedRef: '' })).toBeNull(); + expect(paddleObstacleDetail({ ...state, selectedRef: 'node/2' })).toBeNull(); + }); + + it('FE-PADDLE-OBSMODEL-005: leaves an object only in the area in the area, without a distance or a place on the route', () => { + const weir = feature('way/7', { name: 'Wehr Lindenmühle' }); + const detail = detailOf(weir, { related: [relate(feature('way/8'), 'on_route', 300)] }); + expect(detail).toMatchObject({ feature: weir, relation: 'area', distanceM: null, alongM: null }); + }); + + it('FE-PADDLE-OBSMODEL-006: carries how its own related entry stands to the route, a crossing at the start included', () => { + const weir = feature('way/7'); + const crossed = detailOf(weir, { related: [relate(weir, 'on_route', 0)] }); + expect(crossed).toMatchObject({ relation: 'on_route', distanceM: 0, alongM: 0 }); + const passed = detailOf(weir, { related: [relate(feature('way/8'), 'near', 40), relate(weir, 'near', 1200)] }); + expect(passed).toMatchObject({ relation: 'near', distanceM: 80, alongM: 1200 }); + }); + + it('FE-PADDLE-OBSMODEL-007: dates the data by the region the object came from, and leaves the date unknown otherwise', () => { + const weir = feature('way/7'); + const regions = [region('bayern', '2026-09-01T00:00:00Z'), region('oberpfalz', '2026-10-01T21:14:02Z')]; + expect(detailOf(weir, { regions })?.dataAsOf).toBe('2026-10-01T21:14:02Z'); + expect(detailOf(weir, { regions: [region('oberpfalz', null)] })?.dataAsOf).toBeNull(); + expect(detailOf(weir, { regions: [region('bayern', '2026-09-01T00:00:00Z')] })?.dataAsOf).toBeNull(); + expect(detailOf(weir)?.dataAsOf).toBeNull(); + }); + + it('FE-PADDLE-OBSMODEL-008: reads the access rule canoe first with its condition, and an untagged object has none', () => { + const rule = feature('way/7', { + kind: 'access', + tags: { canoe: 'no', boat: 'yes', 'canoe:conditional': 'yes @ (Jun-Sep)' }, + }); + expect(detailOf(rule)?.access).toEqual({ + value: 'no', + source: 'canoe', + condition: 'yes @ (Jun-Sep)', + forbidden: true, + private: false, + }); + expect(detailOf(feature('way/8'))?.access).toEqual({ + value: null, + source: null, + condition: null, + forbidden: false, + private: false, + }); + }); + + it('FE-PADDLE-OBSMODEL-009: calls a carrying way private on a private tag of its own or of the way, and nothing else', () => { + const carryWay = (tags: Record) => detailOf(feature('way/9', { kind: 'portage', tags })); + expect(carryWay({ access: 'private' })?.privateWay).toBe(true); + expect(carryWay({ canoe: 'yes', 'portage:access': 'private' })?.privateWay).toBe(true); + expect(carryWay({ canoe: 'yes', access: 'private' })?.privateWay).toBe(true); + // The carrying way's own tag speaks over the general one. + expect(carryWay({ canoe: 'yes', access: 'private', 'portage:access': 'yes' })?.privateWay).toBe(false); + expect(carryWay({})?.privateWay).toBe(false); + // Only a carrying way is a way round: a private weir is still an obstacle, not a private way. + expect(detailOf(feature('way/10', { tags: { access: 'private' } }))?.privateWay).toBe(false); + }); + + it('FE-PADDLE-OBSMODEL-010: names the facts it has a label for in its own order, trimmed, and leaves the rest behind', () => { + const lock = feature('way/11', { + kind: 'lock', + tags: { + operator: ' WSA Donau-MDK ', + lock: 'yes', + height: '2.5', + fee: 'yes', + seasonal: ' ', + 'whitewater:section_grade': 'II', + width: '', + opening_hours: 'Mo-Su 08:00-18:00', + }, + }); + expect(detailOf(lock)?.facts).toEqual([ + { key: 'height', value: '2.5' }, + { key: 'whitewater:section_grade', value: 'II' }, + { key: 'opening_hours', value: 'Mo-Su 08:00-18:00' }, + { key: 'fee', value: 'yes' }, + { key: 'operator', value: 'WSA Donau-MDK' }, + ]); + const everything = Object.fromEntries(OBSTACLE_FACT_KEYS.map((key): [string, string] => [key, '1'])); + const named = detailOf(feature('way/12', { tags: everything }))?.facts.map((fact) => fact.key); + expect(named).toEqual([...OBSTACLE_FACT_KEYS]); + expect(detailOf(feature('way/13'))?.facts).toEqual([]); + }); + + it('FE-PADDLE-OBSMODEL-011: shows the description before the note, as written, and no note when both are empty', () => { + const noteOf = (tags: Record) => detailOf(feature('way/14', { tags }))?.note; + expect(noteOf({ description: ' Portage path ', note: 'Steps are slippery' })).toBe('Portage path'); + expect(noteOf({ description: ' ', note: ' Steps are slippery ' })).toBe('Steps are slippery'); + expect(noteOf({ note: '' })).toBeNull(); + expect(noteOf({})).toBeNull(); + }); +}); + +describe('routeStripObstacles', () => { + it('FE-PADDLE-OBSMODEL-012: places each obstacle by its share of the measured route on a band of another length', () => { + const weir = feature('way/1', { name: 'Wehr Lindenmühle' }); + const lock = feature('way/2', { kind: 'lock' }); + const strip = routeStripObstacles([relate(weir, 'on_route', 500), relate(lock, 'near', 1500)], 2000, 3000); + expect(strip).toEqual([ + { ref: 'way/1', kind: 'weir', name: 'Wehr Lindenmühle', relation: 'on_route', atM: 750 }, + { ref: 'way/2', kind: 'lock', name: null, relation: 'near', atM: 2250 }, + ]); + }); + + it('FE-PADDLE-OBSMODEL-013: marks only what a paddler decides about there, leaving access rules to the map and the card', () => { + const all = paddleObstacleKindSchema.options.map((kind) => relate(feature(kind, { kind }), 'near', 100)); + const kinds = routeStripObstacles(all, 1000, 1000).map((entry) => entry.kind); + expect(kinds).toEqual(['weir', 'dam', 'lock', 'lock_gate', 'waterfall', 'rapids', 'hazard']); + }); + + it('FE-PADDLE-OBSMODEL-014: keeps an object before the start or past the measured end on the band', () => { + const before = relate(feature('way/1'), 'near', -40); + const past = relate(feature('way/2'), 'on_route', 2600); + expect(routeStripObstacles([before, past], 2500, 2000).map((entry) => entry.atM)).toEqual([0, 2000]); + }); + + it('FE-PADDLE-OBSMODEL-015: marks nothing on a route or a band without a length', () => { + const related = [relate(feature('way/1'), 'on_route', 100)]; + expect(routeStripObstacles(related, 0, 1000)).toEqual([]); + expect(routeStripObstacles(related, -5, 1000)).toEqual([]); + expect(routeStripObstacles(related, 1000, 0)).toEqual([]); + expect(routeStripObstacles([], 1000, 1000)).toEqual([]); + }); +}); + +describe('metresOf', () => { + it('FE-PADDLE-OBSMODEL-016: reads a bare number as metres and leaves units and words to be shown as written', () => { + expect(metresOf('2')).toBe(2); + expect(metresOf('1.5')).toBe(1.5); + expect(metresOf('0.75')).toBe(0.75); + const worded = ['2 m', '1,5', '6 ft', 'about 3', '.5', '1.', '-1', ' 2', '']; + expect(worded.map((value) => metresOf(value))).toEqual(worded.map(() => null)); + }); +}); diff --git a/client/src/components/Tours/paddle/usePaddleLayerGL.test.ts b/client/src/components/Tours/paddle/usePaddleLayerGL.test.ts new file mode 100644 index 000000000..0bdff4b16 --- /dev/null +++ b/client/src/components/Tours/paddle/usePaddleLayerGL.test.ts @@ -0,0 +1,517 @@ +/** + * FE-PADDLE-GL-001 to FE-PADDLE-GL-017: the paddle objects on a MapLibre or + * Mapbox GL map, and the hit test the map's own click handler asks first. + * + * Pinned: one source and seven layers bottom to top, the ones only in the area + * from PADDLE_AREA_MIN_ZOOM on; each object as a line and a marker that say + * how it stands to the route; a pick or new objects replace the source's data + * in place, and a styledata never hands the same data over twice; a style + * still loading, or one a basemap change rebuilt, takes the layers on its next + * styledata; one map-level click picks the top hit's object; the pointer turns + * into a hand over the layers a click can land on; the plain and the picked + * look of every marker are drawn once, never after the layer went; without + * objects the layers and the source go; a map without the methods is no hit. + */ +import { renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; + +import type { PaddleFeature, PaddleGeometry, PaddleObstacleKind, RelatedObstacle } from '@trek/shared'; + +import { + PADDLE_AREA_MIN_ZOOM, + paddleMapItems, + paddleMarkerImageId, + paddleMarkerSvg, + type PaddleMapItem, + type PaddleMapLayer, +} from './paddleMapLayer'; +import type { PaddlePaint } from './paddlePaint'; +import { isPaddleHit, usePaddleLayerGL, type PaddleGlMap } from './usePaddleLayerGL'; + +/** Test colours standing in for the theme tokens (styles/paddle.css), distinct so each can be told apart. */ +const PAINT: PaddlePaint = { + obstacle: '#334155', + access: '#4338ca', + landing: '#0f766e', + portage: '#9a6233', + disc: '#ffffff', + shadow: 'rgba(15, 23, 42, 0.35)', +}; + +const SOURCE = 'trek-paddle'; +const CASING = 'trek-paddle-casing'; +const CASING_AREA = 'trek-paddle-casing-area'; +const LINE = 'trek-paddle-line'; +const LINE_NEAR = 'trek-paddle-line-near'; +const LINE_AREA = 'trek-paddle-line-area'; +const PINS = 'trek-paddle-pins'; +const PINS_AREA = 'trek-paddle-pins-area'; +/** Bottom to top, as the hook adds them. */ +const LAYERS = [CASING_AREA, CASING, LINE_AREA, LINE, LINE_NEAR, PINS_AREA, PINS]; +/** The layers a click can land on, the markers first. */ +const HIT_LAYERS = [PINS, PINS_AREA, LINE, LINE_NEAR, LINE_AREA]; + +function feature(ref: string, kind: PaddleObstacleKind, geometry: PaddleGeometry): PaddleFeature { + return { ref, kind, group_key: null, name: null, geometry, tags: {}, tag_hash: 'h', region_id: 'r1' }; +} + +const LOCK_LINE: [number, number][] = [ + [11.1, 48.1], + [11.11, 48.11], +]; +const WEIR_LINE: [number, number][] = [ + [11.2, 48.2], + [11.21, 48.21], + [11.22, 48.22], +]; +const PORTAGE_LINE: [number, number][] = [ + [11.3, 48.3], + [11.31, 48.31], +]; +const LOCK = feature('way/1', 'lock', { type: 'LineString', coordinates: LOCK_LINE }); +const WEIR = feature('way/2', 'weir', { type: 'LineString', coordinates: WEIR_LINE }); +const PORTAGE = feature('way/3', 'portage', { type: 'LineString', coordinates: PORTAGE_LINE }); +const PUT_IN = feature('node/4', 'put_in', { type: 'Point', coordinates: [11.4, 48.4] }); +const SLIPWAY = feature('node/5', 'slipway', { type: 'Point', coordinates: [11.5, 48.5] }); +const RELATED: RelatedObstacle[] = [ + { feature: LOCK, relation: 'on_route', distanceM: 0, alongM: 1200 }, + { feature: WEIR, relation: 'near', distanceM: 90, alongM: 3400 }, +]; +/** A lock on the route, a weir near it, three objects only in the area; the put-in and the slipway look alike. */ +const ITEMS = paddleMapItems([LOCK, WEIR, PORTAGE, PUT_IN, SLIPWAY], RELATED, PAINT); + +function itemOf(ref: string): PaddleMapItem { + const item = ITEMS.find((entry) => entry.feature.ref === ref); + if (!item) throw new Error(`no paddle item ${ref}`); + return item; +} + +function layerOf(items: PaddleMapItem[], selectedRef: string | null = null, onSelect = vi.fn()): PaddleMapLayer { + return { items, selectedRef, onSelect }; +} + +const looksOf = (item: PaddleMapItem) => [paddleMarkerImageId(item, false), paddleMarkerImageId(item, true)]; +const svgUrl = (item: PaddleMapItem, selected: boolean) => + `data:image/svg+xml;charset=utf-8,${encodeURIComponent(paddleMarkerSvg(item, selected))}`; + +const drawn: FakeImage[] = []; + +/** jsdom never decodes an image, so these load when a test says so. */ +class FakeImage { + onload: (() => void) | null = null; + src = ''; + readonly width: number; + + constructor(width: number) { + this.width = width; + drawn.push(this); + } +} + +const loadImages = () => drawn.forEach((image) => image.onload?.()); + +type Handler = (event?: unknown) => void; + +interface Hit { + properties: Record | null; +} + +interface LayerSpec { + id: string; + type: string; + source: string; + minzoom?: number; + paint?: Record; +} + +interface FakeFeature { + geometry: unknown; + properties: Record | null; +} + +interface FakeData { + features: FakeFeature[]; +} + +interface FakeSource { + data: FakeData; + setData: Mock<(next: FakeData) => void>; +} + +function fakeGlMap() { + const sources = new Map(); + const layers = new Set(); + const images = new Set(); + const handlers = new Map>(); + const state = { loading: false, removed: false, hits: [] as Hit[] }; + const canvas = document.createElement('canvas'); + const keyOf = (type: string, layer: unknown) => (typeof layer === 'string' ? `${type}:${layer}` : type); + const map = { + getSource: vi.fn((id: string) => sources.get(id)), + addSource: vi.fn((id: string, spec: { type: string; data: FakeData }) => { + // Like a style still loading: the map takes no source yet. + if (state.loading) throw new Error('Style is not done loading.'); + const source: FakeSource = { + data: spec.data, + setData: vi.fn((next: FakeData) => { + source.data = next; + }), + }; + sources.set(id, source); + }), + removeSource: vi.fn((id: string) => { + sources.delete(id); + }), + // Like MapLibre once its view went away: asking it for a layer throws. + getLayer: vi.fn((id: string) => { + if (state.removed) throw new TypeError('this.style is undefined'); + return layers.has(id) ? { id } : undefined; + }), + addLayer: vi.fn((spec: LayerSpec) => { + layers.add(spec.id); + }), + removeLayer: vi.fn((id: string) => { + layers.delete(id); + }), + hasImage: vi.fn((id: string) => images.has(id)), + addImage: vi.fn((id: string) => { + images.add(id); + }), + queryRenderedFeatures: vi.fn(() => state.hits), + getCanvas: vi.fn(() => canvas), + on: vi.fn((type: string, layerOrFn: unknown, fn?: Handler) => { + const key = keyOf(type, layerOrFn); + const set = handlers.get(key) ?? new Set(); + set.add((fn ?? layerOrFn) as Handler); + handlers.set(key, set); + }), + off: vi.fn((type: string, layerOrFn: unknown, fn?: Handler) => { + handlers.get(keyOf(type, layerOrFn))?.delete((fn ?? layerOrFn) as Handler); + }), + }; + return { + map: map as unknown as PaddleGlMap, + spies: map, + state, + sources, + layers, + images, + canvas, + emit: (key: string, event?: unknown) => [...(handlers.get(key) ?? [])].forEach((fn) => fn(event)), + listeners: () => [...handlers.values()].reduce((sum, set) => sum + set.size, 0), + /** A basemap change: the rebuilt style has none of the paddle source, layers or images. */ + dropStyle: () => { + sources.clear(); + layers.clear(); + images.clear(); + }, + }; +} + +type FakeGl = ReturnType; + +const point = { x: 4, y: 4 }; +const addedIds = (gl: FakeGl) => gl.spies.addLayer.mock.calls.map(([spec]) => spec.id); +const specOf = (gl: FakeGl, id: string) => gl.spies.addLayer.mock.calls.find(([spec]) => spec.id === id)?.[0]; +const dataOf = (gl: FakeGl) => gl.sources.get(SOURCE)?.data.features ?? []; + +function propsOf(gl: FakeGl, ref: string) { + const features = dataOf(gl).filter((entry) => entry.properties?.ref === ref); + return features.map((entry) => entry.properties); +} + +function renderLayer(gl: FakeGl, layer: PaddleMapLayer | undefined) { + return renderHook((props: { layer: PaddleMapLayer | undefined }) => usePaddleLayerGL(gl.map, props.layer), { + initialProps: { layer }, + }); +} + +beforeEach(() => { + drawn.length = 0; + vi.stubGlobal('Image', FakeImage); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('usePaddleLayerGL', () => { + it('FE-PADDLE-GL-001: adds one source and seven layers bottom to top, the area ones from PADDLE_AREA_MIN_ZOOM on', () => { + const gl = fakeGlMap(); + renderLayer(gl, layerOf(ITEMS)); + + expect(gl.spies.addSource).toHaveBeenCalledTimes(1); + expect(gl.spies.addSource).toHaveBeenCalledWith(SOURCE, expect.objectContaining({ type: 'geojson' })); + expect(addedIds(gl)).toEqual(LAYERS); + expect(gl.spies.addLayer.mock.calls.every(([spec]) => spec.source === SOURCE)).toBe(true); + const zoomed = gl.spies.addLayer.mock.calls.filter(([spec]) => spec.minzoom === PADDLE_AREA_MIN_ZOOM); + expect(zoomed.map(([spec]) => spec.id)).toEqual([CASING_AREA, LINE_AREA, PINS_AREA]); + expect(specOf(gl, PINS)?.type).toBe('symbol'); + expect(specOf(gl, PINS_AREA)?.type).toBe('symbol'); + expect(specOf(gl, LINE_NEAR)?.paint?.['line-dasharray']).toEqual([2, 1.6]); + expect(specOf(gl, LINE)?.paint?.['line-dasharray']).toBeUndefined(); + }); + + it('FE-PADDLE-GL-002: draws each object as a line and a marker that say how it stands to the route', () => { + const gl = fakeGlMap(); + renderLayer(gl, layerOf(ITEMS)); + const lock = itemOf('way/1'); + const weir = itemOf('way/2'); + const portage = itemOf('way/3'); + + expect(dataOf(gl).map((entry) => entry.properties)).toEqual([ + { ref: 'way/1', color: lock.color, width: 4, opacity: 1, near: false, casing: PAINT.disc, area: false }, + { ref: 'way/1', icon: paddleMarkerImageId(lock, false), order: 2, area: false }, + { ref: 'way/2', color: weir.color, width: 3, opacity: 0.85, near: true, casing: PAINT.disc, area: false }, + { ref: 'way/2', icon: paddleMarkerImageId(weir, false), order: 1, area: false }, + { ref: 'way/3', color: portage.color, width: 2, opacity: 0.5, near: false, casing: PAINT.disc, area: true }, + { ref: 'way/3', icon: paddleMarkerImageId(portage, false), order: 0, area: true }, + { ref: 'node/4', icon: paddleMarkerImageId(itemOf('node/4'), false), order: 0, area: true }, + { ref: 'node/5', icon: paddleMarkerImageId(itemOf('node/5'), false), order: 0, area: true }, + ]); + // GeoJSON order, [lng, lat], as the features came in; a line's marker sits in its middle. + expect(dataOf(gl).map((entry) => entry.geometry)).toEqual([ + { type: 'LineString', coordinates: LOCK_LINE }, + { type: 'Point', coordinates: [11.11, 48.11] }, + { type: 'LineString', coordinates: WEIR_LINE }, + { type: 'Point', coordinates: [11.21, 48.21] }, + { type: 'LineString', coordinates: PORTAGE_LINE }, + { type: 'Point', coordinates: [11.31, 48.31] }, + { type: 'Point', coordinates: [11.4, 48.4] }, + { type: 'Point', coordinates: [11.5, 48.5] }, + ]); + }); + + it('FE-PADDLE-GL-003: a pick hands the source new data in place, the picked object solid, on top and out of the area', () => { + const gl = fakeGlMap(); + const { rerender } = renderLayer(gl, layerOf(ITEMS)); + const weir = itemOf('way/2'); + + rerender({ layer: layerOf(ITEMS, 'way/2') }); + expect(gl.sources.get(SOURCE)?.setData).toHaveBeenCalledTimes(1); + expect(propsOf(gl, 'way/2')).toEqual([ + { ref: 'way/2', color: weir.color, width: 3, opacity: 0.85, near: false, casing: PAINT.disc, area: false }, + { ref: 'way/2', icon: paddleMarkerImageId(weir, true), order: 3, area: false }, + ]); + + rerender({ layer: layerOf(ITEMS, 'node/5') }); + expect(propsOf(gl, 'node/5')).toEqual([ + { ref: 'node/5', icon: paddleMarkerImageId(itemOf('node/5'), true), order: 3, area: false }, + ]); + expect(gl.spies.addSource).toHaveBeenCalledTimes(1); + expect(addedIds(gl)).toEqual(LAYERS); + }); + + it('FE-PADDLE-GL-004: new objects replace the source data without adding the layers again, sent once per change', () => { + const gl = fakeGlMap(); + const { rerender } = renderLayer(gl, layerOf(ITEMS)); + // Every image the map takes fires styledata; the data it already has is not handed over again. + gl.emit('styledata'); + expect(gl.sources.get(SOURCE)?.setData).not.toHaveBeenCalled(); + expect(drawn).toHaveLength(10); + + rerender({ layer: layerOf(ITEMS.slice(0, 1)) }); + const setData = gl.sources.get(SOURCE)?.setData; + expect(setData).toHaveBeenCalledTimes(1); + expect(dataOf(gl).map((entry) => entry.properties?.ref)).toEqual(['way/1', 'way/1']); + + gl.emit('styledata'); + gl.emit('styledata'); + expect(setData).toHaveBeenCalledTimes(1); + expect(gl.spies.addSource).toHaveBeenCalledTimes(1); + expect(addedIds(gl)).toEqual(LAYERS); + }); + + it('FE-PADDLE-GL-005: a basemap change that dropped the style brings the source, the layers and the images back', () => { + const gl = fakeGlMap(); + const { rerender } = renderLayer(gl, layerOf(ITEMS)); + rerender({ layer: layerOf(ITEMS, 'way/2') }); + loadImages(); + const before = drawn.length; + + gl.dropStyle(); + gl.emit('styledata'); + + expect(gl.spies.addSource).toHaveBeenCalledTimes(2); + expect(addedIds(gl)).toEqual([...LAYERS, ...LAYERS]); + expect(propsOf(gl, 'way/2')).toContainEqual(expect.objectContaining({ order: 3, area: false })); + expect(drawn).toHaveLength(before + 10); + }); + + it('FE-PADDLE-GL-006: a style still loading takes the layers on its next styledata', () => { + const gl = fakeGlMap(); + gl.state.loading = true; + renderLayer(gl, layerOf(ITEMS)); + expect(gl.spies.addLayer).not.toHaveBeenCalled(); + + gl.state.loading = false; + gl.emit('styledata'); + expect(gl.spies.addSource).toHaveBeenCalledTimes(2); + expect(addedIds(gl)).toEqual(LAYERS); + }); + + it('FE-PADDLE-GL-007: one click handler for the map picks the top hit, so a marker over a line opens only its object', () => { + const gl = fakeGlMap(); + const onSelect = vi.fn(); + renderLayer(gl, layerOf(ITEMS, null, onSelect)); + expect(gl.spies.on.mock.calls.filter(([type]) => type === 'click')).toHaveLength(1); + + gl.state.hits = [{ properties: { ref: 'node/4' } }, { properties: { ref: 'way/3' } }]; + gl.emit('click', { point }); + + expect(gl.spies.queryRenderedFeatures).toHaveBeenCalledWith(point, { layers: HIT_LAYERS }); + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).toHaveBeenCalledWith('node/4'); + }); + + it('FE-PADDLE-GL-008: a click on nothing, on a feature without a ref or before the layers exist picks nothing', () => { + const gl = fakeGlMap(); + gl.state.loading = true; + const onSelect = vi.fn(); + renderLayer(gl, layerOf(ITEMS, null, onSelect)); + + gl.state.hits = [{ properties: { ref: 'node/4' } }]; + gl.emit('click', { point }); + expect(gl.spies.queryRenderedFeatures).not.toHaveBeenCalled(); + + gl.state.loading = false; + gl.emit('styledata'); + const misses: Hit[][] = [[], [{ properties: null }], [{ properties: { ref: 7 } }]]; + for (const hits of misses) { + gl.state.hits = hits; + gl.emit('click', { point }); + } + expect(gl.spies.queryRenderedFeatures).toHaveBeenCalledTimes(misses.length); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('FE-PADDLE-GL-009: a new onSelect takes the next click without rebuilding the layers or their data', () => { + const gl = fakeGlMap(); + const first = vi.fn(); + const next = vi.fn(); + const { rerender } = renderLayer(gl, layerOf(ITEMS, null, first)); + rerender({ layer: layerOf(ITEMS, null, next) }); + + gl.state.hits = [{ properties: { ref: 'way/2' } }]; + gl.emit('click', { point }); + + expect(next).toHaveBeenCalledWith('way/2'); + expect(first).not.toHaveBeenCalled(); + expect(gl.spies.addSource).toHaveBeenCalledTimes(1); + expect(gl.sources.get(SOURCE)?.setData).not.toHaveBeenCalled(); + }); + + it('FE-PADDLE-GL-010: the pointer turns into a hand over every layer a click can land on, and back on leave', () => { + const gl = fakeGlMap(); + renderLayer(gl, layerOf(ITEMS)); + + for (const id of HIT_LAYERS) { + gl.emit(`mouseenter:${id}`); + expect(gl.canvas.style.cursor).toBe('pointer'); + gl.emit(`mouseleave:${id}`); + expect(gl.canvas.style.cursor).toBe(''); + } + expect(gl.spies.on).not.toHaveBeenCalledWith('mouseenter', CASING, expect.any(Function)); + expect(gl.spies.on).not.toHaveBeenCalledWith('mouseenter', CASING_AREA, expect.any(Function)); + }); + + it('FE-PADDLE-GL-011: unmounting removes every listener, and without a map nothing happens', () => { + const gl = fakeGlMap(); + const { unmount } = renderLayer(gl, layerOf(ITEMS)); + // styledata, click, and mouseenter and mouseleave on each hit layer + expect(gl.listeners()).toBe(2 + HIT_LAYERS.length * 2); + unmount(); + expect(gl.listeners()).toBe(0); + + expect(() => renderHook(() => usePaddleLayerGL(null, layerOf(ITEMS)))).not.toThrow(); + expect(() => renderHook(() => usePaddleLayerGL(null, undefined))).not.toThrow(); + }); + + it('FE-PADDLE-GL-012: draws the plain and the picked look of every marker once, at twice its size for sharp pixels', () => { + const gl = fakeGlMap(); + const { rerender } = renderLayer(gl, layerOf(ITEMS)); + // Five objects with two looks each make ten images; the put-in and the slipway share theirs. + expect(drawn).toHaveLength(10); + loadImages(); + + const looks = new Set(ITEMS.flatMap(looksOf)); + expect(looks.size).toBe(8); + expect(gl.images).toEqual(looks); + expect(gl.spies.addImage).toHaveBeenCalledTimes(8); + const lock = itemOf('way/1'); + expect(gl.spies.addImage).toHaveBeenCalledWith( + paddleMarkerImageId(lock, false), + expect.objectContaining({ width: 60, src: svgUrl(lock, false) }), + { pixelRatio: 2 } + ); + expect(gl.spies.addImage).toHaveBeenCalledWith( + paddleMarkerImageId(lock, true), + expect.objectContaining({ width: 72, src: svgUrl(lock, true) }), + { pixelRatio: 2 } + ); + + // Picking an object finds both looks on the map already. + rerender({ layer: layerOf(ITEMS, 'way/1') }); + expect(drawn).toHaveLength(10); + }); + + it('FE-PADDLE-GL-013: an image that arrives after the layer went is not added', () => { + const gl = fakeGlMap(); + const { unmount } = renderLayer(gl, layerOf(ITEMS)); + unmount(); + loadImages(); + expect(gl.spies.addImage).not.toHaveBeenCalled(); + }); + + it('FE-PADDLE-GL-014: without objects the layers go top first, then the source, and the next objects bring them back', () => { + const gl = fakeGlMap(); + const { rerender } = renderLayer(gl, layerOf(ITEMS)); + + rerender({ layer: undefined }); + expect(gl.spies.removeLayer.mock.calls.map(([id]) => id)).toEqual([...LAYERS].reverse()); + expect(gl.spies.removeSource).toHaveBeenCalledWith(SOURCE); + expect(gl.layers.size).toBe(0); + expect(gl.sources.size).toBe(0); + + rerender({ layer: layerOf(ITEMS) }); + expect(gl.spies.addSource).toHaveBeenCalledTimes(2); + expect(gl.layers.size).toBe(LAYERS.length); + }); + + it('FE-PADDLE-GL-015: an empty list adds and removes nothing, and a map torn down meanwhile is left alone', () => { + const empty = fakeGlMap(); + renderLayer(empty, layerOf([])); + expect(empty.spies.addSource).not.toHaveBeenCalled(); + expect(empty.spies.removeLayer).not.toHaveBeenCalled(); + expect(empty.spies.removeSource).not.toHaveBeenCalled(); + + const torn = fakeGlMap(); + const { rerender } = renderLayer(torn, layerOf(ITEMS)); + torn.state.removed = true; + expect(() => rerender({ layer: layerOf([]) })).not.toThrow(); + expect(torn.spies.removeLayer).not.toHaveBeenCalled(); + }); +}); + +describe('isPaddleHit', () => { + it('FE-PADDLE-GL-016: is a hit only when a paddle layer is on the map and has a feature under the point', () => { + const gl = fakeGlMap(); + expect(isPaddleHit(gl.spies, point)).toBe(false); + expect(gl.spies.queryRenderedFeatures).not.toHaveBeenCalled(); + + renderLayer(gl, layerOf(ITEMS)); + expect(isPaddleHit(gl.spies, point)).toBe(false); + expect(gl.spies.queryRenderedFeatures).toHaveBeenCalledWith(point, { layers: HIT_LAYERS }); + + gl.state.hits = [{ properties: { ref: 'way/2' } }]; + expect(isPaddleHit(gl.spies, point)).toBe(true); + }); + + it('FE-PADDLE-GL-017: a map without getLayer or queryRenderedFeatures is never hit', () => { + const query = vi.fn(() => [{}]); + expect(isPaddleHit({}, point)).toBe(false); + expect(isPaddleHit({ getLayer: () => ({}) }, point)).toBe(false); + expect(isPaddleHit({ queryRenderedFeatures: query }, point)).toBe(false); + expect(query).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/Tours/paddle/usePaddleObstacleSync.test.ts b/client/src/components/Tours/paddle/usePaddleObstacleSync.test.ts new file mode 100644 index 000000000..fed115a38 --- /dev/null +++ b/client/src/components/Tours/paddle/usePaddleObstacleSync.test.ts @@ -0,0 +1,299 @@ +/** + * FE-HOOK-PADDLESYNC-001 to FE-HOOK-PADDLESYNC-008: how the planner keeps the + * paddle obstacle store in step with the paddle tour it has open. + * + * The hook asks the repo for the tiles around the line, snapped outward to a + * 0.05 degree grid, relates what came back to the line as it is now and + * publishes both, with the length the relations were measured along. A point + * moved inside the same tiles asks nothing new, an answer for an area the line + * has left is dropped, and with no paddle tour open, or a line too short to + * paddle, the store is emptied. + */ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { PaddleCoverageRegion, PaddleFeature, PaddleGeometry, PaddleObstacleKind } from '@trek/shared'; + +import { + PaddleObstaclesOfflineError, + paddleObstacleRepo, + type PaddleObstaclesAround, + type Tile, +} from '../../../repo/paddleObstacleRepo'; +import { + clearPaddleObstacles, + publishPaddleObstacles, + selectPaddleObstacle, + usePaddleObstacleStore, +} from '../../../store/paddleObstacleStore'; +import { usePaddleObstacleSync } from './usePaddleObstacleSync'; + +// Only the request is answered here: the offline error and the tile maths stay the repo's own. +vi.mock('../../../repo/paddleObstacleRepo', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, paddleObstacleRepo: { ...actual.paddleObstacleRepo, inTiles: vi.fn() } }; +}); + +type Line = readonly (readonly [number, number])[]; + +interface Props { + line: Line | null; + active: boolean; +} + +/** A kilometre of river running north, [lat, lng] as the planner draws it. */ +const LINE: Line = [ + [46.5, 11.8], + [46.51, 11.8], +]; +/** Its first point dragged 100 m east: still inside the same snapped tiles. */ +const MOVED: Line = [ + [46.5, 11.8013], + [46.51, 11.8], +]; +/** A line in another area altogether. */ +const FAR_LINE: Line = [ + [47, 12], + [47.01, 12], +]; + +/** The tile LINE (and MOVED) asks for, snapped outward to the 0.05 degree grid. */ +const LINE_TILE: Tile = { south: 46.45, west: 11.75, north: 46.55, east: 11.85 }; +const FAR_TILE: Tile = { south: 46.95, west: 11.95, north: 47.05, east: 12.05 }; + +const feature = (ref: string, kind: PaddleObstacleKind, geometry: PaddleGeometry): PaddleFeature => ({ + ref, + kind, + group_key: null, + name: null, + geometry, + tags: {}, + tag_hash: `${ref}-hash`, + region_id: 'bavaria', +}); + +/** Across the river halfway down LINE; geometry is [lng, lat], as OSM gives it. */ +const WEIR = feature('way/1', 'weir', { + type: 'LineString', + coordinates: [ + [11.799, 46.505], + [11.801, 46.505], + ], +}); +/** On the east bank, 100 m off LINE: near it, and on the line once its first point moves east. */ +const SLIPWAY = feature('node/2', 'slipway', { type: 'Point', coordinates: [11.8013, 46.502] }); +/** Several kilometres east: read with the area, related to no line. */ +const DAM = feature('way/3', 'dam', { type: 'Point', coordinates: [11.9, 46.505] }); +/** On FAR_LINE. */ +const LOCK = feature('node/4', 'lock', { type: 'Point', coordinates: [12, 47.005] }); + +const REGION: PaddleCoverageRegion = { + id: 'bavaria', + name: 'Bavaria', + source_timestamp: '2026-09-01T00:00:00Z', + imported_at: '2026-09-02T08:00:00Z', +}; + +/** What the repo reads around LINE: one region only partly covering it, and cut at the cap. */ +const ANSWER: PaddleObstaclesAround = { + features: [WEIR, SLIPWAY, DAM], + coverage: 'partial', + regions: [REGION], + truncated: true, +}; +const FAR_ANSWER: PaddleObstaclesAround = { + features: [LOCK], + coverage: 'covered', + regions: [REGION], + truncated: false, +}; + +const inTiles = vi.mocked(paddleObstacleRepo.inTiles); +const state = () => usePaddleObstacleStore.getState(); + +function renderSync(props: Props) { + return renderHook(({ line, active }: Props) => usePaddleObstacleSync(line, active), { initialProps: props }); +} + +/** A request the test answers by hand, so the state while it is on its way can be read. */ +function deferred() { + let resolve: (value: PaddleObstaclesAround) => void = () => undefined; + let reject: (reason: unknown) => void = () => undefined; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, resolve, reject }; +} + +/** Lets a settled request run its handlers and the store follow. */ +async function drain() { + await act(async () => { + for (let i = 0; i < 5; i += 1) await Promise.resolve(); + }); +} + +/** What a tour opened before left in the store, so a test can see it emptied. */ +function seed() { + publishPaddleObstacles({ + status: 'ready', + features: [WEIR], + related: [], + routeLengthM: 500, + coverage: 'covered', + regions: [REGION], + truncated: false, + }); +} + +describe('usePaddleObstacleSync', () => { + beforeEach(() => { + inTiles.mockReset(); + inTiles.mockResolvedValue(ANSWER); + clearPaddleObstacles(); + }); + + it('FE-HOOK-PADDLESYNC-001: publishes loading while asking, then the objects, the related ones and the coverage', async () => { + const pending = deferred(); + inTiles.mockReturnValueOnce(pending.promise); + renderSync({ line: LINE, active: true }); + + expect(inTiles).toHaveBeenCalledWith([LINE_TILE], expect.any(AbortSignal)); + expect(state()).toMatchObject({ + status: 'loading', + features: [], + related: [], + coverage: 'none', + regions: [], + truncated: false, + }); + expect(state().routeLengthM).toBeCloseTo(1111.95, 1); + + pending.resolve(ANSWER); + await drain(); + + expect(state()).toMatchObject({ + status: 'ready', + features: [WEIR, SLIPWAY, DAM], + coverage: 'partial', + regions: [REGION], + truncated: true, + }); + // In the order the line reaches them; the dam is too far off to relate. + expect(state().related).toMatchObject([ + { feature: { ref: 'node/2' }, relation: 'near' }, + { feature: { ref: 'way/1' }, relation: 'on_route', distanceM: 0, alongM: 556 }, + ]); + expect(state().routeLengthM).toBeCloseTo(1111.95, 1); + expect(inTiles).toHaveBeenCalledTimes(1); + }); + + it('FE-HOOK-PADDLESYNC-002: offline it reports the data out of reach, never a route free of obstacles', async () => { + inTiles.mockRejectedValue(new PaddleObstaclesOfflineError()); + renderSync({ line: LINE, active: true }); + await drain(); + + expect(state()).toMatchObject({ status: 'offline', features: [], related: [], coverage: 'none', regions: [] }); + expect(state().routeLengthM).toBeCloseTo(1111.95, 1); + }); + + it('FE-HOOK-PADDLESYNC-003: a failed request reads as an error, not as offline', async () => { + inTiles.mockRejectedValue(new Error('502')); + renderSync({ line: LINE, active: true }); + await drain(); + + expect(state()).toMatchObject({ status: 'error', features: [], related: [], coverage: 'none' }); + }); + + it.each<[string, Props]>([ + ['switched off', { line: LINE, active: false }], + ['without a line', { line: null, active: true }], + ['for a line of one point', { line: [[46.5, 11.8]], active: true }], + ])('FE-HOOK-PADDLESYNC-004: asks for nothing and empties the store %s', async (_label, props) => { + seed(); + renderSync(props); + await drain(); + + expect(inTiles).not.toHaveBeenCalled(); + expect(state()).toMatchObject({ status: 'idle', features: [], related: [], routeLengthM: 0, regions: [] }); + }); + + it('FE-HOOK-PADDLESYNC-005: a point moved inside the same tiles asks nothing new, a line that leaves them asks again', async () => { + const far = deferred(); + inTiles.mockResolvedValueOnce(ANSWER).mockReturnValueOnce(far.promise); + const { rerender } = renderSync({ line: LINE, active: true }); + await drain(); + selectPaddleObstacle('way/1'); + + rerender({ line: MOVED, active: true }); + await drain(); + expect(inTiles).toHaveBeenCalledTimes(1); + // Related to the line as it is now: the slipway is on it, and the open card stays open. + expect(state()).toMatchObject({ status: 'ready', selectedRef: 'way/1' }); + expect(state().related).toMatchObject([ + { feature: { ref: 'node/2' }, relation: 'on_route' }, + { feature: { ref: 'way/1' }, relation: 'on_route' }, + ]); + expect(state().routeLengthM).toBeCloseTo(1116.39, 1); + + rerender({ line: FAR_LINE, active: true }); + expect(inTiles).toHaveBeenCalledTimes(2); + expect(inTiles).toHaveBeenLastCalledWith([FAR_TILE], expect.any(AbortSignal)); + // The old area's objects are not shown around the new line while it loads. + expect(state()).toMatchObject({ status: 'loading', features: [], related: [], selectedRef: null }); + + far.resolve(FAR_ANSWER); + await drain(); + expect(state()).toMatchObject({ status: 'ready', features: [LOCK], coverage: 'covered' }); + expect(state().related).toMatchObject([{ feature: { ref: 'node/4' }, relation: 'on_route' }]); + }); + + it.each(['answers', 'fails'] as const)( + 'FE-HOOK-PADDLESYNC-006: keeps the new area when the request for the old one %s late', + async (outcome) => { + const old = deferred(); + inTiles.mockReturnValueOnce(old.promise).mockResolvedValueOnce(FAR_ANSWER); + const { rerender } = renderSync({ line: LINE, active: true }); + rerender({ line: FAR_LINE, active: true }); + await drain(); + expect(inTiles.mock.calls[0]?.[1]?.aborted).toBe(true); + expect(state().status).toBe('ready'); + + if (outcome === 'answers') old.resolve(ANSWER); + else old.reject(new Error('502')); + await drain(); + + expect(state()).toMatchObject({ status: 'ready', features: [LOCK], coverage: 'covered' }); + } + ); + + it('FE-HOOK-PADDLESYNC-007: switching the paddle tour off empties the store and closes the open card', async () => { + const { rerender } = renderSync({ line: LINE, active: true }); + await drain(); + selectPaddleObstacle('way/1'); + expect(state().status).toBe('ready'); + + rerender({ line: LINE, active: false }); + expect(state()).toMatchObject({ status: 'idle', features: [], related: [], routeLengthM: 0, selectedRef: null }); + expect(inTiles).toHaveBeenCalledTimes(1); + }); + + it('FE-HOOK-PADDLESYNC-008: empties the store when the planner closes', async () => { + const { unmount } = renderSync({ line: LINE, active: true }); + await drain(); + selectPaddleObstacle('way/1'); + expect(state()).toMatchObject({ status: 'ready', selectedRef: 'way/1' }); + + unmount(); + expect(state()).toEqual({ + status: 'idle', + features: [], + related: [], + routeLengthM: 0, + coverage: 'none', + regions: [], + truncated: false, + selectedRef: null, + }); + }); +}); diff --git a/client/src/components/Tours/paddle/useTourWind.test.ts b/client/src/components/Tours/paddle/useTourWind.test.ts new file mode 100644 index 000000000..9e893944d --- /dev/null +++ b/client/src/components/Tours/paddle/useTourWind.test.ts @@ -0,0 +1,270 @@ +/** + * FE-HOOK-TOURWIND-001 to FE-HOOK-TOURWIND-010: the wind forecast for a paddle + * tour on its day, at its start (PAD-08). + * + * Pinned: without a day, a date or a start place it says which is missing and + * asks nothing; one request per place, day and retry, loading until it + * answers; offline and a failure each say so, and a retry asks again; the day + * shown is the first still to come, else the first of the tour, or the one + * chosen while the tour is on it; an answer for a day no longer shown is + * dropped, and leaving the view aborts the request. + */ +import { act, renderHook, waitFor } from '@testing-library/react'; +import type { WindForecastResponse } from '@trek/shared'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { buildAssignment, buildDay, buildPlace } from '../../../../tests/helpers/factories'; +import { resetAllStores, seedStore } from '../../../../tests/helpers/store'; +import { WindOfflineError } from '../../../repo/windRepo'; +import { useTripStore } from '../../../store/tripStore'; +import type { AssignmentsMap, Day } from '../../../types'; +import { useTourWind } from './useTourWind'; + +type Forecast = (lat: number, lng: number, date: string, signal?: AbortSignal) => Promise; + +const repo = vi.hoisted(() => ({ forecast: vi.fn() })); +vi.mock('../../../repo/windRepo', async (importOriginal) => ({ + ...(await importOriginal()), + windRepo: repo, +})); + +const TODAY = '2026-07-01'; +const START = { lat: 47.5, lng: 9.7 }; +const tour = buildPlace({ name: 'Lake loop' }); + +function forecastFor(date: string): WindForecastResponse { + return { + status: 'forecast', + date, + lat: START.lat, + lng: START.lng, + timezone: 'Europe/Berlin', + source: 'Open-Meteo', + fetched_at: `${TODAY}T06:00:00Z`, + horizon_days: 16, + hours: [{ hour: 9, speed_kmh: 12, gusts_kmh: 25, direction_deg: 240 }], + day: { speed_max_kmh: 18, gusts_max_kmh: 31, direction_dominant_deg: 250 }, + }; +} + +/** The trip's days, with the tour on the ones in `tourOn`. */ +function seedTour(days: Day[], tourOn: Day[] = days): void { + const assignments: AssignmentsMap = {}; + for (const day of tourOn) assignments[String(day.id)] = [buildAssignment({ day_id: day.id, place: tour })]; + seedStore(useTripStore, { days, assignments }); +} + +/** A request that answers only when the test says so. */ +function deferred() { + let resolve: (forecast: WindForecastResponse) => void = () => undefined; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +/** A request that, like the real one, fails as soon as its signal is aborted. */ +function abortable(_lat: number, _lng: number, _date: string, signal?: AbortSignal): Promise { + return new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))); + }); +} + +function signalOf(call: number): AbortSignal | undefined { + return repo.forecast.mock.calls[call]?.[3]; +} + +/** Lets every pending promise reaction run, a late answer included. */ +async function settle(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(`${TODAY}T10:00:00Z`)); + resetAllStores(); + repo.forecast.mockReset(); + repo.forecast.mockImplementation(async (_lat, _lng, date) => forecastFor(date)); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('useTourWind (PAD-08)', () => { + it('FE-HOOK-TOURWIND-001: without a day the tour is on, it says so and asks nothing', () => { + seedStore(useTripStore, { days: undefined }); + const unloaded = renderHook(() => useTourWind(tour.id, START)); + expect(unloaded.result.current.state).toEqual({ kind: 'no_day' }); + unloaded.unmount(); + + const day = buildDay({ date: '2026-07-04' }); + seedTour([day], []); + const offDay = renderHook(() => useTourWind(tour.id, START)); + expect(offDay.result.current.state).toEqual({ kind: 'no_day' }); + expect(offDay.result.current.day).toBeNull(); + expect(offDay.result.current.tourDays).toEqual([]); + offDay.unmount(); + + seedTour([day]); + const noTour = renderHook(() => useTourWind(null, START)); + expect(noTour.result.current.state).toEqual({ kind: 'no_day' }); + expect(repo.forecast).not.toHaveBeenCalled(); + }); + + it('FE-HOOK-TOURWIND-002: a day without a date says so and asks nothing', () => { + const day = buildDay({ date: null }); + seedTour([day]); + const { result } = renderHook(() => useTourWind(tour.id, START)); + expect(result.current.state).toEqual({ kind: 'no_date' }); + expect(result.current.day).toEqual({ day, index: 0 }); + expect(repo.forecast).not.toHaveBeenCalled(); + }); + + it('FE-HOOK-TOURWIND-003: without a start place it says so and asks nothing', () => { + const day = buildDay({ date: '2026-07-04' }); + seedTour([day]); + const { result } = renderHook(() => useTourWind(tour.id, null)); + expect(result.current.state).toEqual({ kind: 'no_place' }); + expect(result.current.day).toEqual({ day, index: 0 }); + expect(repo.forecast).not.toHaveBeenCalled(); + }); + + it('FE-HOOK-TOURWIND-004: loads the forecast for the start on the tour day, once per place and day', async () => { + seedTour([buildDay({ date: '2026-07-04' })]); + const pending = deferred(); + repo.forecast.mockReturnValueOnce(pending.promise); + const { result, rerender } = renderHook(({ start }) => useTourWind(tour.id, start), { + initialProps: { start: START }, + }); + expect(result.current.state).toEqual({ kind: 'loading' }); + expect(repo.forecast).toHaveBeenCalledWith(47.5, 9.7, '2026-07-04', expect.any(AbortSignal)); + + await act(async () => { + pending.resolve(forecastFor('2026-07-04')); + await pending.promise; + }); + expect(result.current.state).toEqual({ kind: 'ready', forecast: forecastFor('2026-07-04') }); + + rerender({ start: { ...START } }); + expect(repo.forecast).toHaveBeenCalledTimes(1); + expect(result.current.state).toEqual({ kind: 'ready', forecast: forecastFor('2026-07-04') }); + + rerender({ start: { lat: 47.6, lng: 9.8 } }); + expect(result.current.state).toEqual({ kind: 'loading' }); + await waitFor(() => expect(result.current.state).toEqual({ kind: 'ready', forecast: forecastFor('2026-07-04') })); + expect(repo.forecast).toHaveBeenCalledTimes(2); + expect(repo.forecast).toHaveBeenLastCalledWith(47.6, 9.8, '2026-07-04', expect.any(AbortSignal)); + }); + + it('FE-HOOK-TOURWIND-005: offline it says so instead of failing', async () => { + seedTour([buildDay({ date: '2026-07-04' })]); + repo.forecast.mockRejectedValueOnce(new WindOfflineError()); + const { result } = renderHook(() => useTourWind(tour.id, START)); + expect(result.current.state).toEqual({ kind: 'loading' }); + await waitFor(() => expect(result.current.state).toEqual({ kind: 'offline' })); + }); + + it('FE-HOOK-TOURWIND-006: a failure says so, and a retry asks again', async () => { + seedTour([buildDay({ date: '2026-07-04' })]); + repo.forecast.mockRejectedValueOnce(new Error('Request failed with status code 502')); + const { result } = renderHook(() => useTourWind(tour.id, START)); + await waitFor(() => expect(result.current.state).toEqual({ kind: 'error' })); + + act(() => result.current.retry()); + expect(result.current.state).toEqual({ kind: 'loading' }); + await waitFor(() => expect(result.current.state).toEqual({ kind: 'ready', forecast: forecastFor('2026-07-04') })); + expect(repo.forecast).toHaveBeenCalledTimes(2); + expect(repo.forecast).toHaveBeenNthCalledWith(2, 47.5, 9.7, '2026-07-04', expect.any(AbortSignal)); + }); + + it('FE-HOOK-TOURWIND-007: shows the first day still to come, else the first day of the tour', async () => { + const past = buildDay({ date: '2026-06-20' }); + const undated = buildDay({ date: null }); + const today = buildDay({ date: TODAY }); + const later = buildDay({ date: '2026-07-04' }); + seedTour([past, undated, today, later]); + const upcoming = renderHook(() => useTourWind(tour.id, START)); + expect(upcoming.result.current.day).toEqual({ day: today, index: 2 }); + expect(upcoming.result.current.tourDays.map(({ day }) => day.id)).toEqual([ + past.id, + undated.id, + today.id, + later.id, + ]); + await waitFor(() => expect(upcoming.result.current.state).toEqual({ kind: 'ready', forecast: forecastFor(TODAY) })); + upcoming.unmount(); + + seedTour([past, undated, later], [past, undated]); + const gone = renderHook(() => useTourWind(tour.id, START)); + expect(gone.result.current.day).toEqual({ day: past, index: 0 }); + await waitFor(() => + expect(gone.result.current.state).toEqual({ kind: 'ready', forecast: forecastFor('2026-06-20') }) + ); + }); + + it('FE-HOOK-TOURWIND-008: a tour on two days shows the chosen one, and falls back once the tour leaves it', async () => { + const first = buildDay({ date: '2026-07-04' }); + const second = buildDay({ date: '2026-07-05' }); + seedTour([first, second]); + const { result } = renderHook(() => useTourWind(tour.id, START)); + expect(result.current.tourDays).toEqual([ + { day: first, index: 0 }, + { day: second, index: 1 }, + ]); + expect(result.current.day).toEqual({ day: first, index: 0 }); + await waitFor(() => expect(result.current.state).toEqual({ kind: 'ready', forecast: forecastFor('2026-07-04') })); + + act(() => result.current.chooseDay(second.id)); + expect(result.current.day).toEqual({ day: second, index: 1 }); + expect(result.current.state).toEqual({ kind: 'loading' }); + await waitFor(() => expect(result.current.state).toEqual({ kind: 'ready', forecast: forecastFor('2026-07-05') })); + expect(repo.forecast).toHaveBeenLastCalledWith(47.5, 9.7, '2026-07-05', expect.any(AbortSignal)); + + act(() => seedTour([first, second], [first])); + expect(result.current.day).toEqual({ day: first, index: 0 }); + await waitFor(() => expect(result.current.state).toEqual({ kind: 'ready', forecast: forecastFor('2026-07-04') })); + expect(repo.forecast).toHaveBeenCalledTimes(3); + }); + + it('FE-HOOK-TOURWIND-009: an answer for a day no longer shown is dropped', async () => { + const first = buildDay({ date: '2026-07-04' }); + const second = buildDay({ date: '2026-07-05' }); + seedTour([first, second]); + const slow = deferred(); + const fresh = deferred(); + repo.forecast.mockReturnValueOnce(slow.promise).mockReturnValueOnce(fresh.promise); + const { result } = renderHook(() => useTourWind(tour.id, START)); + + act(() => result.current.chooseDay(second.id)); + expect(signalOf(0)?.aborted).toBe(true); + expect(signalOf(1)?.aborted).toBe(false); + + await act(async () => { + slow.resolve(forecastFor('2026-07-04')); + await slow.promise; + }); + expect(result.current.state).toEqual({ kind: 'loading' }); + + await act(async () => { + fresh.resolve(forecastFor('2026-07-05')); + await fresh.promise; + }); + expect(result.current.state).toEqual({ kind: 'ready', forecast: forecastFor('2026-07-05') }); + }); + + it('FE-HOOK-TOURWIND-010: leaving the view aborts the request, and its late failure is dropped', async () => { + seedTour([buildDay({ date: '2026-07-04' })]); + repo.forecast.mockImplementationOnce(abortable); + const { result, unmount } = renderHook(() => useTourWind(tour.id, START)); + expect(result.current.state).toEqual({ kind: 'loading' }); + expect(signalOf(0)?.aborted).toBe(false); + + unmount(); + expect(signalOf(0)?.aborted).toBe(true); + // The rejection the abort causes reaches the hook after it is gone, and nothing is set from it. + await settle(); + expect(repo.forecast).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/repo/paddleObstacleRepo.test.ts b/client/src/repo/paddleObstacleRepo.test.ts new file mode 100644 index 000000000..da8da2857 --- /dev/null +++ b/client/src/repo/paddleObstacleRepo.test.ts @@ -0,0 +1,219 @@ +// FE-REPO-PADDLEOBS-001 to FE-REPO-PADDLEOBS-012: the online paddle obstacle read, tiled under the cap and merged. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + PADDLE_OBSTACLES_MAX_SPAN_DEG, + type PaddleCoverageRegion, + type PaddleCoverageState, + type PaddleFeature, + type PaddleObstaclesResponse, +} from '@trek/shared'; + +import { paddleObstaclesApi } from '../api/paddleObstacles'; +import { isEffectivelyOffline } from '../sync/networkMode'; +import { PaddleObstaclesOfflineError, paddleObstacleRepo, tilesAround, type Tile } from './paddleObstacleRepo'; + +vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn(() => false) })); +vi.mock('../api/paddleObstacles', () => ({ paddleObstaclesApi: { inBox: vi.fn() } })); + +const inBox = vi.mocked(paddleObstaclesApi.inBox); +const offline = vi.mocked(isEffectivelyOffline); + +/** Three points whose box, with the margin, is 1.52 degrees high and 2.02 wide. */ +const LONG_LINE: [number, number][] = [ + [48.2, 11], + [49.5, 12.2], + [48, 13], +]; + +function feature(ref: string, regionId: string, kind: PaddleFeature['kind'] = 'weir'): PaddleFeature { + return { + ref, + kind, + group_key: null, + name: null, + geometry: { type: 'Point', coordinates: [12.13, 49.12] }, + tags: { waterway: kind }, + tag_hash: `${regionId}:${ref}`, + region_id: regionId, + }; +} + +function region(id: string, name: string): PaddleCoverageRegion { + return { id, name, source_timestamp: '2026-09-30T00:00:00Z', imported_at: '2026-10-01T06:00:00Z' }; +} + +function answer( + features: PaddleFeature[], + extra: { state?: PaddleCoverageState; regions?: PaddleCoverageRegion[]; truncated?: boolean } = {} +): PaddleObstaclesResponse { + return { + features, + coverage: { state: extra.state ?? 'covered', regions: extra.regions ?? [] }, + truncated: extra.truncated ?? false, + }; +} + +/** The index-th tile of a row along 49 degrees north, each 0.8 degrees wide. */ +function tileAt(index: number): Tile { + return { south: 49, west: 12 + index * 0.8, north: 49.8, east: 12.8 + index * 0.8 }; +} + +const round = (value: number): number => Number(value.toFixed(6)); + +/** Each tile as [south, west, north, east], rounded past the float noise of the stepping. */ +function rounded(tiles: readonly Tile[]): number[][] { + return tiles.map((tile) => [tile.south, tile.west, tile.north, tile.east].map(round)); +} + +beforeEach(() => { + vi.resetAllMocks(); + offline.mockReturnValue(false); + inBox.mockResolvedValue(answer([])); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('tilesAround', () => { + it('FE-REPO-PADDLEOBS-001: a line without points asks for no tile', () => { + expect(tilesAround([])).toEqual([]); + }); + + it('FE-REPO-PADDLEOBS-002: a short line is one tile, its box widened by the margin on every side', () => { + const line: [number, number][] = [ + [49.1, 12.3], + [49.2, 12.1], + ]; + expect(rounded(tilesAround(line))).toEqual([[49.09, 12.09, 49.21, 12.31]]); + expect(rounded(tilesAround([[-34.6, -58.4]]))).toEqual([[-34.61, -58.41, -34.59, -58.39]]); + }); + + it('FE-REPO-PADDLEOBS-003: a long line is split row by row into tiles under the cap that leave no gap', () => { + const tiles = tilesAround(LONG_LINE); + expect(rounded(tiles)).toEqual([ + [47.99, 10.99, 48.79, 11.79], + [47.99, 11.79, 48.79, 12.59], + [47.99, 12.59, 48.79, 13.01], + [48.79, 10.99, 49.51, 11.79], + [48.79, 11.79, 49.51, 12.59], + [48.79, 12.59, 49.51, 13.01], + ]); + for (const tile of tiles) { + expect(tile.north - tile.south).toBeLessThan(PADDLE_OBSTACLES_MAX_SPAN_DEG); + expect(tile.east - tile.west).toBeLessThan(PADDLE_OBSTACLES_MAX_SPAN_DEG); + } + }); +}); + +describe('paddleObstacleRepo.inTiles', () => { + it('FE-REPO-PADDLEOBS-004: asks once per tile with the signal and keeps an object two tiles read once', async () => { + const weir = feature('way/1', 'bayern'); + const lock = feature('node/2', 'bayern', 'lock'); + const rapids = feature('node/3', 'bayern', 'rapids'); + inBox.mockResolvedValueOnce(answer([weir, lock])); + inBox.mockResolvedValueOnce(answer([weir, rapids])); + const signal = new AbortController().signal; + + const result = await paddleObstacleRepo.inTiles([tileAt(0), tileAt(1)], signal); + + expect(inBox).toHaveBeenCalledTimes(2); + expect(inBox).toHaveBeenNthCalledWith(1, tileAt(0), signal); + expect(inBox).toHaveBeenNthCalledWith(2, tileAt(1), signal); + expect(result.features).toEqual([weir, lock, rapids]); + }); + + it('FE-REPO-PADDLEOBS-005: an object two regions hold at their border is one object, as first read', async () => { + const fromBayern = feature('way/7', 'bayern'); + const fromThueringen = feature('way/7', 'thueringen'); + const slipway = feature('node/8', 'thueringen', 'slipway'); + inBox.mockResolvedValueOnce(answer([fromBayern])); + inBox.mockResolvedValueOnce(answer([fromThueringen, slipway])); + expect((await paddleObstacleRepo.inTiles([tileAt(0), tileAt(1)])).features).toEqual([fromBayern, slipway]); + + inBox.mockResolvedValueOnce(answer([fromThueringen, fromBayern])); + expect((await paddleObstacleRepo.inTiles([tileAt(0)])).features).toEqual([fromThueringen]); + }); + + it('FE-REPO-PADDLEOBS-006: the weakest coverage among the tiles is the coverage of the whole', async () => { + async function coverageOf(states: PaddleCoverageState[]): Promise { + for (const state of states) inBox.mockResolvedValueOnce(answer([], { state })); + const result = await paddleObstacleRepo.inTiles(states.map((_state, index) => tileAt(index))); + return result.coverage; + } + + expect(await coverageOf(['covered', 'covered'])).toBe('covered'); + expect(await coverageOf(['covered', 'partial', 'covered'])).toBe('partial'); + expect(await coverageOf(['partial', 'none', 'covered'])).toBe('none'); + expect(await coverageOf(['none', 'partial'])).toBe('none'); + }); + + it('FE-REPO-PADDLEOBS-007: the read counts as cut when any one tile was cut', async () => { + async function truncatedOf(cuts: boolean[]): Promise { + for (const truncated of cuts) inBox.mockResolvedValueOnce(answer([feature('way/1', 'bayern')], { truncated })); + const result = await paddleObstacleRepo.inTiles(cuts.map((_cut, index) => tileAt(index))); + return result.truncated; + } + + expect(await truncatedOf([false, true, false])).toBe(true); + expect(await truncatedOf([false, false])).toBe(false); + }); + + it('FE-REPO-PADDLEOBS-008: the regions of all tiles are merged, each region once', async () => { + const bayern = region('bayern', 'Bayern'); + const thueringen = region('thueringen', 'Thüringen'); + inBox.mockResolvedValueOnce(answer([], { regions: [bayern] })); + inBox.mockResolvedValueOnce(answer([], { state: 'partial', regions: [bayern, thueringen] })); + + const result = await paddleObstacleRepo.inTiles([tileAt(0), tileAt(1)]); + + expect(result.regions).toEqual([bayern, thueringen]); + expect(result.coverage).toBe('partial'); + }); + + it('FE-REPO-PADDLEOBS-009: no tile asks nothing and claims no coverage', async () => { + await expect(paddleObstacleRepo.inTiles([])).resolves.toEqual({ + features: [], + coverage: 'none', + regions: [], + truncated: false, + }); + expect(inBox).not.toHaveBeenCalled(); + }); + + it('FE-REPO-PADDLEOBS-010: offline it refuses with its own error and makes no request', async () => { + offline.mockReturnValue(true); + + await expect(paddleObstacleRepo.inTiles([tileAt(0)])).rejects.toBeInstanceOf(PaddleObstaclesOfflineError); + await expect(paddleObstacleRepo.around(LONG_LINE)).rejects.toMatchObject({ + name: 'PaddleObstaclesOfflineError', + message: 'Paddle obstacles need a connection', + }); + expect(inBox).not.toHaveBeenCalled(); + }); + + it('FE-REPO-PADDLEOBS-011: a failing tile fails the whole read instead of a route that looks free', async () => { + inBox.mockResolvedValueOnce(answer([feature('way/1', 'bayern')])); + inBox.mockRejectedValueOnce(new Error('Network Error')); + + await expect(paddleObstacleRepo.inTiles([tileAt(0), tileAt(1)])).rejects.toThrow('Network Error'); + }); +}); + +describe('paddleObstacleRepo.around', () => { + it('FE-REPO-PADDLEOBS-012: reads the tiles around the line with the signal and merges their answers', async () => { + const inTiles = vi.spyOn(paddleObstacleRepo, 'inTiles'); + const weir = feature('way/1', 'bayern'); + const bayern = region('bayern', 'Bayern'); + inBox.mockResolvedValue(answer([weir], { regions: [bayern] })); + const signal = new AbortController().signal; + + const result = await paddleObstacleRepo.around(LONG_LINE, signal); + + expect(inTiles).toHaveBeenCalledWith(tilesAround(LONG_LINE), signal); + expect(inBox).toHaveBeenCalledTimes(6); + for (const tile of tilesAround(LONG_LINE)) expect(inBox).toHaveBeenCalledWith(tile, signal); + expect(result).toEqual({ features: [weir], coverage: 'covered', regions: [bayern], truncated: false }); + }); +}); diff --git a/client/src/repo/paddleRegionRepo.test.ts b/client/src/repo/paddleRegionRepo.test.ts new file mode 100644 index 000000000..b14f20142 --- /dev/null +++ b/client/src/repo/paddleRegionRepo.test.ts @@ -0,0 +1,223 @@ +// FE-REPO-PADDLEREGION-001 to FE-REPO-PADDLEREGION-008: the instance's paddle regions, managed with a connection only. +// FE-API-PADDLEREGIONS-001 to FE-API-PADDLEREGIONS-004: the admin calls behind them, held to the @trek/shared contract. +import { + paddleRegionCatalogResponseSchema, + paddleRegionListResponseSchema, + paddleRegionRequestSchema, + paddleRegionSchema, + successResponseSchema, + type PaddleRegion, + type PaddleRegionCatalogEntry, +} from '@trek/shared'; +import { http, HttpResponse } from 'msw'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { contractHandler } from '../../tests/helpers/msw/contract'; +import { server } from '../../tests/helpers/msw/server'; +import { paddleRegionsApi } from '../api/paddleRegions'; +import { isEffectivelyOffline } from '../sync/networkMode'; +import { paddleRegionErrorCode, paddleRegionRepo, PaddleRegionsOfflineError } from './paddleRegionRepo'; + +vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn(() => false) })); +vi.mock('../api/paddleRegions', () => ({ + paddleRegionsApi: { list: vi.fn(), catalog: vi.fn(), add: vi.fn(), refresh: vi.fn(), remove: vi.fn() }, +})); + +const LIST = '/api/admin/paddle-regions'; +const CATALOG = '/api/admin/paddle-regions/catalog'; +const REFRESH = '/api/admin/paddle-regions/refresh'; +const REMOVE = '/api/admin/paddle-regions/remove'; + +const REGION: PaddleRegion = { + id: 'oberpfalz', + name: 'Oberpfalz', + status: 'ready', + source_timestamp: '2026-09-30T20:21:02Z', + imported_at: '2026-10-01T03:00:00.000Z', + feature_count: 412, + error: null, + progress: null, +}; +const QUEUED: PaddleRegion = { + ...REGION, + status: 'queued', + source_timestamp: null, + imported_at: null, + feature_count: 0, +}; +const READING: PaddleRegion = { ...REGION, status: 'importing', progress: { phase: 'download', fraction: 0.4 } }; +const ENTRY: PaddleRegionCatalogEntry = { id: 'oberpfalz', name: 'Oberpfalz', path: ['Europe', 'Germany', 'Bayern'] }; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(isEffectivelyOffline).mockReturnValue(false); + vi.mocked(paddleRegionsApi.list).mockResolvedValue([REGION]); + vi.mocked(paddleRegionsApi.catalog).mockResolvedValue([ENTRY]); + vi.mocked(paddleRegionsApi.add).mockResolvedValue(QUEUED); + vi.mocked(paddleRegionsApi.refresh).mockResolvedValue(READING); + vi.mocked(paddleRegionsApi.remove).mockResolvedValue({ success: true }); +}); + +describe('paddleRegionRepo online', () => { + it('FE-REPO-PADDLEREGION-001: the list and the catalogue search go to the api and answer what it answers', async () => { + const signal = new AbortController().signal; + await expect(paddleRegionRepo.list()).resolves.toEqual([REGION]); + await expect(paddleRegionRepo.catalog('ober', signal)).resolves.toEqual([ENTRY]); + expect(paddleRegionsApi.list).toHaveBeenCalledTimes(1); + expect(paddleRegionsApi.catalog).toHaveBeenCalledWith('ober', signal); + }); + + it('FE-REPO-PADDLEREGION-002: add, refresh and remove hand the region id on and answer the region as it stands', async () => { + await expect(paddleRegionRepo.add('oberpfalz')).resolves.toEqual(QUEUED); + await expect(paddleRegionRepo.refresh('oberpfalz')).resolves.toEqual(READING); + await expect(paddleRegionRepo.remove('us/texas')).resolves.toEqual({ success: true }); + expect(paddleRegionsApi.add).toHaveBeenCalledWith('oberpfalz'); + expect(paddleRegionsApi.refresh).toHaveBeenCalledWith('oberpfalz'); + expect(paddleRegionsApi.remove).toHaveBeenCalledWith('us/texas'); + }); + + it('FE-REPO-PADDLEREGION-003: a refusal from the server comes back unchanged, so its code is still read', async () => { + const refusal = { response: { status: 502, data: { error: 'Refused', code: 'catalog_unavailable' } } }; + vi.mocked(paddleRegionsApi.catalog).mockRejectedValue(refusal); + await expect(paddleRegionRepo.catalog('ober')).rejects.toBe(refusal); + expect(paddleRegionErrorCode(refusal)).toBe('catalog_unavailable'); + }); +}); + +describe('paddleRegionRepo offline', () => { + it('FE-REPO-PADDLEREGION-004: every call refuses with PaddleRegionsOfflineError and asks nothing', async () => { + vi.mocked(isEffectivelyOffline).mockReturnValue(true); + const calls: Array<() => Promise> = [ + () => paddleRegionRepo.list(), + () => paddleRegionRepo.catalog('ober', new AbortController().signal), + () => paddleRegionRepo.add('oberpfalz'), + () => paddleRegionRepo.refresh('oberpfalz'), + () => paddleRegionRepo.remove('oberpfalz'), + ]; + for (const call of calls) await expect(call()).rejects.toBeInstanceOf(PaddleRegionsOfflineError); + expect(paddleRegionsApi.list).not.toHaveBeenCalled(); + expect(paddleRegionsApi.catalog).not.toHaveBeenCalled(); + expect(paddleRegionsApi.add).not.toHaveBeenCalled(); + expect(paddleRegionsApi.refresh).not.toHaveBeenCalled(); + expect(paddleRegionsApi.remove).not.toHaveBeenCalled(); + }); + + it('FE-REPO-PADDLEREGION-005: the refusal names itself and says that a connection is needed', async () => { + vi.mocked(isEffectivelyOffline).mockReturnValue(true); + await expect(paddleRegionRepo.add('oberpfalz')).rejects.toThrow('managed with a connection only'); + const refusal = new PaddleRegionsOfflineError(); + expect(refusal).toBeInstanceOf(Error); + expect(refusal.name).toBe('PaddleRegionsOfflineError'); + }); + + it('FE-REPO-PADDLEREGION-006: the connection is read at each call, so the next call once it is back goes through', async () => { + vi.mocked(isEffectivelyOffline).mockReturnValueOnce(true); + await expect(paddleRegionRepo.list()).rejects.toBeInstanceOf(PaddleRegionsOfflineError); + await expect(paddleRegionRepo.list()).resolves.toEqual([REGION]); + expect(paddleRegionsApi.list).toHaveBeenCalledTimes(1); + }); +}); + +describe('paddleRegionErrorCode', () => { + const refused = (code: unknown) => ({ response: { status: 400, data: { error: 'Refused', code } } }); + + it('FE-REPO-PADDLEREGION-007: names the offline refusal and the two codes the server sends', () => { + expect(paddleRegionErrorCode(new PaddleRegionsOfflineError())).toBe('offline'); + expect(paddleRegionErrorCode(refused('catalog_unavailable'))).toBe('catalog_unavailable'); + expect(paddleRegionErrorCode(refused('unknown_region'))).toBe('unknown_region'); + }); + + it('FE-REPO-PADDLEREGION-008: anything else has no code, another code, a bare failure or no error at all included', () => { + const others: unknown[] = [ + refused('AUTH_REQUIRED'), + refused(42), + refused(undefined), + { response: { status: 500 } }, + { response: null }, + new Error('Network Error'), + 'catalog_unavailable', + null, + undefined, + ]; + expect(others.map((err) => paddleRegionErrorCode(err))).toEqual(others.map(() => null)); + }); +}); + +/** The real admin calls; the repo tests above run against a mock of this module. */ +async function realApi() { + const actual = await vi.importActual('../api/paddleRegions'); + return actual.paddleRegionsApi; +} + +const REGION_CONTRACT = { request: paddleRegionRequestSchema, response: paddleRegionSchema }; +const REMOVE_CONTRACT = { request: paddleRegionRequestSchema, response: successResponseSchema }; + +describe('paddleRegionsApi', () => { + it('FE-API-PADDLEREGIONS-001: the list unwraps its regions, the catalogue sends the search and unwraps its entries', async () => { + const api = await realApi(); + const searches: Array = []; + server.use( + contractHandler('get', LIST, { response: paddleRegionListResponseSchema }, () => ({ + regions: [REGION, READING], + })), + contractHandler('get', CATALOG, { response: paddleRegionCatalogResponseSchema }, ({ request }) => { + searches.push(new URL(request.url).searchParams.get('q')); + return { regions: [ENTRY] }; + }) + ); + + await expect(api.list()).resolves.toEqual([REGION, READING]); + await expect(api.catalog('ober')).resolves.toEqual([ENTRY]); + expect(searches).toEqual(['ober']); + }); + + it('FE-API-PADDLEREGIONS-002: add, refresh and remove post the id to their own paths and answer the parsed reply', async () => { + const api = await realApi(); + const posted: string[] = []; + server.use( + contractHandler('post', LIST, REGION_CONTRACT, ({ body }) => { + posted.push(`add ${body.id}`); + return { ...QUEUED, id: body.id }; + }), + contractHandler('post', REFRESH, REGION_CONTRACT, ({ body }) => { + posted.push(`refresh ${body.id}`); + return READING; + }), + contractHandler('post', REMOVE, REMOVE_CONTRACT, ({ body }) => { + posted.push(`remove ${body.id}`); + return { success: true as const }; + }) + ); + + await expect(api.add('us/texas')).resolves.toEqual({ ...QUEUED, id: 'us/texas' }); + await expect(api.refresh('oberpfalz')).resolves.toEqual(READING); + await expect(api.remove('oberpfalz')).resolves.toEqual({ success: true }); + expect(posted).toEqual(['add us/texas', 'refresh oberpfalz', 'remove oberpfalz']); + }); + + it('FE-API-PADDLEREGIONS-003: an answer off the contract is refused instead of handed on', async () => { + const api = await realApi(); + server.use( + http.get(LIST, () => HttpResponse.json([REGION])), + http.get(CATALOG, () => HttpResponse.json({ regions: [{ id: 'oberpfalz', name: 'Oberpfalz' }] })), + http.post(LIST, () => HttpResponse.json({ ...REGION, status: 'done' })), + http.post(REFRESH, () => HttpResponse.json({ ...REGION, progress: { phase: 'read', fraction: 2 } })), + http.post(REMOVE, () => HttpResponse.json({ success: false })) + ); + + await expect(api.list()).rejects.toMatchObject({ name: 'ZodError' }); + await expect(api.catalog('ober')).rejects.toMatchObject({ name: 'ZodError' }); + await expect(api.add('oberpfalz')).rejects.toMatchObject({ name: 'ZodError' }); + await expect(api.refresh('oberpfalz')).rejects.toMatchObject({ name: 'ZodError' }); + await expect(api.remove('oberpfalz')).rejects.toMatchObject({ name: 'ZodError' }); + }); + + it('FE-API-PADDLEREGIONS-004: the code the server puts next to a refusal reaches paddleRegionErrorCode', async () => { + const api = await realApi(); + const refuse = (status: number, code: string) => () => HttpResponse.json({ error: 'Refused', code }, { status }); + server.use(http.get(CATALOG, refuse(502, 'catalog_unavailable')), http.post(LIST, refuse(400, 'unknown_region'))); + + expect(paddleRegionErrorCode(await api.catalog('ober').catch((err: unknown) => err))).toBe('catalog_unavailable'); + expect(paddleRegionErrorCode(await api.add('atlantis').catch((err: unknown) => err))).toBe('unknown_region'); + }); +});