From ccfb610aabfc2e04f059a3368d3540498e6e0da0 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 16 Oct 2026 23:12:41 +0000 Subject: [PATCH] test(tours): cover the paddle obstacle card, map layers, route band, notice, sync, repos and the admin regions panel Thirteen client suites for the paddle obstacle work and the region admin: the detail model and card, the overlay with its outside press and Escape, the Leaflet and GL layers with the area zoom threshold and the single click handler, the route band marks, every state of the obstacle notice, the sync hook, the tiled obstacle repo, the online only region repo, and the regions panel. The wind hook for the start of a paddle tour is covered too. --- .../Admin/paddle/PaddleRegionsPanel.test.tsx | 320 +++++++++++ .../Tours/paddle/PaddleLayerLeaflet.test.tsx | 315 +++++++++++ .../Tours/paddle/PaddleObstacleCard.test.tsx | 292 ++++++++++ .../paddle/PaddleObstacleNotice.test.tsx | 187 +++++++ .../paddle/PaddleObstacleOverlay.test.tsx | 233 ++++++++ .../Tours/paddle/PaddleRouteStrip.test.tsx | 235 ++++++++ .../Tours/paddle/paddleMapLayer.test.ts | 361 ++++++++++++ .../Tours/paddle/paddleObstacleModel.test.ts | 219 ++++++++ .../Tours/paddle/usePaddleLayerGL.test.ts | 517 ++++++++++++++++++ .../paddle/usePaddleObstacleSync.test.ts | 299 ++++++++++ .../Tours/paddle/useTourWind.test.ts | 270 +++++++++ client/src/repo/paddleObstacleRepo.test.ts | 219 ++++++++ client/src/repo/paddleRegionRepo.test.ts | 223 ++++++++ 13 files changed, 3690 insertions(+) create mode 100644 client/src/components/Admin/paddle/PaddleRegionsPanel.test.tsx create mode 100644 client/src/components/Tours/paddle/PaddleLayerLeaflet.test.tsx create mode 100644 client/src/components/Tours/paddle/PaddleObstacleCard.test.tsx create mode 100644 client/src/components/Tours/paddle/PaddleObstacleNotice.test.tsx create mode 100644 client/src/components/Tours/paddle/PaddleObstacleOverlay.test.tsx create mode 100644 client/src/components/Tours/paddle/PaddleRouteStrip.test.tsx create mode 100644 client/src/components/Tours/paddle/paddleMapLayer.test.ts create mode 100644 client/src/components/Tours/paddle/paddleObstacleModel.test.ts create mode 100644 client/src/components/Tours/paddle/usePaddleLayerGL.test.ts create mode 100644 client/src/components/Tours/paddle/usePaddleObstacleSync.test.ts create mode 100644 client/src/components/Tours/paddle/useTourWind.test.ts create mode 100644 client/src/repo/paddleObstacleRepo.test.ts create mode 100644 client/src/repo/paddleRegionRepo.test.ts 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'); + }); +});