diff --git a/client/src/components/Tours/paddle/PaddleTimeCard.test.tsx b/client/src/components/Tours/paddle/PaddleTimeCard.test.tsx new file mode 100644 index 000000000..bc9bac796 --- /dev/null +++ b/client/src/components/Tours/paddle/PaddleTimeCard.test.tsx @@ -0,0 +1,379 @@ +// FE-COMP-PADDLE-TIME-001 to FE-COMP-PADDLE-TIME-015: the time estimate of a paddle tour (PAD-07). +import userEvent from '@testing-library/user-event'; +import { paddleLayoutOfPoints, type PaddleLock } from '@trek/shared'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, within } from '../../../../tests/helpers/render'; +import { resetAllStores, seedStore } from '../../../../tests/helpers/store'; +import { hikePlannerDefaults } from '../../../../tests/helpers/tourPlannerStub'; +import { useSettingsStore } from '../../../store/settingsStore'; +import { paddleEstimateOf, paddlePlanPoints } from '../planner/paddlePlanOf'; +import { portageLegBetween } from '../planner/tourRouteActions'; +import type { TourPlannerController, TourPlannerWaypoint } from '../planner/useTourPlanner'; +import { PaddleEstimateBody, PaddleTimeCard, type SpeedControl } from './PaddleTimeCard'; + +/** What the card writes for a time it cannot give: an en dash. */ +const OPEN = '–'; +/** The sign of a planned total shorter than the estimate: a minus, not a hyphen. */ +const MINUS = '−'; + +const TIMED_LOCK: PaddleLock = { wait_minutes: 20, transit_minutes: 5 }; +const NO_TIMES: PaddleLock = { wait_minutes: null, transit_minutes: null }; + +function point(id: string, lat: number, extra: Partial = {}): TourPlannerWaypoint { + return { id, lat, lng: 11, role: 'via', ...extra }; +} + +interface RouteShape { + /** The lock on the water after the portage. */ + lock?: PaddleLock | null; + /** A lock where the portage starts, so the boat is carried round it. */ + takeOutLock?: PaddleLock | null; + /** A lock at the final take-out, on the water too. */ + endLock?: PaddleLock | null; + portage?: boolean; +} + +/** + * Along one meridian, so each stretch is its span of latitude: 8 km of water + * from the put-in, a 500 m portage, then 2 km of water past a lock to the + * final take-out, 10.5 km in all. + */ +function route(shape: RouteShape = {}): TourPlannerWaypoint[] { + const { lock = TIMED_LOCK, takeOutLock = null, endLock = null, portage = true } = shape; + const takeOut = point('take-out', 48.072, { lock: takeOutLock }); + const reEntry = point('re-entry', 48.0765); + return [ + point('put-in', 48, { role: 'start' }), + portage ? { ...takeOut, fixedLeg: portageLegBetween(takeOut, reEntry) } : takeOut, + reEntry, + point('lock', 48.0855, { lock }), + point('final', 48.0945, { role: 'end', lock: endLock }), + ]; +} + +interface CardSetup { + waypoints?: TourPlannerWaypoint[]; + /** The paddling speed in effect: 4 km/h unless told otherwise, null for none. */ + speedKmh?: number | null; + portageSpeedKmh?: number | null; + breakMinutes?: number | null; + plannedMinutes?: number | null; + /** False leaves the estimate out, as the planner does for a hike. */ + estimate?: boolean; +} + +/** A paddle planner as far as the card reads it, its estimate worked out the way the planner works it out. */ +function paddlePlanner(setup: CardSetup = {}) { + const base = hikePlannerDefaults(); + const waypoints = setup.waypoints ?? route(); + const setPortageSpeedKmh = vi.fn(); + const paddle = { + ...base.paddle, + effectiveSpeedKmh: setup.speedKmh === undefined ? 4 : setup.speedKmh, + portageSpeedKmh: setup.portageSpeedKmh ?? null, + setPortageSpeedKmh, + }; + const planner = { + ...base, + tourType: 'kayak', + isPaddle: true, + paddle, + waypoints, + paddleEstimate: setup.estimate === false ? null : paddleEstimateOf(waypoints, paddle, false), + breakAdditionalMinutes: setup.breakMinutes ?? null, + plannedDurationMinutes: setup.plannedMinutes ?? null, + } as unknown as TourPlannerController; + return { planner, setPortageSpeedKmh }; +} + +function card(setup: CardSetup = {}, locked = false) { + return ; +} + +/** Renders the card and hands back the planner's carrying speed setter. */ +function renderCard(setup: CardSetup = {}, locked = false) { + const { planner, setPortageSpeedKmh } = paddlePlanner(setup); + render(); + return setPortageSpeedKmh; +} + +/** The block as a recorded track shows it, with the paddling speed set in the water tile. */ +function renderBody(paddleSpeed: SpeedControl, locked = false) { + const waypoints = route(); + const speeds = { effectiveSpeedKmh: paddleSpeed.value, portageSpeedKmh: null }; + const estimate = paddleEstimateOf(waypoints, speeds, false); + render( + + ); +} + +/** Types into a speed field the way a paddler does: into the field, a value, out of it again. */ +function enter(input: HTMLElement, value: string) { + fireEvent.focus(input); + fireEvent.change(input, { target: { value } }); + fireEvent.blur(input); +} + +const tile = (name: string) => screen.getByTestId(`paddle-time-${name}`); +const estimateText = () => screen.getByTestId('paddle-time-estimate').textContent; + +beforeEach(() => { + resetAllStores(); + seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } }); +}); + +describe('PaddleTimeCard (PAD-07)', () => { + it('FE-COMP-PADDLE-TIME-001: renders nothing without an estimate or before the tour has two points', () => { + const { container, rerender } = render(card({ estimate: false })); + expect(container).toBeEmptyDOMElement(); + + rerender(card({ waypoints: route().slice(0, 1) })); + expect(container).toBeEmptyDOMElement(); + }); + + it('FE-COMP-PADDLE-TIME-002: heads the card with the estimate, breaks included, and says its values are planning values', () => { + renderCard({ breakMinutes: 30 }); + + const region = screen.getByRole('region', { name: 'Time estimate' }); + expect(within(region).getByText('Planning values')).toBeInTheDocument(); + const info = within(region).getByRole('button', { name: 'Planning values' }); + expect(info).toHaveAccessibleDescription(/^TREK starts with 4 km\/h on the water/); + expect(within(region).getByText('Estimate')).toBeInTheDocument(); + // 200 minutes of water, carrying, getting out and in and the lock, then 30 minutes of breaks. + expect(estimateText()).toBe('3 h 50 min'); + expect(screen.queryByText('Incomplete')).not.toBeInTheDocument(); + expect(screen.queryByTestId('paddle-time-planned')).not.toBeInTheDocument(); + // The planner sets its paddling speed in the type fields; the card shows it as it is. + expect(screen.queryByRole('textbox', { name: 'Paddling speed' })).not.toBeInTheDocument(); + }); + + it('FE-COMP-PADDLE-TIME-003: breaks the estimate into water, carrying, getting out and in, locks and breaks', () => { + renderCard({ breakMinutes: 30 }); + + const water = tile('water'); + expect(within(water).getByText('Water')).toBeInTheDocument(); + expect(within(water).getByText('10 km')).toBeInTheDocument(); + expect(within(water).getByText('4 km/h')).toBeInTheDocument(); + expect(within(water).getByText('2 h 30 min')).toBeInTheDocument(); + + const carry = tile('carry'); + expect(within(carry).getByText('Carrying')).toBeInTheDocument(); + expect(within(carry).getByText('500 m')).toBeInTheDocument(); + expect(within(carry).getByText('km/h')).toBeInTheDocument(); + // 500 m at the starting 2 km/h. + expect(within(carry).getByText('15 min')).toBeInTheDocument(); + + const outIn = tile('out-in'); + expect(within(outIn).getByText('Out and back in')).toBeInTheDocument(); + expect(within(outIn).getByText('1×')).toBeInTheDocument(); + expect(within(outIn).getByText('10 min')).toBeInTheDocument(); + + const locks = tile('locks'); + expect(within(locks).getByText('Locks')).toBeInTheDocument(); + expect(within(locks).getByText('1×')).toBeInTheDocument(); + expect(within(locks).getByText('25 min')).not.toHaveClass('text-warning'); + + const breaks = tile('breaks'); + expect(within(breaks).getByText('Breaks')).toHaveClass('text-content'); + expect(within(breaks).getByText('30 min')).toBeInTheDocument(); + }); + + it('FE-COMP-PADDLE-TIME-004: draws the route as a band of its stretches with the lock on it', () => { + renderCard(); + + const strip = within(screen.getByTestId('paddle-time-card')).getByTestId('paddle-route-strip'); + const kinds = Array.from(strip.querySelectorAll('[data-stretch]'), (node) => node.getAttribute('data-stretch')); + expect(kinds).toEqual(['water', 'portage', 'water']); + expect(within(strip).getAllByTestId('paddle-route-strip-lock')).toHaveLength(1); + expect(within(strip).getByText('0 m')).toBeInTheDocument(); + expect(within(strip).getByText('10.5 km')).toBeInTheDocument(); + }); + + it('FE-COMP-PADDLE-TIME-005: gives the distances in the unit the user reads', () => { + seedStore(useSettingsStore, { settings: { distance_unit: 'imperial' } }); + renderCard(); + + expect(within(tile('water')).getByText('6.2 mi')).toBeInTheDocument(); + expect(within(tile('carry')).getByText('0.3 mi')).toBeInTheDocument(); + expect(within(screen.getByTestId('paddle-route-strip')).getByText('6.5 mi')).toBeInTheDocument(); + }); + + it('FE-COMP-PADDLE-TIME-006: a lock without times leaves the estimate incomplete and says so', () => { + const { rerender } = render(card({ waypoints: route({ lock: NO_TIMES }) })); + + // What is known still adds up: water, carrying and getting out and in. + expect(estimateText()).toBe('2 h 55 min'); + expect(screen.getByText('Incomplete')).toHaveClass('text-warning'); + expect(within(tile('locks')).getByText(OPEN)).toHaveClass('text-warning'); + expect(screen.getByText('One lock has no times yet.')).toBeInTheDocument(); + // Without breaks entered the breaks tile stays open rather than reading zero. + expect(within(tile('breaks')).getByText('Breaks')).toHaveClass('text-content-muted'); + expect(within(tile('breaks')).getByText(OPEN)).toBeInTheDocument(); + + // A wait without its passage counts what is known and still warns. + rerender(card({ waypoints: route({ lock: { wait_minutes: 20, transit_minutes: null } }) })); + expect(estimateText()).toBe('3 h 15 min'); + expect(within(tile('locks')).getByText('20 min')).toHaveClass('text-warning'); + expect(screen.getByText('One lock has no times yet.')).toBeInTheDocument(); + + rerender(card({ waypoints: route({ lock: NO_TIMES, endLock: NO_TIMES }) })); + expect(within(tile('locks')).getByText('2×')).toBeInTheDocument(); + expect(screen.getByText('2 locks have no times yet.')).toBeInTheDocument(); + }); + + it('FE-COMP-PADDLE-TIME-007: without a paddling speed the water time stays open and the rest still adds up', () => { + const { rerender } = render(card({ speedKmh: null })); + + const water = tile('water'); + expect(within(water).getByText('10 km')).toBeInTheDocument(); + expect(within(water).queryByText(/km\/h/)).not.toBeInTheDocument(); + expect(within(water).getByText(OPEN)).toBeInTheDocument(); + // Carrying, getting out and in and the lock: 15 + 10 + 25 minutes. + expect(estimateText()).toBe('50 min'); + expect(screen.getByText('Incomplete')).toBeInTheDocument(); + expect(screen.getByText('Without a paddling speed the time on the water stays open.')).toBeInTheDocument(); + + // Everything still missing is named in one notice. + rerender(card({ speedKmh: null, waypoints: route({ lock: NO_TIMES }) })); + const both = 'Without a paddling speed the time on the water stays open. One lock has no times yet.'; + expect(screen.getByText(both)).toBeInTheDocument(); + }); + + it('FE-COMP-PADDLE-TIME-008: a lock the portage carries round is not counted, and a tour without portage has no carry tiles', () => { + const { rerender } = render(card({ waypoints: route({ takeOutLock: NO_TIMES }) })); + + const marks = screen.getAllByTestId('paddle-route-strip-lock'); + expect(marks).toHaveLength(2); + expect(marks[0]).toHaveClass('opacity-45'); + expect(marks[1]).not.toHaveClass('opacity-45'); + expect(within(tile('locks')).getByText('1×')).toBeInTheDocument(); + expect(within(tile('locks')).getByText('25 min')).toBeInTheDocument(); + // Its missing times are the portage's business, not a gap in the estimate. + expect(screen.queryByText('Incomplete')).not.toBeInTheDocument(); + expect(screen.queryByText('One lock has no times yet.')).not.toBeInTheDocument(); + + rerender(card({ waypoints: route({ portage: false, lock: null }) })); + expect(screen.queryByTestId('paddle-time-carry')).not.toBeInTheDocument(); + expect(screen.queryByTestId('paddle-time-out-in')).not.toBeInTheDocument(); + expect(screen.queryByTestId('paddle-time-locks')).not.toBeInTheDocument(); + expect(screen.queryByTestId('paddle-route-strip-lock')).not.toBeInTheDocument(); + expect(within(tile('water')).getByText('10.5 km')).toBeInTheDocument(); + expect(within(tile('water')).getByText('2 h 38 min')).toBeInTheDocument(); + }); + + it('FE-COMP-PADDLE-TIME-009: sets the planned total beside the estimate with the gap to it', () => { + const { rerender } = render(card({ breakMinutes: 30, plannedMinutes: 240 })); + + const planned = screen.getByTestId('paddle-time-planned'); + expect(planned).toHaveTextContent('Your planned total'); + expect(within(planned).getByText('4 h')).toBeInTheDocument(); + expect(within(planned).getByText('+10 min')).toHaveClass('text-content-secondary'); + + // A plan shorter than the estimate is the one to look at again. + rerender(card({ breakMinutes: 30, plannedMinutes: 200 })); + const shorter = within(screen.getByTestId('paddle-time-deviation')).getByText(`${MINUS}30 min`); + expect(shorter).toHaveClass('text-warning'); + + rerender(card({ breakMinutes: 30, plannedMinutes: 230 })); + expect(screen.getByTestId('paddle-time-planned')).toHaveTextContent('3 h 50 min'); + expect(screen.queryByTestId('paddle-time-deviation')).not.toBeInTheDocument(); + }); + + it('FE-COMP-PADDLE-TIME-010: offers the starting carrying speed and hands a typed one to the planner on Enter', async () => { + const user = userEvent.setup(); + const setPortageSpeedKmh = renderCard(); + + const input = within(tile('carry')).getByRole('textbox', { name: 'Carrying speed' }); + expect(input).toHaveValue(''); + expect(input).toHaveAttribute('placeholder', '2'); + expect(input).toBeEnabled(); + + await user.type(input, '2,5{Enter}'); + expect(setPortageSpeedKmh).toHaveBeenCalledTimes(1); + expect(setPortageSpeedKmh).toHaveBeenCalledWith(2.5); + expect(input).not.toHaveFocus(); + // The field shows the planner's value again, which the stub leaves unset. + expect(input).toHaveValue(''); + }); + + it('FE-COMP-PADDLE-TIME-011: keeps digits and separators, four characters at most, and rounds to a tenth', () => { + const setPortageSpeedKmh = renderCard(); + const input = screen.getByRole('textbox', { name: 'Carrying speed' }); + + fireEvent.focus(input); + fireEvent.change(input, { target: { value: '3a,5' } }); + expect(input).toHaveValue('3,5'); + fireEvent.blur(input); + expect(setPortageSpeedKmh).toHaveBeenLastCalledWith(3.5); + + fireEvent.focus(input); + fireEvent.change(input, { target: { value: '1.2789' } }); + expect(input).toHaveValue('1.27'); + fireEvent.blur(input); + expect(setPortageSpeedKmh).toHaveBeenLastCalledWith(1.3); + }); + + it('FE-COMP-PADDLE-TIME-012: ignores a carrying speed out of range or malformed, and an emptied field returns to the starting value', () => { + const setPortageSpeedKmh = renderCard({ portageSpeedKmh: 3 }); + const input = screen.getByRole('textbox', { name: 'Carrying speed' }); + expect(input).toHaveValue('3'); + // 500 m at 3 km/h. + expect(within(tile('carry')).getByText('10 min')).toBeInTheDocument(); + + for (const typed of ['12', '0,4', '1.2.']) enter(input, typed); + // Leaving the field without having been in it changes nothing either. + fireEvent.blur(input); + expect(setPortageSpeedKmh).not.toHaveBeenCalled(); + expect(input).toHaveValue('3'); + + enter(input, ''); + expect(setPortageSpeedKmh).toHaveBeenCalledWith(null); + }); + + it('FE-COMP-PADDLE-TIME-013: a locked planner shows the carrying speed without taking a new one', () => { + renderCard({ portageSpeedKmh: 3 }, true); + + const input = screen.getByRole('textbox', { name: 'Carrying speed' }); + expect(input).toHaveValue('3'); + expect(input).toBeDisabled(); + }); +}); + +describe('PaddleEstimateBody (PAD-07)', () => { + it('FE-COMP-PADDLE-TIME-014: a recorded track types its paddling speed into the water tile, up to 20 km/h', () => { + const onChange = vi.fn(); + renderBody({ value: 5, startValue: 4, onChange }); + + const water = tile('water'); + const input = within(water).getByRole('textbox', { name: 'Paddling speed' }); + expect(input).toHaveValue('5'); + expect(input).toHaveAttribute('placeholder', '4'); + expect(within(water).queryByText('5 km/h')).not.toBeInTheDocument(); + // 10 km at 5 km/h. + expect(within(water).getByText('2 h')).toBeInTheDocument(); + + enter(input, '25'); + expect(onChange).not.toHaveBeenCalled(); + // Faster than a boat is carried, but a speed on the water. + enter(input, '12'); + expect(onChange).toHaveBeenCalledWith(12); + }); + + it('FE-COMP-PADDLE-TIME-015: without a starting paddling speed the field shows a dash, and a locked track takes none', () => { + renderBody({ value: null, startValue: null, onChange: vi.fn() }, true); + + const input = screen.getByRole('textbox', { name: 'Paddling speed' }); + expect(input).toHaveValue(''); + expect(input).toHaveAttribute('placeholder', OPEN); + expect(input).toBeDisabled(); + expect(screen.getByText('Without a paddling speed the time on the water stays open.')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Tours/paddle/PaddleWindCard.test.tsx b/client/src/components/Tours/paddle/PaddleWindCard.test.tsx new file mode 100644 index 000000000..e31baf441 --- /dev/null +++ b/client/src/components/Tours/paddle/PaddleWindCard.test.tsx @@ -0,0 +1,370 @@ +// FE-PADDLE-WIND-001 to FE-PADDLE-WIND-016: the wind card of a paddle tour (PAD-08) and the section that feeds it. +// FE-PADDLE-FIGURE-001 to FE-PADDLE-FIGURE-002: the figure badge the card shows its values in. +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Day, WindForecastResponse, WindHour } from '@trek/shared'; + +import { fireEvent, render, screen, within } from '../../../../tests/helpers/render'; +import { formatDate } from '../../../utils/formatters'; +import type { TourDay } from '../tourDays'; +import { FigureBadge } from './FigureBadge'; +import { PaddleWindCard, PaddleWindSection } from './PaddleWindCard'; +import type { TourWind, TourWindState } from './useTourWind'; + +const hook = vi.hoisted(() => ({ + useTourWind: vi.fn<(placeId: number | null, start: { lat: number; lng: number } | null) => TourWind>(), +})); +vi.mock('./useTourWind', () => ({ useTourWind: hook.useTourWind })); + +/** The dash a figure the source left out reads as, never a zero. */ +const NONE = '–'; + +/** The notices as shared/src/i18n/en/tours.ts words them. */ +const SAYS = { + noDay: 'Put the tour on a day of the trip to see the wind forecast for that day.', + noDate: 'This day of the trip has no date, so there is no forecast for it.', + noPlace: 'The tour has no start point yet to ask the forecast for.', + offline: 'Without a connection there is no wind forecast.', + error: 'The wind forecast cannot be fetched right now.', + past: 'This day has passed. A forecast exists only for days to come.', +}; + +const HINTS = [ + 'Headwind costs time and strength.', + 'Crosswind pushes the boat off course.', + 'Open crossings are more exposed than the shore.', + 'Gusts are stronger than the mean and come suddenly.', + 'No figure is an all-clear. Check the conditions on site.', +]; + +const FETCHED_AT = '2026-07-07T09:45:00Z'; +const FETCHED_CLOCK = new Date(FETCHED_AT).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); +/** When the forecast was asked, with the spaces the DOM queries read (ICU puts a narrow one before AM). */ +const FETCHED_TIME = FETCHED_CLOCK.replace(/\s/g, ' '); + +const dayOf = (id: number, date: string | null): Day => ({ id, trip_id: 3, date }); +const JULY_8: TourDay = { day: dayOf(11, '2026-07-08'), index: 1 }; +const JULY_9: TourDay = { day: dayOf(12, '2026-07-09'), index: 3 }; +const UNDATED: TourDay = { day: dayOf(13, null), index: 2 }; + +const hourOf = (hour: number, speed: number | null, gusts: number | null, direction: number | null): WindHour => ({ + hour, + speed_kmh: speed, + gusts_kmh: gusts, + direction_deg: direction, +}); + +/** A day on the water: an hour either side of the strip, a calm one and one the source left out. */ +const HOURS: WindHour[] = [ + hourOf(5, 30, 50, 90), + hourOf(6, 8, 14, 200), + hourOf(7, 0, 0, 0), + hourOf(9, null, null, null), + hourOf(10, 10, null, null), + hourOf(12, 20, 40, 270), + hourOf(21, 4, 6, 180), + hourOf(22, 60, 80, 90), +]; + +function forecastOf(overrides: Partial = {}): WindForecastResponse { + return { + status: 'forecast', + date: '2026-07-08', + lat: 47.05, + lng: 8.31, + timezone: 'Europe/Zurich', + source: 'Open-Meteo', + fetched_at: FETCHED_AT, + horizon_days: 16, + hours: [], + day: { speed_max_kmh: 18, gusts_max_kmh: 32, direction_dominant_deg: 225 }, + ...overrides, + }; +} + +const ready = (forecast: WindForecastResponse): TourWindState => ({ kind: 'ready', forecast }); + +const retry = vi.fn(); +const chooseDay = vi.fn(); + +/** What useTourWind hands the card: a state, the tour's days and the one shown. */ +function windOf( + state: TourWindState, + tourDays: TourDay[] = [JULY_8], + day: TourDay | null = tourDays[0] ?? null +): TourWind { + return { state, day, tourDays, chooseDay, retry }; +} + +const renderCard = (wind: TourWind) => render(); + +/** A day as the card names it, with the spaces the DOM queries read. */ +function dateLabel(date: string): string { + const label = formatDate(date, 'en-US'); + if (!label) throw new Error(`no label for ${date}`); + return label.replace(/\s/g, ' '); +} + +/** What an element's tooltip says once it is hovered; the hover ends before it returns. */ +async function tooltipOf(element: HTMLElement): Promise { + fireEvent.mouseEnter(element); + const text = (await screen.findByRole('tooltip')).textContent; + fireEvent.mouseLeave(element); + return text; +} + +/** The column of one hour in the strip, the element its tooltip hangs on. */ +function hourColumn(hour: number): HTMLElement { + const column = within(screen.getByTestId('paddle-wind-hours')).getByText(String(hour)).parentElement; + if (!column) throw new Error(`no column for ${hour}:00`); + return column; +} + +/** The height of a column's bar for the mean or the gusts; undefined when the column has none. */ +function barHeight(column: HTMLElement, bar: 'mean' | 'gusts'): string | undefined { + return column.querySelector(bar === 'mean' ? '.bg-info' : '.bg-info-soft')?.style.height; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('PaddleWindCard', () => { + it('FE-PADDLE-WIND-001: a tour on no day asks to be put on one, names no day and still offers the hints', () => { + renderCard(windOf({ kind: 'no_day' }, [], null)); + + const card = screen.getByRole('region', { name: 'Wind' }); + expect(within(card).getByText(SAYS.noDay)).toBeInTheDocument(); + expect(within(card).queryByText(/^Day \d+$/)).not.toBeInTheDocument(); + expect(within(card).queryByTestId('paddle-wind-mean')).not.toBeInTheDocument(); + expect(within(card).getByRole('button', { name: 'What wind means for paddling' })).toBeInTheDocument(); + }); + + it('FE-PADDLE-WIND-002: a day without a date says there is no forecast for it and is named by its number', () => { + renderCard(windOf({ kind: 'no_date' }, [UNDATED])); + + expect(screen.getByText(SAYS.noDate)).toBeInTheDocument(); + expect(screen.getByText('Day 3')).toBeInTheDocument(); + // One day is a label, not a choice. + expect(screen.queryByRole('button', { name: 'Day 3' })).not.toBeInTheDocument(); + }); + + it('FE-PADDLE-WIND-003: a tour without a start point says so, under the date of its day', () => { + renderCard(windOf({ kind: 'no_place' })); + + expect(screen.getByText(SAYS.noPlace)).toBeInTheDocument(); + expect(screen.getByText(dateLabel('2026-07-08'))).toBeInTheDocument(); + }); + + it('FE-PADDLE-WIND-004: while the forecast loads it holds a placeholder and no figures', () => { + renderCard(windOf({ kind: 'loading' })); + + expect(screen.getByTestId('paddle-wind-loading')).toBeInTheDocument(); + expect(screen.queryByTestId('paddle-wind-mean')).not.toBeInTheDocument(); + expect(screen.queryByTestId('paddle-wind-hours')).not.toBeInTheDocument(); + }); + + it('FE-PADDLE-WIND-005: offline it says there is no forecast without a connection and offers no retry', () => { + renderCard(windOf({ kind: 'offline' })); + + expect(screen.getByText(SAYS.offline)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument(); + expect(screen.queryByTestId('paddle-wind-loading')).not.toBeInTheDocument(); + }); + + it('FE-PADDLE-WIND-006: a failed request is a warning whose Try again asks once more', () => { + renderCard(windOf({ kind: 'error' })); + + const notice = screen.getByText(SAYS.error); + expect(notice.closest('.bg-warning-soft')).not.toBeNull(); + fireEvent.click(within(notice).getByRole('button', { name: 'Try again' })); + expect(retry).toHaveBeenCalledTimes(1); + }); + + it('FE-PADDLE-WIND-007: a day already gone says a forecast exists only for days to come and shows no figures', () => { + renderCard(windOf(ready(forecastOf({ status: 'past', timezone: null, day: null })))); + + expect(screen.getByText(SAYS.past)).toBeInTheDocument(); + expect(screen.queryByTestId('paddle-wind-mean')).not.toBeInTheDocument(); + expect(screen.queryByTestId('paddle-wind-hours')).not.toBeInTheDocument(); + }); + + it('FE-PADDLE-WIND-008: a day beyond the forecast names the first day its forecast can be had', () => { + const later = forecastOf({ status: 'beyond_horizon', date: '2027-03-20', timezone: null, day: null }); + renderCard(windOf(ready(later))); + + // Sixteen days, today included, reach the 20th from the 5th on. + const notice = `No forecast for this day yet. It comes from ${dateLabel('2027-03-05')}.`; + expect(screen.getByText(notice)).toBeInTheDocument(); + expect(screen.queryByTestId('paddle-wind-mean')).not.toBeInTheDocument(); + }); + + it('FE-PADDLE-WIND-009: a forecast shows the direction, the mean and the gusts of the day, the legend and its source', async () => { + renderCard(windOf(ready(forecastOf()))); + + const direction = screen.getByTestId('paddle-wind-direction'); + expect(direction).toHaveTextContent(/^SW$/); + // From the south west, so the arrow turns half round and points the way the wind blows. + expect(direction.querySelector('svg')?.style.transform).toBe('rotate(405deg)'); + const mean = screen.getByTestId('paddle-wind-mean'); + expect(within(mean).getByText('Mean')).toBeInTheDocument(); + expect(within(mean).getByText('18 km/h')).toBeInTheDocument(); + const gusts = screen.getByTestId('paddle-wind-gusts'); + expect(within(gusts).getByText('Gusts')).toBeInTheDocument(); + expect(within(gusts).getByText('32 km/h')).toBeInTheDocument(); + expect(screen.getByText('Mean wind')).toBeInTheDocument(); + expect(screen.getByText(`Open-Meteo, ${FETCHED_TIME}, forecast at the start`)).toBeInTheDocument(); + expect(screen.queryByTestId('paddle-wind-loading')).not.toBeInTheDocument(); + + expect(await tooltipOf(direction)).toBe('Wind from SW, the direction it blows from most of the day'); + expect(await tooltipOf(mean)).toBe('The strongest hourly mean wind of the day'); + expect(await tooltipOf(gusts)).toBe('The strongest gust of the day'); + }); + + it('FE-PADDLE-WIND-010: the hours from 6 to 21 are columns, scaled to the strongest gust among them', async () => { + renderCard(windOf(ready(forecastOf({ hours: HOURS })))); + + const strip = screen.getByTestId('paddle-wind-hours'); + expect(Array.from(strip.children, (column) => column.textContent)).toEqual(['6', '7', '9', '10', '12', '21']); + + // The 40 km/h gusts at noon set the scale; the 80 at 22:00 lie outside the strip and do not. + const noon = hourColumn(12); + expect(barHeight(noon, 'mean')).toBe('50%'); + expect(barHeight(noon, 'gusts')).toBe('100%'); + // From the west, so the arrow points east. + expect(noon.querySelector('svg')?.style.transform).toBe('rotate(450deg)'); + expect(await tooltipOf(noon)).toBe('12:00: 20 km/h, gusts 40 km/h, from W'); + + // A mean without gusts or a direction draws its bar alone and says what it lacks. + const meanOnly = hourColumn(10); + expect(barHeight(meanOnly, 'mean')).toBe('25%'); + expect(barHeight(meanOnly, 'gusts')).toBeUndefined(); + expect(meanOnly.querySelector('svg')).toBeNull(); + expect(await tooltipOf(meanOnly)).toBe(`10:00: 10 km/h, gusts ${NONE} km/h, from ${NONE}`); + }); + + it('FE-PADDLE-WIND-011: an hour the source left out stays an empty column, while a calm hour is a figure', async () => { + renderCard(windOf(ready(forecastOf({ hours: HOURS })))); + + const unknown = hourColumn(9); + expect(barHeight(unknown, 'mean')).toBeUndefined(); + expect(barHeight(unknown, 'gusts')).toBeUndefined(); + expect(unknown.querySelector('.border-dashed')).not.toBeNull(); + expect(unknown.querySelector('svg')).toBeNull(); + expect(await tooltipOf(unknown)).toBe('9:00: no value'); + + // 0 km/h from the north is a measured calm: a sliver of a bar and an arrow, no dashed outline. + const calm = hourColumn(7); + expect(barHeight(calm, 'mean')).toBe('4%'); + expect(barHeight(calm, 'gusts')).toBe('4%'); + expect(calm.querySelector('.border-dashed')).toBeNull(); + expect(calm.querySelector('svg')?.style.transform).toBe('rotate(180deg)'); + expect(await tooltipOf(calm)).toBe('7:00: 0 km/h, gusts 0 km/h, from N'); + }); + + it('FE-PADDLE-WIND-012: a light day is drawn against 10 km/h at least, so a breeze never fills its column', () => { + renderCard(windOf(ready(forecastOf({ hours: [hourOf(8, 5, null, 90)] })))); + + expect(barHeight(hourColumn(8), 'mean')).toBe('50%'); + }); + + it('FE-PADDLE-WIND-013: a day figure the source left out reads as a dash, never as calm; a real calm reads 0 km/h', () => { + const blank = forecastOf({ + day: { speed_max_kmh: null, gusts_max_kmh: null, direction_dominant_deg: null }, + hours: [hourOf(9, null, null, null)], + }); + const { rerender } = renderCard(windOf(ready(blank))); + + expect(within(screen.getByTestId('paddle-wind-mean')).getByText(NONE)).toBeInTheDocument(); + expect(within(screen.getByTestId('paddle-wind-gusts')).getByText(NONE)).toBeInTheDocument(); + expect(screen.queryByTestId('paddle-wind-direction')).not.toBeInTheDocument(); + expect(screen.getByTestId('paddle-wind-card')).not.toHaveTextContent('km/h'); + + // A forecast without day figures at all reads the same. + rerender(); + expect(within(screen.getByTestId('paddle-wind-mean')).getByText(NONE)).toBeInTheDocument(); + expect(within(screen.getByTestId('paddle-wind-gusts')).getByText(NONE)).toBeInTheDocument(); + expect(screen.getByTestId('paddle-wind-card')).not.toHaveTextContent('km/h'); + + const calm = forecastOf({ day: { speed_max_kmh: 0, gusts_max_kmh: 0, direction_dominant_deg: 0 } }); + rerender(); + expect(within(screen.getByTestId('paddle-wind-mean')).getByText('0 km/h')).toBeInTheDocument(); + expect(within(screen.getByTestId('paddle-wind-gusts')).getByText('0 km/h')).toBeInTheDocument(); + expect(screen.getByTestId('paddle-wind-direction')).toHaveTextContent(/^N$/); + }); + + it('FE-PADDLE-WIND-014: a tour on two days offers each as a button, marks the one shown and asks for the other', () => { + const { rerender } = renderCard(windOf(ready(forecastOf()), [JULY_8, JULY_9], JULY_9)); + + const second = screen.getByRole('button', { name: 'Day 2' }); + expect(second).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByRole('button', { name: 'Day 4' })).toHaveAttribute('aria-pressed', 'true'); + // The buttons stand in for the date pill a single day gets. + expect(screen.queryByText(dateLabel('2026-07-09'))).not.toBeInTheDocument(); + + fireEvent.click(second); + expect(chooseDay).toHaveBeenCalledTimes(1); + expect(chooseDay).toHaveBeenCalledWith(11); + + // The card follows the day the hook shows. + rerender(); + expect(screen.getByRole('button', { name: 'Day 2' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: 'Day 4' })).toHaveAttribute('aria-pressed', 'false'); + }); + + it('FE-PADDLE-WIND-015: the hints for paddlers fold out and back in', () => { + renderCard(windOf({ kind: 'offline' })); + + const toggle = screen.getByRole('button', { name: 'What wind means for paddling' }); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByRole('list')).not.toBeInTheDocument(); + + fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getAllByRole('listitem').map((item) => item.textContent)).toEqual(HINTS); + + fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByRole('list')).not.toBeInTheDocument(); + }); +}); + +describe('PaddleWindSection', () => { + it('FE-PADDLE-WIND-016: asks the wind for its tour and start and shows the card for the answer', () => { + hook.useTourWind.mockReturnValue(windOf({ kind: 'offline' })); + + render(); + + expect(hook.useTourWind).toHaveBeenCalledWith(7, { lat: 47.05, lng: 8.31 }); + expect(screen.getByRole('region', { name: 'Wind' })).toHaveTextContent(SAYS.offline); + }); +}); + +describe('FigureBadge', () => { + it('FE-PADDLE-FIGURE-001: holds a label and a value; the warning tone colours the value alone', () => { + const { rerender } = render(); + + expect(within(screen.getByTestId('figure')).getByText('Height')).not.toHaveClass('text-warning'); + expect(screen.getByText('1.2 m').parentElement).toHaveClass('text-warning'); + + // Without a label the badge holds its value alone, in the neutral tone. + rerender(); + expect(screen.getByTestId('figure')).toHaveTextContent(/^Private$/); + expect(screen.getByText('Private').parentElement).toHaveClass('text-content'); + expect(screen.getByText('Private').parentElement).not.toHaveClass('text-warning'); + }); + + it('FE-PADDLE-FIGURE-002: only a badge with an info opens a tooltip', async () => { + render( + <> + + + + ); + + // Both are hovered: had the plain badge a tooltip of its own, it would still be open afterwards. + fireEvent.mouseEnter(screen.getByTestId('plain')); + expect(await tooltipOf(screen.getByTestId('explained'))).toBe('Recorded as private on OpenStreetMap'); + expect(screen.queryAllByRole('tooltip')).toHaveLength(0); + }); +}); diff --git a/client/src/components/Tours/paddle/TrackPaddlePlanCard.test.tsx b/client/src/components/Tours/paddle/TrackPaddlePlanCard.test.tsx new file mode 100644 index 000000000..32c1419a6 --- /dev/null +++ b/client/src/components/Tours/paddle/TrackPaddlePlanCard.test.tsx @@ -0,0 +1,487 @@ +// FE-COMP-PADDLE-TRACKPLAN-001 to FE-COMP-PADDLE-TRACKPLAN-016: the cards of a recorded paddle track, its time +// estimate reckoned with the portages and locks laid onto it, and the card that marks them by a click on the line. +import { + DEFAULT_PORTAGE_SPEED_KMH, + estimatePaddleTime, + paddleLayoutOfTrack, + recommendedPaddleSpeedKmh, + type TourListItem, + type TrackMarks, +} from '@trek/shared'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useShallow } from 'zustand/react/shallow'; +import { buildSettings } from '../../../../tests/helpers/factories'; +import { act, fireEvent, render, screen, within } from '../../../../tests/helpers/render'; +import { resetAllStores, seedStore } from '../../../../tests/helpers/store'; +import { useSettingsStore } from '../../../store/settingsStore'; +import { + loadTrackMarks, + pickOnTrack, + refuseTrackPick, + startTrackPick, + trackMarkEdits, + useTrackMarkToolStore, + type TrackSpeeds, +} from '../../../store/trackMarkToolStore'; +import { NO_TRACK_MARKS } from '../../../utils/paddleTrackMarks'; +import { TrackPaddlePlanCards } from './TrackPaddlePlanCard'; +import type { TrackPaddlePlan } from './useTrackPaddlePlan'; + +const EMPTY = 'Nothing marked yet. The recorded track stays as it is.'; + +/** What the card says a click on the line does next, or why the last one placed nothing. */ +const HINT = { + lock: 'Click the line where the lock is.', + takeOut: 'Click the line where you get out.', + reEntry: 'Now click where you put back in.', + overlap: 'A portage is already marked there. Mark the new one beside it.', + offLine: 'That was too far from the line. Click right on the track.', +}; + +/** A time that is not known reads as an en dash; a planned total that falls short carries a real minus sign. */ +const DASH = '–'; +const MINUS = '−'; + +/** The recorded track is 6.5 km long. */ +const TOTAL_M = 6500; + +const NO_SPEEDS: TrackSpeeds = { paddle: null, portage: null }; + +/** A portage from 2 to 2.5 km at the starting time out and in, and a lock at 4 km with both its times. */ +const MARKS: TrackMarks = { + portages: [{ from_m: 2000, to_m: 2500, extra_minutes: null }], + locks: [{ at_m: 4000, wait_minutes: 5, transit_minutes: 10 }], +}; + +/** Two of each in order along the track: the second portage has its own time, the second lock none yet. */ +const TWO: TrackMarks = { + portages: [ + { from_m: 1000, to_m: 1300, extra_minutes: null }, + { from_m: 3000, to_m: 3400, extra_minutes: 25 }, + ], + locks: [ + { at_m: 2000, wait_minutes: 5, transit_minutes: 10 }, + { at_m: 5000, wait_minutes: null, transit_minutes: null }, + ], +}; + +const TOUR: TourListItem = { + place_id: 21, + name: 'Recorded river run', + tour_type: 'kayak', + distance: 6.5, + elevation_gain: null, + elevation_loss: null, + duration: null, + difficulty: null, + wanderer_ref: null, + match_confidence: null, + max_hiking_difficulty: null, + planned: false, + caution: false, + boat_type: 'kayak', + break_additional_minutes: 20, + planned_duration_minutes: 120, + track_marks: MARKS, +}; + +/** + * A plan as useTrackPaddlePlan hands it to the cards: an open paddle track the + * viewer may edit, nothing picked, its layout and estimate reckoned from the + * marks and speeds the way the hook reckons them, and every action a spy. + */ +function planOf({ + marks = NO_TRACK_MARKS, + speeds = NO_SPEEDS, + recommendedPaddleSpeed = recommendedPaddleSpeedKmh(TOUR.boat_type), + ...rest +}: Partial = {}): TrackPaddlePlan { + const layout = paddleLayoutOfTrack(TOTAL_M, marks); + return { + active: true, + canEdit: true, + saving: false, + dirty: false, + pick: null, + pendingFromM: null, + refused: null, + totalM: TOTAL_M, + marks, + speeds, + layout, + estimate: estimatePaddleTime(layout, { + paddleSpeedKmh: speeds.paddle ?? recommendedPaddleSpeed, + portageSpeedKmh: speeds.portage, + breakMinutes: null, + }), + recommendedPaddleSpeed, + defaultPortageSpeed: DEFAULT_PORTAGE_SPEED_KMH, + startPick: vi.fn(), + edits: { removePortage: vi.fn(), setPortageExtra: vi.fn(), setLock: vi.fn(), setSpeeds: vi.fn() }, + save: vi.fn(() => Promise.resolve()), + discard: vi.fn(), + ...rest, + }; +} + +/** The two cards for a plan, on the tour the plan belongs to. */ +function cards(plan: TrackPaddlePlan, tour: TourListItem = TOUR) { + return ; +} + +/** The cards on the shared store the map is clicked into, wired to it the way useTrackPaddlePlan wires them. */ +function StoreCards() { + const state = useTrackMarkToolStore( + useShallow((store) => ({ + marks: store.marks, + speeds: store.speeds, + dirty: store.dirty, + pick: store.pick, + pendingFromM: store.pendingFromM, + refused: store.refused, + })) + ); + const plan = planOf({ + ...state, + startPick: (kind) => startTrackPick(state.pick === kind ? null : kind), + edits: trackMarkEdits, + }); + return cards(plan); +} + +/** Opens a portage or lock row to its fields. */ +function openRow(row: HTMLElement): void { + fireEvent.click(within(row).getByRole('button', { expanded: false })); +} + +/** Types into a field and leaves it: the fields take their value on leaving. */ +function enter(input: HTMLElement, value: string): void { + fireEvent.change(input, { target: { value } }); + fireEvent.blur(input); +} + +describe('TrackPaddlePlanCards', () => { + beforeEach(() => resetAllStores()); + + it('FE-COMP-PADDLE-TRACKPLAN-001: shows nothing while no recorded paddle track is open', () => { + const { container } = render(cards(planOf({ active: false, marks: MARKS, dirty: true }))); + expect(container).toBeEmptyDOMElement(); + }); + + it('FE-COMP-PADDLE-TRACKPLAN-002: reckons the time with the portages and locks laid on, the breaks, and the planned total beside it', () => { + render(cards(planOf({ marks: MARKS }))); + + expect(screen.getByRole('region', { name: 'Time estimate' })).toBeInTheDocument(); + expect(screen.getByRole('region', { name: 'Portages and locks' })).toBeInTheDocument(); + // 6 km of water at the kayak's 4 km/h, 500 m carried at 2 km/h, 10 min out and in, a 15 min lock, 20 min of breaks. + expect(screen.getByTestId('paddle-time-estimate')).toHaveTextContent('2 h 30 min'); + const water = screen.getByTestId('paddle-time-water'); + expect(water).toHaveTextContent('6 km'); + expect(water).toHaveTextContent('1 h 30 min'); + expect(screen.getByTestId('paddle-time-water-speed')).toHaveAttribute('placeholder', '4'); + const carry = screen.getByTestId('paddle-time-carry'); + expect(carry).toHaveTextContent('500 m'); + expect(carry).toHaveTextContent('15 min'); + expect(screen.getByTestId('paddle-time-carry-speed')).toHaveAttribute('placeholder', '2'); + expect(screen.getByTestId('paddle-time-out-in')).toHaveTextContent('1×'); + expect(screen.getByTestId('paddle-time-out-in')).toHaveTextContent('10 min'); + expect(screen.getByTestId('paddle-time-locks')).toHaveTextContent('1×'); + expect(screen.getByTestId('paddle-time-locks')).toHaveTextContent('15 min'); + expect(screen.getByTestId('paddle-time-breaks')).toHaveTextContent('20 min'); + const planned = screen.getByTestId('paddle-time-planned'); + expect(planned).toHaveTextContent('Your planned total'); + expect(within(planned).getByText('2 h')).toBeInTheDocument(); + expect(screen.getByTestId('paddle-time-deviation')).toHaveTextContent(`${MINUS}30 min`); + expect(screen.queryByText('Incomplete')).not.toBeInTheDocument(); + expect(screen.getAllByTestId('paddle-route-strip-lock')).toHaveLength(1); + }); + + it('FE-COMP-PADDLE-TRACKPLAN-003: without a paddling speed or lock times the estimate is incomplete and names what is missing', () => { + const marks: TrackMarks = { portages: [], locks: [{ at_m: 3000, wait_minutes: null, transit_minutes: null }] }; + const tour = { ...TOUR, break_additional_minutes: null, planned_duration_minutes: null }; + render(cards(planOf({ marks, recommendedPaddleSpeed: null }), tour)); + + const missing = 'Without a paddling speed the time on the water stays open. One lock has no times yet.'; + expect(screen.getByText(missing)).toBeInTheDocument(); + expect(screen.getByText('Incomplete')).toBeInTheDocument(); + expect(screen.getByTestId('paddle-time-estimate')).toHaveTextContent('0 min'); + const water = screen.getByTestId('paddle-time-water'); + expect(water).toHaveTextContent('6.5 km'); + expect(within(water).getByText(DASH)).toBeInTheDocument(); + expect(screen.getByTestId('paddle-time-water-speed')).toHaveAttribute('placeholder', DASH); + const locks = screen.getByTestId('paddle-time-locks'); + expect(locks).toHaveTextContent('1×'); + expect(within(locks).getByText(DASH)).toHaveClass('text-warning'); + expect(within(screen.getByTestId('paddle-time-breaks')).getByText(DASH)).toBeInTheDocument(); + expect(screen.queryByTestId('paddle-time-planned')).not.toBeInTheDocument(); + expect(screen.queryByTestId('paddle-time-carry')).not.toBeInTheDocument(); + }); + + it('FE-COMP-PADDLE-TRACKPLAN-004: the paddling and carrying speeds are typed in place, with either decimal separator, empty for the starting value', () => { + const plan = planOf({ marks: MARKS, speeds: { paddle: 4.5, portage: null } }); + render(cards(plan)); + const paddle = screen.getByTestId('paddle-time-water-speed'); + const carry = screen.getByTestId('paddle-time-carry-speed'); + expect(paddle).toHaveValue('4.5'); + expect(screen.getByTestId('paddle-time-water')).toHaveTextContent('1 h 20 min'); + expect(carry).toHaveValue(''); + expect(carry).toHaveAttribute('placeholder', '2'); + + enter(paddle, '5,5'); + expect(plan.edits.setSpeeds).toHaveBeenCalledWith({ paddle: 5.5 }); + enter(carry, '3'); + expect(plan.edits.setSpeeds).toHaveBeenLastCalledWith({ portage: 3 }); + enter(paddle, ''); + expect(plan.edits.setSpeeds).toHaveBeenLastCalledWith({ paddle: null }); + }); + + it('FE-COMP-PADDLE-TRACKPLAN-005: with nothing marked it says the track stays as recorded, and each tool asks for its own pick', () => { + const plan = planOf(); + render(cards(plan)); + + expect(screen.getByText(EMPTY)).toBeInTheDocument(); + expect(screen.queryByTestId('tour-planner-portage-leg')).not.toBeInTheDocument(); + expect(screen.queryByTestId('tour-planner-lock')).not.toBeInTheDocument(); + expect(screen.queryByTestId('paddle-time-carry')).not.toBeInTheDocument(); + for (const hint of Object.values(HINT)) expect(screen.queryByText(hint)).not.toBeInTheDocument(); + const portage = screen.getByTestId('track-marks-pick-portage'); + const lock = screen.getByTestId('track-marks-pick-lock'); + expect(portage).toHaveTextContent('Portage'); + expect(lock).toHaveTextContent('Lock'); + expect(portage).toHaveAttribute('aria-pressed', 'false'); + expect(lock).toHaveAttribute('aria-pressed', 'false'); + + fireEvent.click(portage); + expect(plan.startPick).toHaveBeenCalledWith('portage'); + fireEvent.click(lock); + expect(plan.startPick).toHaveBeenLastCalledWith('lock'); + expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument(); + }); + + it('FE-COMP-PADDLE-TRACKPLAN-006: the tool that is on shows pressed and says what the next click on the line does', () => { + const { rerender } = render(cards(planOf({ pick: 'lock' }))); + expect(screen.getByTestId('track-marks-pick-lock')).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByTestId('track-marks-pick-portage')).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByText(HINT.lock).parentElement).toHaveClass('bg-surface-card'); + expect(screen.getByText(HINT.lock).parentElement).not.toHaveClass('bg-warning-soft'); + + rerender(cards(planOf({ pick: 'portage' }))); + expect(screen.getByTestId('track-marks-pick-portage')).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByText(HINT.takeOut)).toBeInTheDocument(); + expect(screen.queryByText(HINT.lock)).not.toBeInTheDocument(); + + rerender(cards(planOf({ pick: 'portage', pendingFromM: 2000 }))); + expect(screen.getByText(HINT.reEntry)).toBeInTheDocument(); + expect(screen.queryByText(HINT.takeOut)).not.toBeInTheDocument(); + }); + + it('FE-COMP-PADDLE-TRACKPLAN-007: a click that placed nothing says why in the warning tone, instead of what the tool does', () => { + const { rerender } = render(cards(planOf({ pick: 'portage', refused: 'overlap' }))); + expect(screen.getByText(HINT.overlap).parentElement).toHaveClass('bg-warning-soft'); + expect(screen.queryByText(HINT.takeOut)).not.toBeInTheDocument(); + + rerender(cards(planOf({ pick: 'portage', refused: 'off_line' }))); + expect(screen.getByText(HINT.offLine).parentElement).toHaveClass('bg-warning-soft'); + expect(screen.queryByText(HINT.overlap)).not.toBeInTheDocument(); + }); + + it('FE-COMP-PADDLE-TRACKPLAN-008: lists every portage and lock where it lies on the track, with its length and its times', () => { + render(cards(planOf({ marks: TWO }))); + + expect(screen.queryByText(EMPTY)).not.toBeInTheDocument(); + const [near, far] = screen.getAllByTestId('tour-planner-portage-leg'); + expect(near).toHaveTextContent('at 1 km'); + expect(near).toHaveTextContent('300 m'); + expect(near).toHaveTextContent('+10 min'); + expect(far).toHaveTextContent('at 3 km'); + expect(far).toHaveTextContent('400 m'); + expect(far).toHaveTextContent('+25 min'); + const [timed, untimed] = screen.getAllByTestId('tour-planner-lock'); + expect(timed).toHaveTextContent('at 2 km'); + expect(timed).toHaveTextContent('15 min'); + expect(untimed).toHaveTextContent('at 5 km'); + expect(within(untimed).getByText('Time missing')).toHaveClass('text-warning'); + + openRow(near); + expect(within(near).getByText('Starting value')).toBeInTheDocument(); + expect(within(near).getByTestId('tour-planner-portage-extra')).toHaveAttribute('placeholder', '10'); + }); + + it('FE-COMP-PADDLE-TRACKPLAN-009: a lock a portage carries round is dimmed, reads carried round, and its times do not count', () => { + const marks: TrackMarks = { + portages: [{ from_m: 2000, to_m: 2500, extra_minutes: null }], + locks: [{ at_m: 2200, wait_minutes: null, transit_minutes: null }], + }; + render(cards(planOf({ marks }))); + + const lock = screen.getByTestId('tour-planner-lock'); + expect(lock).toHaveTextContent('Carried round, the portage counts'); + expect(lock).not.toHaveTextContent('Time missing'); + expect(within(lock).getByRole('button', { expanded: false })).toHaveClass('opacity-60'); + expect(screen.queryByTestId('paddle-time-locks')).not.toBeInTheDocument(); + expect(screen.queryByText('Incomplete')).not.toBeInTheDocument(); + }); + + it('FE-COMP-PADDLE-TRACKPLAN-010: a change or a removal goes to the mark of its own row, by its place in the list', () => { + const plan = planOf({ marks: TWO }); + render(cards(plan)); + + const portage = screen.getAllByTestId('tour-planner-portage-leg')[1]; + openRow(portage); + const extra = within(portage).getByTestId('tour-planner-portage-extra'); + enter(extra, '15'); + expect(plan.edits.setPortageExtra).toHaveBeenCalledWith(1, 15); + enter(extra, ''); + expect(plan.edits.setPortageExtra).toHaveBeenLastCalledWith(1, null); + fireEvent.click(within(portage).getByRole('button', { name: 'Remove the portage' })); + expect(plan.edits.removePortage).toHaveBeenCalledWith(1); + + const lock = screen.getAllByTestId('tour-planner-lock')[1]; + openRow(lock); + enter(within(lock).getByTestId('tour-planner-lock-wait'), '8'); + expect(plan.edits.setLock).toHaveBeenCalledWith(1, { at_m: 5000, wait_minutes: 8, transit_minutes: null }); + fireEvent.click(within(lock).getByRole('button', { name: 'Remove the lock' })); + expect(plan.edits.setLock).toHaveBeenLastCalledWith(1, null); + }); + + it('FE-COMP-PADDLE-TRACKPLAN-011: says where a mark lies in the distance unit the user reads, metric without one', () => { + seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'imperial' }) }); + render(cards(planOf({ marks: MARKS }))); + expect(screen.getByTestId('tour-planner-portage-leg')).toHaveTextContent('at 1.2 mi'); + expect(screen.getByTestId('tour-planner-portage-leg')).toHaveTextContent('0.3 mi'); + expect(screen.getByTestId('tour-planner-lock')).toHaveTextContent('at 2.5 mi'); + + act(() => seedStore(useSettingsStore, { settings: buildSettings() })); + expect(screen.getByTestId('tour-planner-portage-leg')).toHaveTextContent('at 2 km'); + expect(screen.getByTestId('tour-planner-lock')).toHaveTextContent('at 4 km'); + }); + + it('FE-COMP-PADDLE-TRACKPLAN-012: offers save and discard only once something changed, and hands each on', () => { + const { rerender } = render(cards(planOf({ marks: MARKS }))); + expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Discard' })).not.toBeInTheDocument(); + + const plan = planOf({ marks: MARKS, dirty: true }); + rerender(cards(plan)); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + expect(plan.save).toHaveBeenCalledTimes(1); + expect(plan.discard).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: 'Discard' })); + expect(plan.discard).toHaveBeenCalledTimes(1); + }); + + it('FE-COMP-PADDLE-TRACKPLAN-013: while it saves, the tools, the fields and both buttons hold still and nothing can be removed', () => { + const plan = planOf({ marks: MARKS, dirty: true, saving: true }); + render(cards(plan)); + + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Discard' })).toBeDisabled(); + expect(screen.getByTestId('track-marks-pick-portage')).toBeDisabled(); + const pickLock = screen.getByTestId('track-marks-pick-lock'); + expect(pickLock).toBeDisabled(); + fireEvent.click(pickLock); + expect(plan.startPick).not.toHaveBeenCalled(); + expect(screen.getByTestId('paddle-time-water-speed')).toBeDisabled(); + expect(screen.getByTestId('paddle-time-carry-speed')).toBeDisabled(); + + const portage = screen.getByTestId('tour-planner-portage-leg'); + openRow(portage); + expect(within(portage).getByTestId('tour-planner-portage-extra')).toBeDisabled(); + expect(within(portage).queryByRole('button', { name: 'Remove the portage' })).not.toBeInTheDocument(); + const lock = screen.getByTestId('tour-planner-lock'); + openRow(lock); + expect(within(lock).queryByRole('button', { name: 'Remove the lock' })).not.toBeInTheDocument(); + }); + + it('FE-COMP-PADDLE-TRACKPLAN-014: a viewer who may not edit reads the marks and the estimate, with no tools, no save and nothing to change', () => { + render(cards(planOf({ marks: MARKS, dirty: true, canEdit: false }))); + + expect(screen.queryByTestId('track-marks-pick-portage')).not.toBeInTheDocument(); + expect(screen.queryByTestId('track-marks-pick-lock')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Discard' })).not.toBeInTheDocument(); + expect(screen.getByTestId('paddle-time-estimate')).toHaveTextContent('2 h 30 min'); + expect(screen.getByTestId('paddle-time-water-speed')).toBeDisabled(); + expect(screen.getByTestId('paddle-time-carry-speed')).toBeDisabled(); + + const lock = screen.getByTestId('tour-planner-lock'); + openRow(lock); + expect(within(lock).getByTestId('tour-planner-lock-wait')).toBeDisabled(); + expect(within(lock).getByTestId('tour-planner-lock-transit')).toBeDisabled(); + expect(within(lock).queryByRole('button', { name: 'Remove the lock' })).not.toBeInTheDocument(); + const portage = screen.getByTestId('tour-planner-portage-leg'); + openRow(portage); + expect(within(portage).queryByRole('button', { name: 'Remove the portage' })).not.toBeInTheDocument(); + }); + + it('FE-COMP-PADDLE-TRACKPLAN-015: on the shared store, two clicks lay a portage, a refused click says why, and Remove takes it off', () => { + loadTrackMarks(TOUR.place_id, null, NO_SPEEDS); + render(); + expect(screen.getByText(EMPTY)).toBeInTheDocument(); + + // A second press on the tool that is on turns it off again. + const pickLock = screen.getByTestId('track-marks-pick-lock'); + fireEvent.click(pickLock); + expect(pickLock).toHaveAttribute('aria-pressed', 'true'); + fireEvent.click(pickLock); + expect(pickLock).toHaveAttribute('aria-pressed', 'false'); + expect(screen.queryByText(HINT.lock)).not.toBeInTheDocument(); + + const pickPortage = screen.getByTestId('track-marks-pick-portage'); + fireEvent.click(pickPortage); + expect(screen.getByText(HINT.takeOut)).toBeInTheDocument(); + act(() => pickOnTrack(1000)); + expect(screen.getByText(HINT.reEntry)).toBeInTheDocument(); + act(() => pickOnTrack(1500)); + const portage = screen.getByTestId('tour-planner-portage-leg'); + expect(portage).toHaveTextContent('at 1 km'); + expect(portage).toHaveTextContent('500 m'); + expect(pickPortage).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByTestId('paddle-time-carry')).toHaveTextContent('500 m'); + expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled(); + + fireEvent.click(pickPortage); + act(() => pickOnTrack(1200)); + act(() => pickOnTrack(1800)); + expect(screen.getByText(HINT.overlap)).toBeInTheDocument(); + expect(screen.getAllByTestId('tour-planner-portage-leg')).toHaveLength(1); + act(() => refuseTrackPick()); + expect(screen.getByText(HINT.offLine)).toBeInTheDocument(); + + openRow(portage); + fireEvent.click(within(portage).getByRole('button', { name: 'Remove the portage' })); + expect(screen.queryByTestId('tour-planner-portage-leg')).not.toBeInTheDocument(); + expect(useTrackMarkToolStore.getState().marks.portages).toEqual([]); + expect(screen.getByText(EMPTY)).toBeInTheDocument(); + }); + + it('FE-COMP-PADDLE-TRACKPLAN-016: on the shared store, a lock laid on the line misses its times until both are entered', () => { + loadTrackMarks(TOUR.place_id, null, NO_SPEEDS); + render(); + + fireEvent.click(screen.getByTestId('track-marks-pick-lock')); + expect(screen.getByText(HINT.lock)).toBeInTheDocument(); + act(() => pickOnTrack(3000.4)); + expect(screen.getByTestId('track-marks-pick-lock')).toHaveAttribute('aria-pressed', 'false'); + expect(screen.queryByText(HINT.lock)).not.toBeInTheDocument(); + const lock = screen.getByTestId('tour-planner-lock'); + expect(lock).toHaveTextContent('at 3 km'); + expect(lock).toHaveTextContent('Time missing'); + expect(screen.getByText('One lock has no times yet.')).toBeInTheDocument(); + expect(screen.getByText('Incomplete')).toBeInTheDocument(); + + openRow(lock); + enter(within(lock).getByTestId('tour-planner-lock-wait'), '5'); + enter(within(lock).getByTestId('tour-planner-lock-transit'), '10'); + expect(lock).toHaveTextContent('15 min'); + expect(lock).not.toHaveTextContent('Time missing'); + expect(screen.queryByText('One lock has no times yet.')).not.toBeInTheDocument(); + expect(screen.queryByText('Incomplete')).not.toBeInTheDocument(); + const { marks } = useTrackMarkToolStore.getState(); + expect(marks.locks).toEqual([{ at_m: 3000, wait_minutes: 5, transit_minutes: 10 }]); + + fireEvent.click(within(lock).getByRole('button', { name: 'Remove the lock' })); + expect(screen.queryByTestId('tour-planner-lock')).not.toBeInTheDocument(); + expect(useTrackMarkToolStore.getState()).toMatchObject({ dirty: true, marks: { locks: [] } }); + }); +}); diff --git a/client/src/components/Tours/paddle/useTrackPaddlePlan.test.ts b/client/src/components/Tours/paddle/useTrackPaddlePlan.test.ts new file mode 100644 index 000000000..e6e60c42b --- /dev/null +++ b/client/src/components/Tours/paddle/useTrackPaddlePlan.test.ts @@ -0,0 +1,508 @@ +/** + * FE-HOOK-TRACKPADDLE-001 to FE-HOOK-TRACKPADDLE-014: the portages, locks and + * speeds laid onto a recorded paddle track, as the planner's card and map + * share them. + * + * The hook loads the marks of the open paddle import into the mark store, lays + * them along the length of the track and reckons the paddle time from them. A + * portage takes two clicks on the line, a lock one. Saving goes through the + * repo with what the store holds, takes in what came back and says how it + * went; nothing is saved for someone who may not change the tour, and the + * track itself is never touched. + */ +import { act, renderHook } from '@testing-library/react'; +import type { TourDetailResponse, TourListItem, TourPaddlePlanRequest, TrackMarks } from '@trek/shared'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { resetAllStores } from '../../../../tests/helpers/store'; +import { TranslationProvider } from '../../../i18n/TranslationContext'; +import { loadTrackMarks, pickOnTrack, refuseTrackPick, useTrackMarkToolStore } from '../../../store/trackMarkToolStore'; +import type { TourPlannerController } from '../planner/useTourPlanner'; +import { useTrackPaddlePlan } from './useTrackPaddlePlan'; + +type UpdatePaddlePlan = ( + tripId: number | string, + placeId: number, + body: TourPaddlePlanRequest +) => Promise; + +const repo = vi.hoisted(() => ({ updatePaddlePlan: vi.fn() })); +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() })); +vi.mock('../../../repo/tourRepo', () => ({ tourRepo: repo })); +vi.mock('../../shared/Toast', () => ({ useToast: () => toast })); + +type Options = Parameters[0]; + +/** A portage from 2 to 3 km and a lock at 6 km with both its times entered. */ +const MARKS: TrackMarks = { + portages: [{ from_m: 2000, to_m: 3000, extra_minutes: null }], + locks: [{ at_m: 6000, wait_minutes: 10, transit_minutes: 5 }], +}; +const NO_MARKS: TrackMarks = { portages: [], locks: [] }; + +/** A kayak import of the trip, with the marks above and no speeds of its own. */ +function paddleTour(overrides: Partial = {}): TourListItem { + return { + place_id: 7, + name: 'Altmühl from Treuchtlingen', + tour_type: 'kayak', + distance: 12, + elevation_gain: null, + elevation_loss: null, + duration: null, + difficulty: null, + wanderer_ref: null, + match_confidence: null, + max_hiking_difficulty: null, + planned: false, + caution: false, + boat_type: 'kayak', + water_context: 'river', + paddle_speed_kmh: null, + portage_speed_kmh: null, + track_marks: MARKS, + ...overrides, + }; +} + +const TOUR = paddleTour(); +const HIKE: TourListItem = { ...TOUR, tour_type: 'hike', boat_type: null, water_context: null, track_marks: null }; + +/** The planner with a recorded track open read-only, measured at this length, or with nothing open. */ +function planner(tour: TourListItem | null, distanceKm = 12): TourPlannerController { + return { + mode: tour ? { type: 'view-gpx', placeId: tour.place_id, tour } : { type: 'neutral' }, + readOnlyGpxAnalysis: tour ? { distanceKm } : null, + } as unknown as TourPlannerController; +} + +function options(overrides: Partial = {}): Options { + return { tripId: 3, planner: planner(TOUR), canEdit: true, onSaved: vi.fn(), ...overrides }; +} + +function renderPlan(props: Options = options()) { + return renderHook((current: Options) => useTrackPaddlePlan(current), { + initialProps: props, + wrapper: TranslationProvider, + }); +} + +/** A save the test answers by hand, so the hook can be read while it is on its way. */ +function deferred() { + let resolve: (value: TourDetailResponse) => void = () => undefined; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +beforeEach(() => { + vi.clearAllMocks(); + repo.updatePaddlePlan.mockReset(); + resetAllStores(); +}); + +describe('useTrackPaddlePlan', () => { + it('FE-HOOK-TRACKPADDLE-001: loads the marks of the open paddle import and lays them along the track', () => { + const { result } = renderPlan(); + + expect(useTrackMarkToolStore.getState().placeId).toBe(7); + expect(result.current).toMatchObject({ + active: true, + canEdit: true, + saving: false, + dirty: false, + pick: null, + pendingFromM: null, + refused: null, + totalM: 12000, + speeds: { paddle: null, portage: null }, + recommendedPaddleSpeed: 4, + defaultPortageSpeed: 2, + }); + expect(result.current.marks).toEqual(MARKS); + expect(result.current.layout).toEqual({ + totalM: 12000, + stretches: [ + { kind: 'water', fromM: 0, toM: 2000, extraMinutes: null, startIndex: null }, + { kind: 'portage', fromM: 2000, toM: 3000, extraMinutes: null, startIndex: null }, + { kind: 'water', fromM: 3000, toM: 12000, extraMinutes: null, startIndex: null }, + ], + locks: [{ wait_minutes: 10, transit_minutes: 5, atM: 6000, bypassed: false, index: null }], + }); + // 11 km on water at the kayak's 4 km/h, 1 km carried at 2 km/h plus 10 minutes, and the lock's 15. + expect(result.current.estimate).toEqual({ + waterM: 11000, + landM: 1000, + portageCount: 1, + paddleSpeedKmh: 4, + portageSpeedKmh: 2, + waterMinutes: 165, + carryMinutes: 30, + portageExtraMinutes: 10, + lockMinutes: 15, + locksCounted: 1, + locksMissingTime: 0, + breakMinutes: 0, + estimateMinutes: 220, + complete: true, + }); + }); + + it.each<[string, TourListItem | null, number]>([ + ['with nothing open', null, 0], + ['for a hike import', HIKE, 12000], + ])('FE-HOOK-TRACKPADDLE-002: shows no card and empties the store %s', (_label, tour, totalM) => { + loadTrackMarks(9, MARKS, { paddle: 5, portage: 3 }); + const { result } = renderPlan(options({ planner: planner(tour) })); + + expect(useTrackMarkToolStore.getState()).toMatchObject({ placeId: null, speeds: { paddle: null, portage: null } }); + expect(result.current).toMatchObject({ active: false, totalM, marks: NO_MARKS, recommendedPaddleSpeed: null }); + // No boat and no speed: the water time is unknown, never zero. + expect(result.current.estimate).toMatchObject({ + paddleSpeedKmh: null, + waterMinutes: null, + estimateMinutes: 0, + complete: false, + }); + }); + + it('FE-HOOK-TRACKPADDLE-003: marks a portage from two clicks in either order and a lock from one', () => { + const { result } = renderPlan(); + + act(() => result.current.startPick('portage')); + act(() => pickOnTrack(9000.4)); + expect(result.current).toMatchObject({ pick: 'portage', pendingFromM: 9000.4, dirty: false }); + expect(result.current.marks.portages).toHaveLength(1); + + act(() => pickOnTrack(7999.6)); + expect(result.current).toMatchObject({ pick: null, pendingFromM: null, dirty: true }); + expect(result.current.marks.portages).toEqual([ + { from_m: 2000, to_m: 3000, extra_minutes: null }, + { from_m: 8000, to_m: 9000, extra_minutes: null }, + ]); + expect(result.current.layout.stretches.map((stretch) => [stretch.kind, stretch.fromM, stretch.toM])).toEqual([ + ['water', 0, 2000], + ['portage', 2000, 3000], + ['water', 3000, 8000], + ['portage', 8000, 9000], + ['water', 9000, 12000], + ]); + expect(result.current.estimate).toMatchObject({ + waterM: 10000, + landM: 2000, + portageCount: 2, + waterMinutes: 150, + carryMinutes: 60, + portageExtraMinutes: 20, + estimateMinutes: 245, + complete: true, + }); + + act(() => result.current.startPick('lock')); + act(() => pickOnTrack(4000.2)); + expect(result.current.pick).toBeNull(); + expect(result.current.marks.locks).toEqual([ + { at_m: 4000, wait_minutes: null, transit_minutes: null }, + { at_m: 6000, wait_minutes: 10, transit_minutes: 5 }, + ]); + // A lock without its times leaves the estimate incomplete rather than counting it as zero. + expect(result.current.estimate).toMatchObject({ + locksCounted: 2, + locksMissingTime: 1, + lockMinutes: 15, + estimateMinutes: 245, + complete: false, + }); + + // A lock inside a portage is carried round: it counts with the portage, not again. + act(() => result.current.startPick('lock')); + act(() => pickOnTrack(8500)); + expect(result.current.layout.locks.map((lock) => [lock.atM, lock.bypassed])).toEqual([ + [4000, false], + [6000, false], + [8500, true], + ]); + expect(result.current.estimate).toMatchObject({ locksCounted: 2, locksMissingTime: 1, estimateMinutes: 245 }); + }); + + it('FE-HOOK-TRACKPADDLE-004: a tool pressed again turns off, and a refused click says why', () => { + const { result } = renderPlan(); + + act(() => result.current.startPick('lock')); + expect(result.current.pick).toBe('lock'); + act(() => result.current.startPick('lock')); + expect(result.current.pick).toBeNull(); + + act(() => result.current.startPick('portage')); + act(() => pickOnTrack(2200)); + act(() => pickOnTrack(2800)); + expect(result.current).toMatchObject({ pick: 'portage', pendingFromM: null, refused: 'overlap', dirty: false }); + expect(result.current.marks).toEqual(MARKS); + + act(() => refuseTrackPick()); + expect(result.current.refused).toBe('off_line'); + act(() => result.current.startPick('lock')); + expect(result.current).toMatchObject({ pick: 'lock', refused: null }); + }); + + it('FE-HOOK-TRACKPADDLE-005: takes the minutes entered for a portage and a lock, and removes either', () => { + const { result } = renderPlan(); + + act(() => result.current.edits.setPortageExtra(0, 25)); + expect(result.current.marks.portages).toEqual([{ from_m: 2000, to_m: 3000, extra_minutes: 25 }]); + expect(result.current.dirty).toBe(true); + expect(result.current.estimate).toMatchObject({ portageExtraMinutes: 25, estimateMinutes: 235 }); + + // Zero is a time of its own; cleared, the starting value of 10 minutes applies again. + act(() => result.current.edits.setPortageExtra(0, 0)); + expect(result.current.estimate).toMatchObject({ portageExtraMinutes: 0, estimateMinutes: 210 }); + act(() => result.current.edits.setPortageExtra(0, null)); + expect(result.current.estimate).toMatchObject({ portageExtraMinutes: 10, estimateMinutes: 220 }); + + act(() => result.current.edits.setLock(0, { wait_minutes: null, transit_minutes: 5 })); + expect(result.current.marks.locks).toEqual([{ at_m: 6000, wait_minutes: null, transit_minutes: 5 }]); + expect(result.current.estimate).toMatchObject({ + lockMinutes: 5, + locksMissingTime: 1, + estimateMinutes: 210, + complete: false, + }); + + act(() => result.current.edits.setLock(0, null)); + expect(result.current.marks.locks).toEqual([]); + expect(result.current.estimate).toMatchObject({ + locksCounted: 0, + lockMinutes: 0, + estimateMinutes: 205, + complete: true, + }); + + act(() => result.current.edits.removePortage(0)); + expect(result.current.marks).toEqual(NO_MARKS); + expect(result.current.layout.stretches).toEqual([ + { kind: 'water', fromM: 0, toM: 12000, extraMinutes: null, startIndex: null }, + ]); + expect(result.current.estimate).toMatchObject({ portageCount: 0, waterMinutes: 180, estimateMinutes: 180 }); + }); + + it('FE-HOOK-TRACKPADDLE-006: reckons with the speeds saved on the tour, then with those entered', () => { + const saved = paddleTour({ paddle_speed_kmh: 5.5, portage_speed_kmh: 3 }); + const { result } = renderPlan(options({ planner: planner(saved) })); + + expect(result.current.speeds).toEqual({ paddle: 5.5, portage: 3 }); + expect(result.current.estimate).toMatchObject({ + paddleSpeedKmh: 5.5, + portageSpeedKmh: 3, + waterMinutes: 120, + carryMinutes: 20, + estimateMinutes: 165, + }); + + act(() => result.current.edits.setSpeeds({ paddle: 6 })); + expect(result.current).toMatchObject({ dirty: true, speeds: { paddle: 6, portage: 3 } }); + expect(result.current.estimate).toMatchObject({ waterMinutes: 110, carryMinutes: 20, estimateMinutes: 155 }); + + act(() => result.current.edits.setSpeeds({ portage: 4 })); + expect(result.current.estimate).toMatchObject({ portageSpeedKmh: 4, carryMinutes: 15, estimateMinutes: 150 }); + + // Cleared, the suggested speed of the boat applies again. + act(() => result.current.edits.setSpeeds({ paddle: null })); + expect(result.current.speeds).toEqual({ paddle: null, portage: 4 }); + expect(result.current.estimate).toMatchObject({ paddleSpeedKmh: 4, waterMinutes: 165, estimateMinutes: 205 }); + }); + + it('FE-HOOK-TRACKPADDLE-007: a boat with no suggested speed leaves the water time open until one is entered', () => { + const { result } = renderPlan(options({ planner: planner(paddleTour({ boat_type: 'other' })) })); + + expect(result.current.recommendedPaddleSpeed).toBeNull(); + expect(result.current.estimate).toMatchObject({ + paddleSpeedKmh: null, + waterMinutes: null, + carryMinutes: 30, + portageExtraMinutes: 10, + lockMinutes: 15, + estimateMinutes: 55, + complete: false, + }); + + act(() => result.current.edits.setSpeeds({ paddle: 4 })); + expect(result.current.estimate).toMatchObject({ + paddleSpeedKmh: 4, + waterMinutes: 165, + estimateMinutes: 220, + complete: true, + }); + }); + + it('FE-HOOK-TRACKPADDLE-008: saves through the repo, takes in what came back and hands the tour on', async () => { + const sent: TrackMarks = { + portages: [{ from_m: 2000, to_m: 3000, extra_minutes: null }], + locks: [ + { at_m: 4000, wait_minutes: null, transit_minutes: null }, + { at_m: 6000, wait_minutes: 10, transit_minutes: 5 }, + ], + }; + const saved = paddleTour({ paddle_speed_kmh: 5, portage_speed_kmh: undefined, track_marks: sent }); + const pending = deferred(); + repo.updatePaddlePlan.mockReturnValueOnce(pending.promise); + // Whether the store was clean already when the tour was handed on to replace the open one. + const dirtyWhenHandedOn: boolean[] = []; + const onSaved = vi.fn<(tour: TourListItem) => void>(() => { + dirtyWhenHandedOn.push(useTrackMarkToolStore.getState().dirty); + }); + const { result } = renderPlan(options({ onSaved })); + act(() => result.current.startPick('lock')); + act(() => pickOnTrack(4000)); + act(() => result.current.edits.setSpeeds({ paddle: 5 })); + + const saves: Promise[] = []; + act(() => { + saves.push(result.current.save()); + }); + expect(result.current.saving).toBe(true); + // A second press while the first is on its way sends nothing more. + await act(async () => { + await result.current.save(); + }); + expect(repo.updatePaddlePlan).toHaveBeenCalledTimes(1); + expect(repo.updatePaddlePlan).toHaveBeenCalledWith(3, 7, { + paddle_speed_kmh: 5, + portage_speed_kmh: null, + track_marks: sent, + }); + + await act(async () => { + pending.resolve({ tour: saved, waypoints: [] }); + await Promise.all(saves); + }); + + expect(result.current).toMatchObject({ saving: false, dirty: false, speeds: { paddle: 5, portage: null } }); + expect(result.current.marks).toEqual(sent); + expect(onSaved).toHaveBeenCalledWith(saved); + expect(dirtyWhenHandedOn).toEqual([false]); + expect(toast.success).toHaveBeenCalledWith('Portages and locks saved'); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it('FE-HOOK-TRACKPADDLE-009: with everything removed, the saved tour carries no marks and the store none', async () => { + const saved = paddleTour({ track_marks: null }); + repo.updatePaddlePlan.mockResolvedValueOnce({ tour: saved, waypoints: [] }); + const onSaved = vi.fn<(tour: TourListItem) => void>(); + const { result } = renderPlan(options({ onSaved })); + act(() => { + result.current.edits.removePortage(0); + result.current.edits.setLock(0, null); + }); + + await act(async () => { + await result.current.save(); + }); + + expect(repo.updatePaddlePlan).toHaveBeenCalledWith(3, 7, { + paddle_speed_kmh: null, + portage_speed_kmh: null, + track_marks: NO_MARKS, + }); + expect(result.current).toMatchObject({ dirty: false, marks: NO_MARKS }); + expect(onSaved).toHaveBeenCalledWith(saved); + }); + + it('FE-HOOK-TRACKPADDLE-010: a failed save says so and keeps the unsaved marks to try again', async () => { + repo.updatePaddlePlan.mockRejectedValueOnce(new Error('offline')); + const onSaved = vi.fn<(tour: TourListItem) => void>(); + const { result } = renderPlan(options({ onSaved })); + act(() => result.current.edits.setPortageExtra(0, 20)); + + await act(async () => { + await result.current.save(); + }); + + expect(toast.error).toHaveBeenCalledWith('Saving did not work. Please try again.'); + expect(toast.success).not.toHaveBeenCalled(); + expect(onSaved).not.toHaveBeenCalled(); + expect(result.current).toMatchObject({ saving: false, dirty: true }); + expect(result.current.marks.portages).toEqual([{ from_m: 2000, to_m: 3000, extra_minutes: 20 }]); + }); + + it.each<[string, Partial, boolean]>([ + ['for someone who may not change the tour', { canEdit: false }, true], + ['with no paddle import open', { planner: planner(null) }, false], + ])('FE-HOOK-TRACKPADDLE-011: saves nothing %s', async (_label, overrides, active) => { + const { result } = renderPlan(options(overrides)); + expect(result.current.active).toBe(active); + + await act(async () => { + await result.current.save(); + }); + + expect(repo.updatePaddlePlan).not.toHaveBeenCalled(); + expect(toast.success).not.toHaveBeenCalled(); + expect(toast.error).not.toHaveBeenCalled(); + expect(result.current.saving).toBe(false); + }); + + it('FE-HOOK-TRACKPADDLE-012: discarding puts back the marks and speeds the tour was saved with', () => { + const { result } = renderPlan(); + act(() => { + result.current.edits.removePortage(0); + result.current.edits.setSpeeds({ paddle: 6, portage: 3 }); + result.current.startPick('lock'); + }); + expect(result.current).toMatchObject({ dirty: true, pick: 'lock' }); + + act(() => result.current.discard()); + expect(result.current).toMatchObject({ dirty: false, pick: null, speeds: { paddle: null, portage: null } }); + expect(result.current.marks).toEqual(MARKS); + expect(repo.updatePaddlePlan).not.toHaveBeenCalled(); + }); + + it('FE-HOOK-TRACKPADDLE-013: with no paddle import open, discarding leaves the shared store alone', () => { + const { result } = renderPlan(options({ planner: planner(null) })); + act(() => loadTrackMarks(9, MARKS, { paddle: 5, portage: null })); + + act(() => result.current.discard()); + expect(useTrackMarkToolStore.getState()).toMatchObject({ + placeId: 9, + marks: MARKS, + speeds: { paddle: 5, portage: null }, + }); + }); + + it('FE-HOOK-TRACKPADDLE-014: the marks follow the open tour when it is saved elsewhere, changed or left', () => { + const props = options(); + const { result, rerender } = renderPlan(props); + act(() => result.current.edits.setPortageExtra(0, 30)); + + // The same tour, saved from another device meanwhile: its marks replace the unsaved ones. + rerender({ ...props, planner: planner(paddleTour({ track_marks: null, paddle_speed_kmh: 6 })) }); + expect(result.current).toMatchObject({ active: true, dirty: false, speeds: { paddle: 6, portage: null } }); + expect(result.current.marks).toEqual(NO_MARKS); + expect(result.current.estimate).toMatchObject({ waterMinutes: 120, estimateMinutes: 120, complete: true }); + + // Another import, from a list cached before tours carried their speeds. + const other = paddleTour({ + place_id: 8, + boat_type: 'canoe', + paddle_speed_kmh: undefined, + portage_speed_kmh: undefined, + track_marks: { portages: [], locks: [{ at_m: 500, wait_minutes: 5, transit_minutes: 5 }] }, + }); + rerender({ ...props, planner: planner(other, 3) }); + expect(useTrackMarkToolStore.getState().placeId).toBe(8); + expect(result.current).toMatchObject({ active: true, totalM: 3000, speeds: { paddle: null, portage: null } }); + expect(result.current.estimate).toMatchObject({ + waterMinutes: 45, + lockMinutes: 10, + estimateMinutes: 55, + complete: true, + }); + + // The shared store holding the marks of another tour: the card does not show them as this one's. + act(() => loadTrackMarks(9, null, { paddle: null, portage: null })); + expect(result.current.active).toBe(false); + + rerender({ ...props, planner: planner(null) }); + expect(result.current.active).toBe(false); + expect(useTrackMarkToolStore.getState()).toMatchObject({ placeId: null, marks: NO_MARKS }); + }); +});