diff --git a/client/src/mobile/screens/trip/roadtrip/MRtStopSheet.tsx b/client/src/mobile/screens/trip/roadtrip/MRtStopSheet.tsx index d25211a33..ea0dcbba2 100644 --- a/client/src/mobile/screens/trip/roadtrip/MRtStopSheet.tsx +++ b/client/src/mobile/screens/trip/roadtrip/MRtStopSheet.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState, type ReactNode, type Ref } from 'react' import { - AlertTriangle, Clock, Footprints, Fuel, Hourglass, MapPin, Moon, Navigation, X, + AlertTriangle, Clock, Footprints, Fuel, Hourglass, MapPin, Moon, Navigation, Pencil, X, } from 'lucide-react' import MSheet from '../../../components/MSheet' import MIconBtn from '../../../components/MIconBtn' @@ -9,6 +9,7 @@ import MToggle from '../../../components/MToggle' import type { MTripSheetsProps } from '../MTripShell' import { useTranslation } from '../../../../i18n' import { useSettingsStore } from '../../../../store/settingsStore' +import { useTripStore } from '../../../../store/tripStore' import ChargingInfo from '../../../../components/Roadtrip/ChargingInfo' import { formatDurationShort } from '../../../../components/Roadtrip/roadtripModel' import { roadtripRows, stageOf, type StopRow } from '../../../../components/Roadtrip/roadtripRowModel' @@ -34,6 +35,13 @@ import type { RoadtripDay, ScheduleWarning } from '@trek/shared/roadtrip' * confirmation, simply absent, because a dead control on a phone is a promise the screen * cannot keep. What is left are the three things a passenger does decide on the road: * navigate there, stay longer, stop for the night here. + * + * Editing the place is the one way out of that rule, and it is a hand-over rather than a + * control of this sheet: the pencil opens the place editor the plan tab opens, with this + * very visit in context, and this sheet closes behind it. The time somebody fixed for a + * stop is that editor's start time (the chain anchors on the visit's own start time), so + * a time control here would be a second writer for one number. A booked night can also + * be held by its check-in, which belongs to the booking and not to the place. */ interface RtStopSheetPayload { @@ -132,6 +140,7 @@ export default function MRtStopSheet({ planner, shell }: MTripSheetsProps) { const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' const unitSetting = useSettingsStore(s => s.settings.distance_unit) const unit: DistanceUnit = unitSetting === 'imperial' ? 'imperial' : 'metric' + const tripPlaces = useTripStore(s => s.places) const days = planner.roadtripRoutes.days const live = useMemo( @@ -155,7 +164,10 @@ export default function MRtStopSheet({ planner, shell }: MTripSheetsProps) { useEffect(() => { setPending(null) }, [payload.assignmentId]) const stop = located?.row.stop ?? null - const place = planner.places.find(p => p.id === stop?.placeId) ?? null + // From the whole trip store, not planner.places: with service stops hidden from the day + // lists the phone filters them out of that list, while the stage still draws them, and + // both the pencil and the directions have to reach the row the chain drew. + const place = tripPlaces.find(p => p.id === stop?.placeId) ?? null /** * Addressed by stop, not by selection. @@ -210,11 +222,22 @@ export default function MRtStopSheet({ planner, shell }: MTripSheetsProps) { } const stayMinutes = stop.dwellMinutes - const canEditStay = planner.can('place_edit', planner.trip) + const canEditPlace = planner.can('place_edit', planner.trip) const editStay = () => { shell.openSheet('rtstay', { placeId: stop.placeId, minutes: stayMinutes }) } + // A negative id is the optimistic row of a stop still being saved. The editor would + // write the time against an id the server never issued, and handing it null instead is + // no way round that: the planner then falls back to the place's only visit, which can be + // that same row. So the pencil waits until the stop carries the id the server gave it. + const canOpenEditor = canEditPlace && place != null && stop.assignmentId > 0 + const editPlace = () => { + if (!place) return + planner.openPlaceEditor(place, stop.assignmentId) + shell.closeSheet() + } + const toggleEndDay = async (next: boolean) => { if (!canEndDay || saving) return setPending({ from: endDayTruth, to: next }) @@ -239,9 +262,12 @@ export default function MRtStopSheet({ planner, shell }: MTripSheetsProps) { return ( - {/* ── Header: the disc the chain draws, the name, the way out ── */} + {/* ── Header: the disc the chain draws, the name, the way to the editor and the way out ── */}
-
+ {/* Centred, not top-aligned: one line of the name is shorter than the 34px disc, so + hung from the top it sat high beside it. A name that wraps to its two lines grows + the row, and the disc and the buttons centre on both, as they do in the chain row. */} +
{row.service ? ( kind && KindIcon ? ( {stop.name} - - - +
+ {canOpenEditor && ( + + + + )} + + + +
@@ -347,7 +380,7 @@ export default function MRtStopSheet({ planner, shell }: MTripSheetsProps) { icon={} label={t('roadtrip.stop.stayShort')} value={stayMinutes ? formatDurationShort(stayMinutes * 60) : t('roadtrip.stay.none')} - onClick={canEditStay ? editStay : undefined} + onClick={canEditPlace ? editStay : undefined} />
{navOpen && ( diff --git a/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx b/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx index a9027dbb8..e0c671950 100644 --- a/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx @@ -14,7 +14,7 @@ import PlPlaceSearch, { type PlSearchPick } from './PlPlaceSearch' import PlCategoryPicker from './PlCategoryPicker' import PlTimeFields from './PlTimeFields' import PlFileAttach from './PlFileAttach' -import type { Place } from '../../../../types' +import type { Assignment, AssignmentsMap, Place } from '../../../../types' import type { TripPlanner } from '../MTripShell' import { useLocationBias } from '../../../../hooks/useLocationBias' @@ -48,6 +48,21 @@ function findDuplicateName( return null } +/** + * The visit the editor was opened on, looked up in the planner's STORED list. + * + * Not planner.assignments: the phone filters booked nights (always) and service stops + * (while the day lists hide them) out of that one, and the road trip stop sheet opens + * this editor on exactly those visits. Looked up there the visit was not found, Start + * prefilled from the pool place, and a plain save wrote that over the time the stop was + * pinned to; the day note was dropped the same way. Every visit the plan tab shows is in + * the stored list as well, so its entry points resolve exactly as they did. + */ +function findVisit(stored: AssignmentsMap, assignmentId: number | null): Assignment | null { + if (!assignmentId) return null + return Object.values(stored).flat().find(a => a.id === assignmentId) ?? null +} + /** * Add/edit place sheet — the mobile counterpart of PlaceFormModal, driven by * the planner's own editor flags (showPlaceForm / editingPlace / prefillCoords / @@ -58,7 +73,7 @@ function findDuplicateName( */ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSheetProps) { const { - t, toast, places, assignments, canUploadFiles, + t, toast, places, assignments, storedAssignments, canUploadFiles, showPlaceForm, setShowPlaceForm, editingPlace, setEditingPlace, prefillCoords, setPrefillCoords, @@ -84,11 +99,23 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh const [sheetPlace, setSheetPlace] = useState(null) const [sheetAssignmentId, setSheetAssignmentId] = useState(null) - const dayAssignments = useMemo( - () => (sheetPlace ? Object.values(assignments).flat() : []), - [sheetPlace, assignments], + // Live rather than the open-time snapshot, so the note's dirty check compares against + // the note as it stands when Save is tapped. + const ctxAssignment = useMemo( + () => (sheetPlace ? findVisit(storedAssignments, sheetAssignmentId) : null), + [sheetPlace, storedAssignments, sheetAssignmentId], ) + // The overlap warning keeps to what the day lists show: on the plan tab a clash with a + // pump or a booked night the list does not draw would name a stop nobody can see there. + // The visit under edit joins when the lists hide it, because the warning reads the day + // to compare against off that very row. + const dayAssignments = useMemo(() => { + if (!sheetPlace) return [] + const listed = Object.values(assignments).flat() + return ctxAssignment && !listed.some(a => a.id === ctxAssignment.id) ? [...listed, ctxAssignment] : listed + }, [sheetPlace, assignments, ctxAssignment]) + // Prefill on open — same source order as the desktop form: editing place // (times off the in-context assignment), map/POI prefill coords, blank. useEffect(() => { @@ -96,9 +123,7 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh setSheetPlace(editingPlace) setSheetAssignmentId(editingAssignmentId) if (editingPlace) { - const assignment = editingAssignmentId - ? Object.values(assignments).flat().find(a => a.id === editingAssignmentId) - : null + const assignment = findVisit(storedAssignments, editingAssignmentId) const timeSource = assignment?.place ?? editingPlace setForm({ name: editingPlace.name || '', @@ -143,7 +168,7 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh setPendingFiles([]) setDuplicateWarning(null) setDeleteArmed(false) - // assignments is a fresh map each load — read at open time only. + // storedAssignments is a fresh map each load, so it is read at open time only. // eslint-disable-next-line react-hooks/exhaustive-deps }, [showPlaceForm, editingPlace, prefillCoords, editingAssignmentId]) @@ -236,7 +261,6 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh // #2163: the per-assignment note only travels when an assignment is in // context AND the value actually changed — same dirty-check as the // desktop form, so an untouched note never produces a PUT. - const ctxAssignment = sheetAssignmentId ? dayAssignments.find(a => a.id === sheetAssignmentId) : null if (!ctxAssignment || (form.assignment_notes ?? '') === (ctxAssignment.notes ?? '')) { delete payload.assignment_notes } diff --git a/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx index 3f8c892d1..914a0fa8d 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx +++ b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx @@ -1,5 +1,5 @@ import { roadtripPreferencesRepo } from '../../repo/roadtripPreferencesRepo' -// FE-TP-ROAD-001 to FE-TP-ROAD-096 +// FE-TP-ROAD-001 to FE-TP-ROAD-097 import React from 'react' import { renderHook, act, waitFor } from '@testing-library/react' import { TranslationProvider } from '../../i18n/TranslationContext' @@ -2088,4 +2088,39 @@ describe('useTripPlanner road trip: the phone feed', () => { expect(result.current.showPlaceForm).toBe(true) expect(result.current.placeFormDayId).toBe(5) }) + + it('FE-TP-ROAD-097: a service stop opened for editing on the phone gets the place form with its own visit', async () => { + // The stop sheet's pencil relies on this. The desk popup it would otherwise open has + // no start time, and for a service stop the start time is the only way to pin or + // unpin its arrival (a booked night can also be held by the booking's check-in). + const fuelStop = buildPlace({ id: 102, name: 'Tankstelle', stop_type: 'fuel', lat: 60.39, lng: 5.32 }) + seedTrip({ + places: [fuelStop], + days: [buildDay({ id: 5, day_number: 1 })], + assignments: { '5': [buildAssignment({ id: 12, day_id: 5, order_index: 0, place: fuelStop })] }, + }) + // The STORED mode is on, set here rather than trusted to the suite's beforeEach: with + // it off the form branch would be taken anyway and this test would prove nothing. + sessionStorage.setItem('trip-roadtrip-42', '1') + sessionStorage.setItem('trip-tab-42', 'roadtrip') + + const phone = await renderPhone() + act(() => { phone.result.current.openPlaceEditor(fuelStop, 12) }) + + expect(phone.result.current.roadtripMode).toBe(false) + expect(phone.result.current.stopDraft).toBeNull() + expect(phone.result.current.showPlaceForm).toBe(true) + expect(phone.result.current.editingPlace?.id).toBe(102) + expect(phone.result.current.editingAssignmentId).toBe(12) + phone.unmount() + + // The very same trip at desk width opens the stop popup, so what kept the phone on + // the form is the width alone. + Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: desktopWidth }) + const desk = await renderRoadtrip() + act(() => { desk.result.current.openPlaceEditor(fuelStop, 12) }) + + expect(desk.result.current.stopDraft).toMatchObject({ dayId: 5, editing: { placeId: 102, stopType: 'fuel' } }) + expect(desk.result.current.showPlaceForm).toBe(false) + }) }) diff --git a/client/tests/helpers/mobileTrip.ts b/client/tests/helpers/mobileTrip.ts index a5723f1e7..2e903cb0c 100644 --- a/client/tests/helpers/mobileTrip.ts +++ b/client/tests/helpers/mobileTrip.ts @@ -58,6 +58,9 @@ export function buildPlanner(overrides: Partial = {}): TripPlanner days: [], places: [], assignments: [], + // The unfiltered map the store holds. The place editor looks the visit it was + // opened on up here, so every sheet test that edits a place needs the shape. + storedAssignments: {}, packingItems: [], todoItems: [], categories: [], diff --git a/client/tests/unit/mobile/trip/MPlaceEditSheet.test.tsx b/client/tests/unit/mobile/trip/MPlaceEditSheet.test.tsx index 0119b0671..120ed48bc 100644 --- a/client/tests/unit/mobile/trip/MPlaceEditSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MPlaceEditSheet.test.tsx @@ -10,7 +10,7 @@ import { server } from '../../../helpers/msw/server' import { resetAllStores, seedStore } from '../../../helpers/store' import { fireEvent, render, screen, waitFor } from '../../../helpers/render' -// FE-MOB-PLEDIT-001 to FE-MOB-PLEDIT-024, plus FE-MOB-PLEDIT-033 to -035 +// FE-MOB-PLEDIT-001 to FE-MOB-PLEDIT-040, plus the 009b, 025b and 029b variants // planner.t echoes the key, so every label/placeholder is asserted as its key. const CATEGORIES = [ @@ -93,7 +93,9 @@ describe('MPlaceEditSheet', () => { id: 7, day_id: 2, place_id: 42, order_index: 0, place: { ...EDITED, place_time: '14:00', end_time: '15:00' }, } as unknown as Assignment - setup({ editingPlace: EDITED, editingAssignmentId: 7, assignments: { '2': [assignment] } }) + // A visit the plan tab lists is in both of the planner's lists. + const visits = { '2': [assignment] } + setup({ editingPlace: EDITED, editingAssignmentId: 7, assignments: visits, storedAssignments: visits }) expect(screen.getByText('places.startTime')).toBeInTheDocument() const times = screen.getAllByDisplayValue(/^1[45]:00$/) expect(times.map(el => (el as HTMLInputElement).value)).toEqual(['14:00', '15:00']) @@ -293,7 +295,8 @@ describe('MPlaceEditSheet', () => { it('FE-MOB-PLEDIT-024: blocks the save while the end time is not after the start', () => { const assignment = { id: 7, day_id: 2, place_id: 42, order_index: 0, place: EDITED } as unknown as Assignment - setup({ editingPlace: EDITED, editingAssignmentId: 7, assignments: { '2': [assignment] } }) + const visits = { '2': [assignment] } + setup({ editingPlace: EDITED, editingAssignmentId: 7, assignments: visits, storedAssignments: visits }) const [start, end] = screen.getAllByDisplayValue(/^0[89]:[0-3]0$/) as HTMLInputElement[] expect(submit()).not.toBeDisabled() fireEvent.change(end, { target: { value: '07:00' } }) @@ -466,4 +469,78 @@ describe('MPlaceEditSheet', () => { expect(onOpenExpense).not.toHaveBeenCalled() }) }) + + // ── A visit the day lists hide (the road trip stop sheet opens the editor on them) ── + + describe('a visit the day lists hide', () => { + /** + * A booked night: the phone's filtered `assignments` never carries it, the stored + * map does. Its own times differ from the pool place's (08:00 to 09:30), so a + * lookup in the wrong list shows up as the wrong clock. + */ + const NIGHT = { + id: 7, day_id: 2, place_id: 42, order_index: 0, accommodation_id: 9, notes: 'Late check-in', + place: { ...EDITED, place_time: '10:00', end_time: '11:00' }, + } as unknown as Assignment + + const openOnNight = (overrides: Partial = {}) => setup({ + editingPlace: EDITED, + editingAssignmentId: 7, + assignments: {}, + storedAssignments: { '2': [NIGHT] }, + ...overrides, + }) + + it('FE-MOB-PLEDIT-036: reads the times and the day note off the stored visit', () => { + openOnNight() + const times = screen.getAllByDisplayValue(/^1[01]:00$/) as HTMLInputElement[] + expect(times.map(el => el.value)).toEqual(['10:00', '11:00']) + expect(screen.getByPlaceholderText('places.assignmentNotesPlaceholder')).toHaveValue('Late check-in') + }) + + it('FE-MOB-PLEDIT-037: saving it untouched keeps the pinned time and sends no note', async () => { + const { planner } = openOnNight() + fireEvent.click(submit()) + await waitFor(() => expect(planner.handleSavePlace).toHaveBeenCalledTimes(1)) + const payload = vi.mocked(planner.handleSavePlace).mock.calls[0][0] + expect(payload).toMatchObject({ place_time: '10:00', end_time: '11:00' }) + expect(payload).not.toHaveProperty('assignment_notes') + }) + + it('FE-MOB-PLEDIT-038: an edited day note on it travels with the save', async () => { + const { planner } = openOnNight() + fireEvent.change(screen.getByPlaceholderText('places.assignmentNotesPlaceholder'), { target: { value: 'Key box at the door' } }) + fireEvent.click(submit()) + await waitFor(() => expect(planner.handleSavePlace).toHaveBeenCalledTimes(1)) + expect(planner.handleSavePlace).toHaveBeenCalledWith(expect.objectContaining({ assignment_notes: 'Key box at the door' })) + }) + + it('FE-MOB-PLEDIT-039: clearing Start on it sends the empty time that unpins the stop', async () => { + const { planner } = openOnNight() + const [start] = screen.getAllByDisplayValue('10:00') + fireEvent.change(start, { target: { value: '' } }) + fireEvent.click(submit()) + await waitFor(() => expect(planner.handleSavePlace).toHaveBeenCalledTimes(1)) + expect(planner.handleSavePlace).toHaveBeenCalledWith(expect.objectContaining({ place_time: '', end_time: '11:00' })) + }) + + it('FE-MOB-PLEDIT-040: the overlap warning names what the day lists show, never a hidden stop', () => { + const listed = { + id: 8, day_id: 2, place_id: 50, order_index: 1, + place: { id: 50, name: 'Kaminarimon', place_time: '10:30', end_time: '11:30' }, + } as unknown as Assignment + const hiddenPump = { + id: 9, day_id: 2, place_id: 51, order_index: 2, + place: { id: 51, name: 'ENEOS Asakusa', stop_type: 'fuel', place_time: '10:15', end_time: '10:45' }, + } as unknown as Assignment + openOnNight({ + assignments: { '2': [listed] }, + storedAssignments: { '2': [NIGHT, listed, hiddenPump] }, + }) + // The night itself is hidden too, and still gets warned: the warning reads its day off it. + const warning = screen.getByText(/places\.timeCollision/) + expect(warning).toHaveTextContent('Kaminarimon') + expect(warning).not.toHaveTextContent('ENEOS') + }) + }) }) diff --git a/client/tests/unit/mobile/trip/MRtStopSheet.test.tsx b/client/tests/unit/mobile/trip/MRtStopSheet.test.tsx index f423d6807..799b1b0fe 100644 --- a/client/tests/unit/mobile/trip/MRtStopSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MRtStopSheet.test.tsx @@ -4,13 +4,17 @@ import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/ import type { Place } from '../../../../src/types' import type { RoadtripDay, RoadtripStop } from '@trek/shared/roadtrip' import { buildPlanner, buildShell } from '../../../helpers/mobileTrip' -import { resetAllStores } from '../../../helpers/store' +import { useTripStore } from '../../../../src/store/tripStore' +import { resetAllStores, seedStore } from '../../../helpers/store' import { fireEvent, render, screen, waitFor } from '../../../helpers/render' -// FE-MOB-RTSTOP-001 to FE-MOB-RTSTOP-027 +// FE-MOB-RTSTOP-001 to FE-MOB-RTSTOP-034 // // The sheet renders inside the real TranslationProvider, so the copy is asserted // in English. `planner.t` from the fixture is never consulted here. +// +// The place rows come from the trip store, not from the planner: the sheet reads the +// unfiltered list, so renderSheet seeds the store and the planner keeps its empty one. // The charging panel polls a repo of its own every minute; stubbed so the sheet's // wiring can be asserted without a live availability lookup in the background. @@ -93,7 +97,6 @@ const PLACES = [ function makePlanner(overrides: Record = {}) { return buildPlanner({ tripId: 4, - places: PLACES, dailyTimesActive: true, setRoadtripEndDay: vi.fn(async () => undefined), roadtripRoutes: { days: [DAY_A, DAY_B] }, @@ -108,7 +111,12 @@ function makeShell(overrides: Record = {}) { } as unknown as Partial) } -function renderSheet(plannerOverrides: Record = {}, shellOverrides: Record = {}) { +function renderSheet( + plannerOverrides: Record = {}, + shellOverrides: Record = {}, + storePlaces: Place[] = PLACES, +) { + seedStore(useTripStore, { places: storePlaces }) const planner = makePlanner(plannerOverrides) const shell = makeShell(shellOverrides) render() @@ -183,7 +191,7 @@ describe('MRtStopSheet', () => { }) it('FE-MOB-RTSTOP-009: offers the map apps built from the stop itself when no place row matches', () => { - renderSheet({ places: [] }) + renderSheet({}, {}, []) fireEvent.click(screen.getByRole('button', { name: 'Navigation' })) expect(screen.getByRole('menu')).toBeInTheDocument() expect(screen.getByRole('menuitem', { name: 'Google Maps' })).toBeInTheDocument() @@ -192,9 +200,11 @@ describe('MRtStopSheet', () => { it('FE-MOB-RTSTOP-010: has no delete, no reorder and no stop-kind control at all', () => { renderSheet() - // Close, navigation, stay, show-on-map, plus the end-day switch, which is a switch. - expect(screen.getAllByRole('button')).toHaveLength(4) + // Close, edit, navigation, stay, show-on-map, plus the end-day switch, which is a switch. + // Edit is a hand-over to the place editor, not a control of this sheet (RTSTOP-030). + expect(screen.getAllByRole('button')).toHaveLength(5) expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Navigation' })).toBeInTheDocument() expect(screen.getByRole('button', { name: /^Stay/ })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Show on map' })).toBeInTheDocument() @@ -310,7 +320,7 @@ describe('MRtStopSheet', () => { it('FE-MOB-RTSTOP-022: one map app means no picker, the tap opens it and names it on the tile', () => { const open = vi.spyOn(window, 'open').mockReturnValue(null) // Name only: no coordinates and no address, so only the OSM search link survives. - renderSheet({ places: [{ id: 203, name: 'Bremen Marktplatz', lat: null, lng: null, address: null }] }) + renderSheet({}, {}, [{ id: 203, name: 'Bremen Marktplatz', lat: null, lng: null, address: null }] as unknown as Place[]) const tile = screen.getByRole('button', { name: /^Navigation/ }) expect(tile).toHaveTextContent('OpenStreetMap') fireEvent.click(tile) @@ -324,7 +334,7 @@ describe('MRtStopSheet', () => { }) it('FE-MOB-RTSTOP-023: no reachable map app at all drops the tile instead of leaving a dead one', () => { - renderSheet({ places: [{ id: 203, name: '', lat: null, lng: null, address: null }] }) + renderSheet({}, {}, [{ id: 203, name: '', lat: null, lng: null, address: null }] as unknown as Place[]) expect(screen.queryByRole('button', { name: /Navigation/ })).not.toBeInTheDocument() expect(screen.getByRole('button', { name: /^Stay/ })).toBeInTheDocument() }) @@ -358,4 +368,90 @@ describe('MRtStopSheet', () => { renderSheet({}, { sheet: { id: 'rtstop', payload: { dayId: 11, assignmentId: 102 } } }) expect(screen.queryByTestId('charging-info')).not.toBeInTheDocument() }) + + // jsdom has no layout, so the two header tests pin the class contract rather than pixels. + it('FE-MOB-RTSTOP-028: the header centres the name on the disc instead of hanging it from the top', () => { + renderSheet() + const heading = screen.getByRole('heading', { name: 'Bremen Marktplatz' }) + const header = heading.parentElement + expect(header).toHaveClass('items-center') + expect(header).not.toHaveClass('items-start') + // Disc, name and buttons are one row, so they share that one cross-axis alignment. + expect(heading.previousElementSibling).toHaveTextContent('2') + const buttons = heading.nextElementSibling + expect(buttons).toHaveClass('items-center') + expect(buttons).toContainElement(screen.getByRole('button', { name: 'Close' })) + expect(buttons).toContainElement(screen.getByRole('button', { name: 'Edit' })) + }) + + it('FE-MOB-RTSTOP-029: a service stop with a long name centres the same way and wraps rather than truncating', () => { + const longName = 'Aral Tankstelle an der Autobahnausfahrt Hamburg Dammtor Nord' + renderSheet( + { roadtripRoutes: { days: [{ ...DAY_A, stops: [HAMBURG, { ...ARAL, name: longName }, BREMEN] }, DAY_B] } }, + { sheet: { id: 'rtstop', payload: { dayId: 11, assignmentId: 102 } } }, + ) + const heading = screen.getByRole('heading', { name: longName }) + expect(heading.parentElement).toHaveClass('items-center') + expect(heading).toHaveClass('line-clamp-2') + expect(heading).not.toHaveClass('truncate') + expect(heading.previousElementSibling?.querySelector('svg')).not.toBeNull() + }) + + it('FE-MOB-RTSTOP-030: the pencil opens the place editor on this very visit and closes the sheet', () => { + const { planner, shell } = renderSheet() + const edit = screen.getByRole('button', { name: 'Edit' }) + // Before the way out, so the close button keeps the corner it has on every sheet. + expect(edit.compareDocumentPosition(screen.getByRole('button', { name: 'Close' })) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + fireEvent.click(edit) + expect(planner.openPlaceEditor).toHaveBeenCalledTimes(1) + expect(planner.openPlaceEditor).toHaveBeenCalledWith(expect.objectContaining({ id: 203, address: 'Am Markt' }), 103) + expect(shell.closeSheet).toHaveBeenCalledTimes(1) + // The stop sheet does not hand over to a stay or a map on the way. + expect(shell.openSheet).not.toHaveBeenCalled() + }) + + it('FE-MOB-RTSTOP-031: somebody who may not edit places gets no pencil at all', () => { + // Every right but place_edit, so it is that one right the pencil answers to. + const { planner } = renderSheet({ can: vi.fn((action: string) => action !== 'place_edit') }) + // Absent rather than disabled, the same rule as every other write on this sheet. + expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument() + // The night switch answers to day_edit and stays, so no other right took the pencil away. + expect(screen.getByRole('switch', { name: 'End the day here' })).toBeInTheDocument() + expect(planner.can).toHaveBeenCalledWith('place_edit', planner.trip) + }) + + it('FE-MOB-RTSTOP-032: a pump hidden from the day lists is still editable from the stage', () => { + // The planner's filtered list lacks the pump, the store still holds it. + const pump = { id: 202, name: 'Aral Dammtor', lat: 53.56, lng: 9.99, stop_type: 'fuel' } as unknown as Place + const { planner } = renderSheet( + { places: PLACES }, + { sheet: { id: 'rtstop', payload: { dayId: 11, assignmentId: 102 } } }, + [...PLACES, pump], + ) + fireEvent.click(screen.getByRole('button', { name: 'Edit' })) + expect(planner.openPlaceEditor).toHaveBeenCalledWith(expect.objectContaining({ id: 202 }), 102) + }) + + it('FE-MOB-RTSTOP-033: a stop still being saved under its optimistic id offers no pencil yet', () => { + const saving = stop({ assignmentId: -5, placeId: 203, name: 'Bremen Marktplatz', ownerIndex: 0, dwellMinutes: 90 }) + const day = { + ...DAY_A, + stops: [saving], + schedule: { entries: [{ arrival: '13:05', departure: '14:35', anchored: false, dayOffset: 0 }], warnings: [] }, + driveWarnings: [], + } as unknown as RoadtripDay + const { planner } = renderSheet({ roadtripRoutes: { days: [day] } }, { sheet: { id: 'rtstop', payload: { dayId: 11, assignmentId: -5 } } }) + expect(screen.getByRole('dialog', { name: 'Bremen Marktplatz' })).toBeInTheDocument() + // Neither the temporary id nor a null the planner would resolve back to that same row. + expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument() + expect(planner.openPlaceEditor).not.toHaveBeenCalled() + }) + + it('FE-MOB-RTSTOP-034: a stop whose place row is not in the trip store offers no pencil', () => { + // The editor needs the whole row; the stop's name and position are enough for directions only. + renderSheet({}, {}, []) + expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Navigation' })).toBeInTheDocument() + }) })