test(tours): cover the paddle time card, the wind card and the marks on a recorded paddle track

The time card's estimate with water, portages and locks and a lock without times leaving it incomplete, every state of the wind card with day switching and missing values that never read as calm, and the track marks with their tools, minutes, removal and saving through the repo.
This commit is contained in:
Maurice
2026-10-16 23:20:46 +00:00
parent 2e3fd4fc4c
commit 64e413330e
4 changed files with 1744 additions and 0 deletions
@@ -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> = {}): 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 <PaddleTimeCard planner={paddlePlanner(setup).planner} locked={locked} />;
}
/** Renders the card and hands back the planner's carrying speed setter. */
function renderCard(setup: CardSetup = {}, locked = false) {
const { planner, setPortageSpeedKmh } = paddlePlanner(setup);
render(<PaddleTimeCard planner={planner} locked={locked} />);
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(
<PaddleEstimateBody
estimate={estimate}
layout={paddleLayoutOfPoints(paddlePlanPoints(waypoints))}
breakMinutes={null}
planned={null}
locked={locked}
portageSpeed={{ value: null, startValue: 2, onChange: vi.fn() }}
paddleSpeed={paddleSpeed}
/>
);
}
/** 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();
});
});
@@ -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> = {}): 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<TourWind['retry']>();
const chooseDay = vi.fn<TourWind['chooseDay']>();
/** 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(<PaddleWindCard wind={wind} />);
/** 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<string | null> {
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<HTMLElement>(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(<PaddleWindCard wind={windOf(ready(forecastOf({ day: null })))} />);
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(<PaddleWindCard wind={windOf(ready(calm))} />);
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(<PaddleWindCard wind={windOf(ready(forecastOf()), [JULY_8, JULY_9], JULY_8)} />);
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(<PaddleWindSection placeId={7} start={{ lat: 47.05, lng: 8.31 }} />);
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(<FigureBadge testId="figure" label="Height" value="1.2 m" tone="warning" />);
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(<FigureBadge testId="figure" value="Private" />);
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(
<>
<FigureBadge testId="plain" value="Private" />
<FigureBadge testId="explained" label="Access" value="No" info="Recorded as private on OpenStreetMap" />
</>
);
// 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);
});
});
@@ -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> = {}): 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 <TrackPaddlePlanCards plan={plan} tour={tour} />;
}
/** 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(<StoreCards />);
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(<StoreCards />);
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: [] } });
});
});
@@ -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<TourDetailResponse>;
const repo = vi.hoisted(() => ({ updatePaddlePlan: vi.fn<UpdatePaddlePlan>() }));
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<typeof useTrackPaddlePlan>[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> = {}): 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> = {}): 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<TourDetailResponse>((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<void>[] = [];
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<Options>, 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 });
});
});