feat(mobile): the driving figures are a form on an iPad and an unfolded Duo

On the phone the road trip's driving figures stay a read-out, set at the
desktop. A large screen is that table, so in the wide and split size classes
the same sheet edits the two driving limits, the day's window and where it
ends, the vehicle with its range and fill, and what the route may avoid,
through the planner's saveRoadtripLimit, the desktop dialog's own save. A
range worked out from the vehicle's own figures stays read-only, as on the
desktop, and a reader without the right to edit days sees every field
disabled. The phone and the folded Duo are unchanged.
This commit is contained in:
Maurice
2026-11-08 02:37:26 +00:00
parent dedbf16f03
commit 060a43ba64
4 changed files with 555 additions and 18 deletions
+1
View File
@@ -1045,6 +1045,7 @@
"src/mobile/screens/trip/roadtrip/MRtCorridorBar.tsx": "Markup only; its corridor state comes through props from useMRtCorridor.",
"src/mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx": "Phone corridor search sheet over useMRtCorridor and the planner's shared corridor state.",
"src/mobile/screens/trip/roadtrip/MRtInfoSheet.tsx": "Read only figures sheet that already shares useHotelBookends, dayWindow and parseAvoid with RoadtripLimitsCard.",
"src/mobile/screens/trip/roadtrip/MRtLimitsEditor.tsx": "Large screen form inside MRtInfoSheet over the planner's saveRoadtripLimit, the desktop dialog's own save, and the shared dayWindow, parseAvoid and rangeFromSpec.",
"src/mobile/screens/trip/roadtrip/MRtKindSheet.tsx": "Thin sheet over planner.setRoadtripStopKind that already shares STOP_KINDS with StopKindPicker.",
"src/mobile/screens/trip/roadtrip/MRtStopSheet.tsx": "Phone only stop detail sheet over the shared roadtripRowModel, stayReading and planner writers.",
"src/mobile/screens/trip/roadtrip/stageBadges.ts": "Badge helpers for the phone stage header and alternatives bar, which have no desktop counterpart.",
@@ -14,6 +14,8 @@ import { formatDurationShort, parseAvoid } from '../../../../components/Roadtrip
import { valhallaAvailable } from '../../../../components/Map/valhallaRoute'
import { convertDistance, formatDistance, getDistanceUnitLabel } from '../../../../utils/units'
import { Eyebrow, TileHeader } from '../sheets/MTripSheetUi'
import { useIsWide } from '../../../../hooks/sizeClass'
import MRtLimitsEditor from './MRtLimitsEditor'
import type { DistanceUnit } from '../../../../types'
/**
@@ -104,6 +106,9 @@ export default function MRtInfoSheet({ planner, shell }: MTripSheetsProps) {
const degradation = useRoadtripSettings(s => s.roadtrip_battery_degradation, tripId)
const avoidRaw = useRoadtripSettings(s => s.roadtrip_avoid, tripId)
const bookends = useHotelBookends(planner.saveRoadtripLimit, tripId)
// An iPad or an unfolded Duo is the table the figures are set at: there the limits are a
// form (MRtLimitsEditor). The phone and the folded Duo keep the read-out below.
const large = useIsWide()
const electric = vehicleKind === 'electric'
const VehicleIcon = electric ? Zap : vehicleKind === 'combustion' ? Fuel : Car
@@ -225,26 +230,41 @@ export default function MRtInfoSheet({ planner, shell }: MTripSheetsProps) {
</div>
{/* ── Limits: what every warning on the stage is measured against ── */}
<div className="mt-2.5">
<InfoCard label={t('roadtrip.limit.title')}>
<div className="mt-1">
{lines.map((line, i) => (
<LimitLine
key={line.key}
label={line.label}
value={line.value}
fallback={line.fallback}
first={i === 0}
/>
))}
{large ? (
<div className="mt-2.5">
<MRtLimitsEditor
tripId={tripId}
// No save is the planner saying the reader may not edit days, or that the
// trip's settings are not in yet: the form shows its figures, disabled.
readOnly={!planner.saveRoadtripLimit}
onSave={(key, value) => void planner.saveRoadtripLimit?.(key, value)}
onResetDayBoundaries={planner.resetDayBoundaries}
/>
</div>
) : (
<>
<div className="mt-2.5">
<InfoCard label={t('roadtrip.limit.title')}>
<div className="mt-1">
{lines.map((line, i) => (
<LimitLine
key={line.key}
label={line.label}
value={line.value}
fallback={line.fallback}
first={i === 0}
/>
))}
</div>
</InfoCard>
</div>
</InfoCard>
</div>
{/* Under the figures it speaks of, and above the switch it does not. */}
<p className="mt-2.5 font-geist text-[0.6875rem] leading-snug text-m-faint">
{t('mobileTrip.rtDesktopNote')}
</p>
{/* Under the figures it speaks of, and above the switch it does not. */}
<p className="mt-2.5 font-geist text-[0.6875rem] leading-snug text-m-faint">
{t('mobileTrip.rtDesktopNote')}
</p>
</>
)}
{/* ── The one switch: whether a day starts and ends at the stay ── */}
<div className="mt-3">
@@ -0,0 +1,350 @@
import { useState, type ReactNode } from 'react';
import { valhallaAvailable } from '../../../../components/Map/valhallaRoute';
import { dayWindow } from '../../../../components/Roadtrip/dayWindow';
import { parseAvoid, serializeAvoid, type VehicleKind } from '../../../../components/Roadtrip/roadtripModel';
import { rangeFromSpec, type VehicleSpec } from '../../../../components/Roadtrip/vehicleRange';
import CustomTimePicker from '../../../../components/shared/CustomTimePicker';
import { useRoadtripSettings } from '../../../../hooks/useRoadtripSettings';
import { useTranslation } from '../../../../i18n';
import { useSettingsStore } from '../../../../store/settingsStore';
import type { RouteAvoidClass } from '../../../../types';
import { convertDistance } from '../../../../utils/units';
import MSegmented from '../../../components/MSegmented';
import MToggle from '../../../components/MToggle';
import { Eyebrow } from '../sheets/MTripSheetUi';
/**
* The driving figures as a form, for an iPad or an unfolded Duo.
*
* The phone keeps them read-only (MRtInfoSheet says why: a form in the passenger seat breaks
* a trip while it is happening). A large screen is the table the figures are set at, so
* there the same sheet edits what the desktop's limits dialog edits most: the two driving
* limits, the day's window and where it ends, the vehicle with its range and fill, and what
* the road may avoid. The vehicle's own figures (tank, consumption, battery) stay on the
* desktop; once they are stored the range is theirs and shows read-only here, as it does
* there.
*
* Every change goes through the planner's `saveRoadtripLimit`, the desktop dialog's own
* save, which checks the right to edit days itself. `readOnly` disables the controls for a
* reader without that right, so nothing takes a value only to drop it.
*/
/** Empty means no limit, and so does zero: both are stored as 0 and read as off. */
function parseFigure(raw: string): number {
const n = Number(raw.replace(',', '.'));
return Number.isFinite(n) && n > 0 ? Math.round(n) : 0;
}
/** One card of the form: eyebrow plus its rows. */
function EditCard({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="rounded-[16px] bg-[color:var(--m-ic)] px-3.5 py-3">
<Eyebrow>{label}</Eyebrow>
<div className="mt-1">{children}</div>
</div>
);
}
/** A label and its control on one line, with the row rule above every row but the first. */
function EditRow({ label, first, children }: { label: string; first?: boolean; children: ReactNode }) {
return (
<div
className={`flex min-h-11 items-center justify-between gap-3 py-1.5 ${first ? '' : 'border-t border-[color:var(--m-rowbr)]'}`}
>
<span className="min-w-0 flex-1 text-body text-m-muted">{label}</span>
{children}
</div>
);
}
/**
* A number typed in and saved when the field is left or Enter is pressed, not per
* keystroke: a settings write per digit would plan the trip against "4" on the way to "45".
*/
function FigureField({
label,
value,
suffix,
placeholder,
disabled,
testId,
onCommit,
}: {
label: string;
value: number | undefined;
suffix: string;
placeholder: string;
disabled: boolean;
testId: string;
onCommit: (value: number) => void;
}) {
const [draft, setDraft] = useState<string | null>(null);
const shown = draft ?? (value ? String(value) : '');
const commit = () => {
if (draft === null) return;
setDraft(null);
const next = parseFigure(draft);
if (next !== (value ?? 0)) onCommit(next);
};
return (
<span className="flex flex-none items-center gap-1.5">
<input
type="text"
inputMode="numeric"
dir="ltr"
aria-label={label}
data-testid={testId}
value={shown}
placeholder={placeholder}
disabled={disabled}
onChange={(e) => setDraft(e.target.value.replace(/[^0-9.,]/g, ''))}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur();
}}
className="h-9 w-[5.5rem] rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-3 text-end text-body font-semibold tabular-nums text-m-ink outline-none placeholder:font-medium placeholder:text-m-faint disabled:opacity-60"
/>
<span className="w-7 text-caption text-m-faint">{suffix}</span>
</span>
);
}
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
const HINT = 'font-geist text-caption leading-snug text-m-faint';
interface CardProps {
tripId: number;
readOnly: boolean;
onSave: (key: string, value: number | string | boolean) => void;
}
/** How long one stretch and one day may be, in minutes. */
function DrivingCard({ tripId, readOnly, onSave }: CardProps) {
const { t } = useTranslation();
const leg = useRoadtripSettings((all) => all.roadtrip_leg_minutes, tripId);
const day = useRoadtripSettings((all) => all.roadtrip_day_minutes, tripId);
const rows = [
{ key: 'roadtrip_leg_minutes', label: t('roadtrip.limit.legLabel'), value: leg, testId: 'rt-edit-leg' },
{ key: 'roadtrip_day_minutes', label: t('roadtrip.limit.dayLabel'), value: day, testId: 'rt-edit-day' },
];
return (
<EditCard label={t('roadtrip.limit.sectionDriving')}>
{rows.map((row, i) => (
<EditRow key={row.key} label={row.label} first={i === 0}>
<FigureField
label={row.label}
value={row.value}
suffix={t('roadtrip.limit.minutes')}
placeholder={t('roadtrip.limit.off')}
disabled={readOnly}
testId={row.testId}
onCommit={(v) => onSave(row.key, v)}
/>
</EditRow>
))}
<p className={`pb-1 ${HINT}`}>{t('roadtrip.limit.hint')}</p>
</EditCard>
);
}
/** A choice between a few words, faded and out of reach for a reader. */
function Choice<T extends string>({
readOnly,
value,
options,
onChange,
}: {
readOnly: boolean;
value: T;
options: { value: T; label: string }[];
onChange: (value: T) => void;
}) {
return (
<div className={readOnly ? 'pointer-events-none opacity-60' : ''} aria-disabled={readOnly || undefined}>
<MSegmented value={value} onChange={onChange} options={options} />
</div>
);
}
/** When the day may start and end, and whether it ends on the road or at a stop. */
function WindowCard({
tripId,
readOnly,
onSave,
onResetDayBoundaries,
}: CardProps & {
onResetDayBoundaries?: () => Promise<void>;
}) {
const { t } = useTranslation();
const storedStart = useRoadtripSettings((all) => all.roadtrip_day_start, tripId) ?? '';
const storedEnd = useRoadtripSettings((all) => all.roadtrip_day_end, tripId) ?? '';
const endMode = useRoadtripSettings((all) => all.roadtrip_day_end_mode, tripId) === 'stop' ? 'stop' : 'route';
// What is in the pickers, ahead of a time that is whole enough to save.
const [times, setTimes] = useState<{ start?: string; end?: string }>({});
const shown = { start: times.start ?? storedStart, end: times.end ?? storedEnd };
const stored = { start: storedStart, end: storedEnd };
const invalid = !!shown.start && !!shown.end && !dayWindow(shown.start, shown.end);
const setTime = (field: 'start' | 'end', value: string) => {
setTimes((prev) => ({ ...prev, [field]: value }));
if (value !== '' && !TIME_RE.test(value)) return;
if (value !== stored[field]) onSave(`roadtrip_day_${field}`, value);
};
return (
<EditCard label={t('roadtrip.window.title')}>
{(['start', 'end'] as const).map((field, i) => (
<EditRow key={field} label={t(`roadtrip.window.${field}`)} first={i === 0}>
<span className="w-[7rem] flex-none">
<CustomTimePicker
value={shown[field]}
disabled={readOnly}
aria-label={t(`roadtrip.window.${field}`)}
aria-invalid={invalid || undefined}
onChange={(value) => setTime(field, value)}
/>
</span>
</EditRow>
))}
<p
role={invalid ? 'alert' : undefined}
className={`pb-2 ${invalid ? 'font-geist text-caption leading-snug text-danger' : HINT}`}
>
{t(invalid ? 'roadtrip.window.invalid' : 'roadtrip.window.hint')}
</p>
<div className="border-t border-[color:var(--m-rowbr)] pt-2.5">
<div className="mb-2 text-body text-m-muted">{t('roadtrip.window.endAt')}</div>
<Choice
readOnly={readOnly}
value={endMode}
onChange={(mode) => onSave('roadtrip_day_end_mode', mode)}
options={[
{ value: 'route', label: t('roadtrip.window.onRoute') },
{ value: 'stop', label: t('roadtrip.window.atStop') },
]}
/>
<p className={`mt-2 ${HINT}`}>
{t(endMode === 'stop' ? 'roadtrip.window.stopHint' : 'roadtrip.window.routeHint')}
</p>
{onResetDayBoundaries && !readOnly && (
<button
type="button"
onClick={() => void onResetDayBoundaries()}
className="mt-2 min-h-9 text-start text-caption font-semibold text-m-ink underline-offset-2 active:underline"
>
{t('roadtrip.window.resetBoundaries')}
</button>
)}
</div>
</EditCard>
);
}
/** The vehicle, how far it goes, and how full a stop fills it. */
function VehicleCard({ tripId, readOnly, onSave }: CardProps) {
const { t } = useTranslation();
const imperial = useSettingsStore((st) => st.settings.distance_unit) === 'imperial';
const s = useRoadtripSettings((all) => all, tripId);
const vehicle = s.roadtrip_vehicle ?? '';
const vehicleKind: VehicleKind | null = vehicle === 'combustion' || vehicle === 'electric' ? vehicle : null;
const computedKm = rangeFromSpec(vehicleKind, {
tankLitres: s.roadtrip_tank_litres,
litresPer100: s.roadtrip_litres_per_100,
batteryKwh: s.roadtrip_battery_kwh,
kwhPer100: s.roadtrip_kwh_per_100,
degradationPercent: s.roadtrip_battery_degradation,
} satisfies VehicleSpec);
const rangeKm = computedKm ?? s.roadtrip_range_km;
// Kilometres in storage, the traveller's own unit on screen, as on the desktop.
const rangeShown = rangeKm ? Math.round(convertDistance(rangeKm, imperial ? 'imperial' : 'metric')) : undefined;
const rangeLabel = t(vehicleKind === 'electric' ? 'roadtrip.limit.rangeLabelCharge' : 'roadtrip.limit.rangeLabel');
return (
<EditCard label={t('roadtrip.limit.sectionVehicle')}>
<div className="pb-1.5 pt-1">
<Choice
readOnly={readOnly}
value={vehicleKind ?? 'any'}
onChange={(kind) => onSave('roadtrip_vehicle', kind === 'any' ? '' : kind)}
options={[
{ value: 'any', label: t('roadtrip.limit.vehicleAny') },
{ value: 'combustion', label: t('roadtrip.limit.vehicleCombustion') },
{ value: 'electric', label: t('roadtrip.limit.vehicleElectric') },
]}
/>
</div>
<EditRow label={rangeLabel}>
<FigureField
label={rangeLabel}
value={rangeShown}
suffix={imperial ? 'mi' : 'km'}
placeholder={t('roadtrip.limit.off')}
// Worked out from the vehicle's own figures: those are set on the desktop, and a
// second answer here would leave nobody knowing which one the warnings use.
disabled={readOnly || computedKm !== null}
testId="rt-edit-range"
onCommit={(v) => onSave('roadtrip_range_km', Math.round(imperial ? v / 0.621371 : v))}
/>
</EditRow>
<EditRow label={t('roadtrip.limit.fillLabel')}>
<FigureField
label={t('roadtrip.limit.fillLabel')}
value={s.roadtrip_fill_percent}
suffix="%"
placeholder={t('roadtrip.limit.fillFull')}
disabled={readOnly}
testId="rt-edit-fill"
onCommit={(v) => onSave('roadtrip_fill_percent', Math.min(100, v))}
/>
</EditRow>
{computedKm !== null && <p className={`pb-1 ${HINT}`}>{t('roadtrip.limit.computed')}</p>}
</EditCard>
);
}
/** What the road may avoid: one stored list that each switch adds to or takes from. */
function AvoidCard({ tripId, readOnly, onSave }: CardProps) {
const { t } = useTranslation();
const avoiding = parseAvoid(useRoadtripSettings((all) => all.roadtrip_avoid, tripId));
// No second engine, no avoidance: the switches stay, disabled, and the hint says why.
const canAvoid = valhallaAvailable();
const rows: { cls: RouteAvoidClass; label: string }[] = [
{ cls: 'toll', label: t('roadtrip.avoid.toll') },
{ cls: 'motorway', label: t('roadtrip.avoid.motorway') },
{ cls: 'ferry', label: t('roadtrip.avoid.ferry') },
];
const toggle = (cls: RouteAvoidClass) =>
onSave(
'roadtrip_avoid',
serializeAvoid(avoiding.includes(cls) ? avoiding.filter((c) => c !== cls) : [...avoiding, cls])
);
return (
<EditCard label={t('roadtrip.avoid.section')}>
{rows.map(({ cls, label }, i) => (
<EditRow key={cls} label={label} first={i === 0}>
<MToggle
checked={avoiding.includes(cls)}
onChange={() => toggle(cls)}
ariaLabel={label}
disabled={readOnly || !canAvoid}
/>
</EditRow>
))}
<p className={`pb-1 ${HINT}`}>{t(canAvoid ? 'roadtrip.avoid.hint' : 'roadtrip.avoid.unavailable')}</p>
</EditCard>
);
}
export default function MRtLimitsEditor({
onResetDayBoundaries,
...card
}: CardProps & {
onResetDayBoundaries?: () => Promise<void>;
}) {
return (
<div className="flex flex-col gap-2.5" data-testid="rt-limits-editor">
<DrivingCard {...card} />
<WindowCard {...card} onResetDayBoundaries={onResetDayBoundaries} />
<VehicleCard {...card} />
<AvoidCard {...card} />
</div>
);
}
@@ -0,0 +1,166 @@
import type { RoadtripPreferences } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { _pinSizeClass, type SizeClass } from '../../../../src/hooks/sizeClass';
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell';
import MRtInfoSheet from '../../../../src/mobile/screens/trip/roadtrip/MRtInfoSheet';
import { useAuthStore } from '../../../../src/store/authStore';
import { useRoadtripPreferencesStore } from '../../../../src/store/roadtripPreferencesStore';
import { useSettingsStore } from '../../../../src/store/settingsStore';
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip';
import { fireEvent, render, screen } from '../../../helpers/render';
import { resetAllStores, seedStore } from '../../../helpers/store';
// FE-MOB-RTEDIT-001 to FE-MOB-RTEDIT-012
//
// The driving figures as a form on an iPad or an unfolded Duo, inside the real
// MRtInfoSheet and TranslationProvider. The phone keeps the read-out
// (MRtInfoSheet.test.tsx); these pin the large screen's form.
const USER_ID = 7;
const TRIP_ID = 4;
const TYPED: RoadtripPreferences = {
roadtrip_vehicle: 'combustion',
roadtrip_range_km: 600,
roadtrip_fill_percent: 80,
roadtrip_leg_minutes: 240,
roadtrip_day_minutes: 480,
roadtrip_day_start: '08:00',
roadtrip_day_end: '19:00',
roadtrip_avoid: 'toll',
};
function renderSheet(
preferences: RoadtripPreferences = TYPED,
size: SizeClass = 'wide',
plannerOverrides: Record<string, unknown> = {}
) {
_pinSizeClass(size);
useRoadtripPreferencesStore.setState({ byTrip: { [`${USER_ID}:${TRIP_ID}`]: preferences } });
const saveRoadtripLimit = vi.fn(async () => {});
const planner = buildPlanner({
tripId: TRIP_ID,
saveRoadtripLimit,
...plannerOverrides,
} as unknown as Partial<TripPlanner>);
const shell = buildShell({ sheet: { id: 'rtinfo' } } as unknown as Partial<MTripShellApi>);
render(<MRtInfoSheet planner={planner} shell={shell} />);
return { saveRoadtripLimit };
}
/** Types a figure into a field and leaves it, which is when the form saves. */
function typeInto(testId: string, value: string): void {
const field = screen.getByTestId(testId);
fireEvent.change(field, { target: { value } });
fireEvent.blur(field);
}
describe('MRtLimitsEditor', () => {
beforeEach(() => {
resetAllStores();
useRoadtripPreferencesStore.setState({ byTrip: {} });
seedStore(useAuthStore, { user: { id: USER_ID } as never });
});
afterEach(() => {
_pinSizeClass(null);
});
it('FE-MOB-RTEDIT-001: the phone keeps the read-out and its desktop footnote', () => {
renderSheet(TYPED, 'compact');
expect(screen.queryByTestId('rt-limits-editor')).not.toBeInTheDocument();
expect(screen.getByText(/desktop/i)).toBeInTheDocument();
});
it('FE-MOB-RTEDIT-002: an iPad gets the form instead, without the footnote', () => {
renderSheet();
expect(screen.getByTestId('rt-limits-editor')).toBeInTheDocument();
expect(screen.queryByText(/desktop/i)).not.toBeInTheDocument();
expect(screen.getByTestId('rt-edit-leg')).toHaveValue('240');
expect(screen.getByTestId('rt-edit-day')).toHaveValue('480');
expect(screen.getByTestId('rt-edit-range')).toHaveValue('600');
expect(screen.getByTestId('rt-edit-fill')).toHaveValue('80');
});
it('FE-MOB-RTEDIT-003: a driving limit is saved when the field is left, not per keystroke', () => {
const { saveRoadtripLimit } = renderSheet();
const field = screen.getByTestId('rt-edit-leg');
fireEvent.change(field, { target: { value: '3' } });
fireEvent.change(field, { target: { value: '300' } });
expect(saveRoadtripLimit).not.toHaveBeenCalled();
fireEvent.blur(field);
expect(saveRoadtripLimit).toHaveBeenCalledTimes(1);
expect(saveRoadtripLimit).toHaveBeenCalledWith('roadtrip_leg_minutes', 300);
});
it('FE-MOB-RTEDIT-004: an emptied field switches the limit off, an unchanged one saves nothing', () => {
const { saveRoadtripLimit } = renderSheet();
typeInto('rt-edit-day', '480');
expect(saveRoadtripLimit).not.toHaveBeenCalled();
typeInto('rt-edit-day', '');
expect(saveRoadtripLimit).toHaveBeenCalledWith('roadtrip_day_minutes', 0);
});
it('FE-MOB-RTEDIT-005: the fill is capped at a full tank', () => {
const { saveRoadtripLimit } = renderSheet();
typeInto('rt-edit-fill', '140');
expect(saveRoadtripLimit).toHaveBeenCalledWith('roadtrip_fill_percent', 100);
});
it('FE-MOB-RTEDIT-006: an imperial traveller types miles and kilometres are stored', () => {
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, distance_unit: 'imperial' } });
const { saveRoadtripLimit } = renderSheet();
expect(screen.getByTestId('rt-edit-range')).toHaveValue('373');
typeInto('rt-edit-range', '310');
expect(saveRoadtripLimit).toHaveBeenCalledWith('roadtrip_range_km', 499);
});
it('FE-MOB-RTEDIT-007: a range worked out from the battery is read-only here', () => {
renderSheet({
roadtrip_vehicle: 'electric',
roadtrip_battery_kwh: 77,
roadtrip_kwh_per_100: 18,
roadtrip_battery_degradation: 8,
});
expect(screen.getByTestId('rt-edit-range')).toBeDisabled();
expect(screen.getByTestId('rt-edit-range')).toHaveValue('394');
});
it('FE-MOB-RTEDIT-008: the vehicle is chosen in the form, none stored as empty', () => {
const { saveRoadtripLimit } = renderSheet();
fireEvent.click(screen.getByRole('tab', { name: 'Electric' }));
expect(saveRoadtripLimit).toHaveBeenCalledWith('roadtrip_vehicle', 'electric');
fireEvent.click(screen.getByRole('tab', { name: 'Either' }));
expect(saveRoadtripLimit).toHaveBeenCalledWith('roadtrip_vehicle', '');
});
it('FE-MOB-RTEDIT-009: the avoidance is one stored list that each switch adds to or takes from', () => {
const { saveRoadtripLimit } = renderSheet();
fireEvent.click(screen.getByRole('switch', { name: 'Ferries' }));
expect(saveRoadtripLimit).toHaveBeenLastCalledWith('roadtrip_avoid', 'toll,ferry');
fireEvent.click(screen.getByRole('switch', { name: 'Toll roads' }));
expect(saveRoadtripLimit).toHaveBeenLastCalledWith('roadtrip_avoid', '');
});
it('FE-MOB-RTEDIT-010: where the day ends is a choice between the route and a stop', () => {
const { saveRoadtripLimit } = renderSheet();
fireEvent.click(screen.getByRole('tab', { name: 'At the last place' }));
expect(saveRoadtripLimit).toHaveBeenCalledWith('roadtrip_day_end_mode', 'stop');
});
it('FE-MOB-RTEDIT-011: a reader without a save sees the figures, every field disabled', () => {
renderSheet(TYPED, 'split', { saveRoadtripLimit: undefined });
for (const id of ['rt-edit-leg', 'rt-edit-day', 'rt-edit-range', 'rt-edit-fill']) {
expect(screen.getByTestId(id)).toBeDisabled();
}
expect(screen.getByRole('switch', { name: 'Toll roads' })).toBeDisabled();
});
it('FE-MOB-RTEDIT-012: a decimal comma is read and rounded, junk switches the limit off', () => {
const { saveRoadtripLimit } = renderSheet();
typeInto('rt-edit-leg', '245,6');
expect(saveRoadtripLimit).toHaveBeenLastCalledWith('roadtrip_leg_minutes', 246);
typeInto('rt-edit-day', '.');
expect(saveRoadtripLimit).toHaveBeenLastCalledWith('roadtrip_day_minutes', 0);
});
});