mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
154c88b48a
A visit's End could be edited and was shown in road trip mode, but the schedule never read it: the drive always left a stop after its stay, so a traveller who knew when they had to leave but not how long they would stand there had no way to say so. The End is now the time the drive leaves. RoadtripStop.leaveAt is filled from the visit's End (or its place's) on the client by useRoadtripRoutes and on the server by roadtrip-plan.service, so calculate_roadtrip and get_roadtrip_context agree with the planner, and computeSchedule takes it as the departure it already knew how to hold. With Start and End both set the stay is the time between them. With daily travel times on, the stay is the time until then, and an End before the window start starts the day early the way a Start does. Before the first fixed time the chain works backwards through an End as it does through a pinned Start. An End is read against the drive: the last passing of that clock time counts as missed when the drive went past it on the road or earlier the same day, anything else is the next one, so a drive across midnight neither waits a whole day nor pretends to be on time. A drive that gets there after the End leaves on arrival and files a missedLeave finding, shown beside the late badge in the rail and on the phone. The rail's STAY badge, the inspector's Stay tile, the phone stop sheet and the phone chain show the stay the End makes, with an "until 14:00" hint. It is read off the schedule and never written to duration_minutes: the stay belongs to the place, the End to one visit, and a stored copy would go stale as soon as the arrival moved. For such a stop the stay dialog, desktop and phone, shows that stay read only and offers to remove the End, through the visit's time route with an offline path (assignmentRepo.setTimes, setAssignmentTimes). While the road trip is on, the End field in both place forms says the drive leaves at that time. The dead checkoutAt in the plan service is gone. New strings in all 23 locales.
325 lines
16 KiB
TypeScript
325 lines
16 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import MRtStaySheet from '../../../../src/mobile/screens/trip/roadtrip/MRtStaySheet'
|
|
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
|
import { useSettingsStore } from '../../../../src/store/settingsStore'
|
|
import { useAuthStore } from '../../../../src/store/authStore'
|
|
import { usePermissionsStore } from '../../../../src/store/permissionsStore'
|
|
import { useTripStore } from '../../../../src/store/tripStore'
|
|
import type { RoadtripDay } from '@trek/shared/roadtrip'
|
|
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
|
import { resetAllStores, seedStore } from '../../../helpers/store'
|
|
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
|
|
|
// FE-MOB-RTSTAY-001 to FE-MOB-RTSTAY-025
|
|
//
|
|
// The sheet renders inside the real TranslationProvider, so the copy is asserted
|
|
// in English.
|
|
|
|
/** Arrives late in the evening, so a long stay has to carry into the next day. */
|
|
const STAGE = {
|
|
dayId: 11,
|
|
dayNumber: 1,
|
|
date: '2026-05-01',
|
|
title: null,
|
|
stops: [
|
|
{ assignmentId: 101, ownerDayId: 11, ownerIndex: 0, placeId: 201, name: 'Hamburg Hafen', lat: 53.54, lng: 9.98, time: null, dwellMinutes: 30, legMode: null, incomingLegMode: null, stopType: null },
|
|
{ assignmentId: 102, ownerDayId: 11, ownerIndex: 1, placeId: 202, name: 'Bremen Marktplatz', lat: 53.07, lng: 8.8, time: null, dwellMinutes: 30, legMode: null, incomingLegMode: null, stopType: null },
|
|
],
|
|
legs: [undefined, undefined],
|
|
legVias: [[], []],
|
|
geometry: [],
|
|
distance: 0,
|
|
duration: 0,
|
|
dayWarning: null,
|
|
schedule: {
|
|
entries: [
|
|
{ arrival: '09:15', departure: '09:45', anchored: false, dayOffset: 0 },
|
|
{ arrival: '22:30', departure: '23:00', anchored: false, dayOffset: 0 },
|
|
],
|
|
warnings: [],
|
|
},
|
|
driveWarnings: [],
|
|
} as unknown as RoadtripDay
|
|
|
|
function makePlanner(overrides: Record<string, unknown> = {}) {
|
|
return buildPlanner({
|
|
tripId: 4,
|
|
selectedDayId: 11,
|
|
roadtripRoutes: { days: [STAGE] },
|
|
setRoadtripStay: vi.fn(async () => undefined),
|
|
...overrides,
|
|
} as unknown as Partial<TripPlanner>)
|
|
}
|
|
|
|
function makeShell(overrides: Record<string, unknown> = {}) {
|
|
return buildShell({
|
|
sheet: { id: 'rtstay', payload: { placeId: 202, minutes: 30, name: 'Bremen Marktplatz', dayId: 11, assignmentId: 102 } },
|
|
...overrides,
|
|
} as unknown as Partial<MTripShellApi>)
|
|
}
|
|
|
|
function renderSheet(plannerOverrides: Record<string, unknown> = {}, shellOverrides: Record<string, unknown> = {}) {
|
|
const planner = makePlanner(plannerOverrides)
|
|
const shell = makeShell(shellOverrides)
|
|
render(<MRtStaySheet planner={planner} shell={shell} />)
|
|
return { planner, shell }
|
|
}
|
|
|
|
const minus = () => screen.getByRole('button', { name: '5 minutes less' })
|
|
const plus = () => screen.getByRole('button', { name: '5 minutes more' })
|
|
const value = () => screen.getByText('Minutes').previousElementSibling
|
|
|
|
describe('MRtStaySheet', () => {
|
|
beforeEach(() => {
|
|
resetAllStores()
|
|
})
|
|
|
|
it('FE-MOB-RTSTAY-001: stays closed while another sheet id is active', () => {
|
|
renderSheet({}, { sheet: { id: 'rtstop', payload: { placeId: 202, minutes: 30 } } })
|
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-RTSTAY-002: opens on the stay the place carries, named and scoped', () => {
|
|
renderSheet()
|
|
expect(screen.getByRole('dialog', { name: 'Add a stay' })).toBeInTheDocument()
|
|
expect(screen.getByText('Bremen Marktplatz')).toBeInTheDocument()
|
|
expect(screen.getByText('The stay belongs to the place, so it counts on every day this stop is planned.')).toBeInTheDocument()
|
|
expect(value()).toHaveTextContent('30')
|
|
})
|
|
|
|
it('FE-MOB-RTSTAY-003: plus and minus move the value in five-minute steps', () => {
|
|
renderSheet()
|
|
fireEvent.click(plus())
|
|
expect(value()).toHaveTextContent('35')
|
|
fireEvent.click(plus())
|
|
expect(value()).toHaveTextContent('40')
|
|
fireEvent.click(minus())
|
|
expect(value()).toHaveTextContent('35')
|
|
})
|
|
|
|
it('FE-MOB-RTSTAY-004: minus stops at zero instead of going negative', () => {
|
|
renderSheet({}, { sheet: { id: 'rtstay', payload: { placeId: 202, minutes: 3, name: 'Bremen Marktplatz', dayId: 11, assignmentId: 102 } } })
|
|
fireEvent.click(minus())
|
|
expect(value()).toHaveTextContent('0')
|
|
expect(minus()).toBeDisabled()
|
|
})
|
|
|
|
it('FE-MOB-RTSTAY-005: plus stops at a full day', () => {
|
|
renderSheet({}, { sheet: { id: 'rtstay', payload: { placeId: 202, minutes: 1438, name: 'Bremen Marktplatz' } } })
|
|
fireEvent.click(plus())
|
|
expect(value()).toHaveTextContent('1440')
|
|
expect(plus()).toBeDisabled()
|
|
})
|
|
|
|
it('FE-MOB-RTSTAY-006: a preset sets the value outright rather than adding to it', () => {
|
|
renderSheet()
|
|
fireEvent.click(screen.getByRole('button', { name: '2 h' }))
|
|
expect(value()).toHaveTextContent('120')
|
|
fireEvent.click(screen.getByRole('button', { name: '45 min' }))
|
|
expect(value()).toHaveTextContent('45')
|
|
})
|
|
|
|
it('FE-MOB-RTSTAY-007: previews the new departure as arrival plus the stay', () => {
|
|
renderSheet({}, { sheet: { id: 'rtstay', payload: { placeId: 201, minutes: 30, name: 'Hamburg Hafen' } } })
|
|
expect(screen.getByText('09:15')).toBeInTheDocument()
|
|
expect(screen.getByText('09:45')).toBeInTheDocument()
|
|
fireEvent.click(screen.getByRole('button', { name: '1 h' }))
|
|
expect(screen.getByText('10:15')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-RTSTAY-008: carries the preview into the next day when the stay runs past midnight', () => {
|
|
renderSheet()
|
|
// Arrives 22:30; two hours of it land at 00:30 the following day.
|
|
fireEvent.click(screen.getByRole('button', { name: '2 h' }))
|
|
expect(screen.getByText('00:30')).toBeInTheDocument()
|
|
expect(screen.getByText('+1')).toBeInTheDocument()
|
|
expect(screen.getByText('Next day')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-RTSTAY-009: no carry mark while the departure stays on the same day', () => {
|
|
renderSheet()
|
|
fireEvent.click(screen.getByRole('button', { name: '30 min' }))
|
|
expect(screen.getByText('23:00')).toBeInTheDocument()
|
|
expect(screen.queryByText('+1')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-RTSTAY-010: reads the preview clocks in the 12h setting', () => {
|
|
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, time_format: '12h' } })
|
|
renderSheet({}, { sheet: { id: 'rtstay', payload: { placeId: 201, minutes: 30, name: 'Hamburg Hafen' } } })
|
|
expect(screen.getByText('9:15 AM')).toBeInTheDocument()
|
|
expect(screen.getByText('9:45 AM')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-RTSTAY-011: drops the preview when the stop is not on the stage', () => {
|
|
renderSheet({ selectedDayId: 99 })
|
|
expect(screen.queryByText('Arrive')).not.toBeInTheDocument()
|
|
expect(screen.queryByText('Leave')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-RTSTAY-012: saving writes the place and the minutes, then hands back to the stop', async () => {
|
|
const { planner, shell } = renderSheet()
|
|
fireEvent.click(plus())
|
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
|
await waitFor(() => expect(planner.setRoadtripStay).toHaveBeenCalledWith(202, 35))
|
|
// Addressed by day and assignment, which is how 'rtstop' locates a row. Spreading this
|
|
// sheet's own payload left it unable to resolve anything, so it drew nothing while still
|
|
// counting as open, and the day swipe stayed blocked behind it.
|
|
expect(shell.openSheet).toHaveBeenCalledWith('rtstop', { dayId: 11, assignmentId: 102 })
|
|
})
|
|
|
|
it('FE-MOB-RTSTAY-019: with no row to go back to it closes rather than opening a sheet that resolves to nothing', async () => {
|
|
// Every caller sends the row, but a payload without one must not leave `shell.sheet`
|
|
// pointing at a stop sheet that cannot locate anything: it would draw nothing and still
|
|
// count as open, and the day swipe reads exactly that to decide it is blocked.
|
|
const { planner, shell } = renderSheet({}, {
|
|
sheet: { id: 'rtstay', payload: { placeId: 202, minutes: 30, name: 'Bremen Marktplatz' } },
|
|
})
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
|
|
|
await waitFor(() => expect(planner.setRoadtripStay).toHaveBeenCalledWith(202, 30))
|
|
expect(shell.closeSheet).toHaveBeenCalled()
|
|
expect(shell.openSheet).not.toHaveBeenCalledWith('rtstop', expect.anything())
|
|
})
|
|
|
|
it('FE-MOB-RTSTAY-013: clearing the stay is a save of zero, not a second write path', async () => {
|
|
const { planner, shell } = renderSheet()
|
|
fireEvent.click(screen.getByRole('button', { name: 'No stay' }))
|
|
expect(planner.setRoadtripStay).not.toHaveBeenCalled()
|
|
expect(value()).toHaveTextContent('0')
|
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
|
await waitFor(() => expect(planner.setRoadtripStay).toHaveBeenCalledWith(202, 0))
|
|
expect(shell.openSheet).toHaveBeenCalledWith('rtstop', { dayId: 11, assignmentId: 102 })
|
|
})
|
|
|
|
it('FE-MOB-RTSTAY-014: closing without saving writes nothing and does not reopen the stop', () => {
|
|
const { planner, shell } = renderSheet()
|
|
fireEvent.click(plus())
|
|
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
|
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
|
|
expect(planner.setRoadtripStay).not.toHaveBeenCalled()
|
|
expect(shell.openSheet).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-MOB-RTSTAY-015: a failed write puts the stored value back and says so', async () => {
|
|
const { planner, shell } = renderSheet({
|
|
setRoadtripStay: vi.fn(async () => { throw new Error('offline') }),
|
|
})
|
|
fireEvent.click(screen.getByRole('button', { name: '2 h' }))
|
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
|
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('Unknown error'))
|
|
expect(value()).toHaveTextContent('30')
|
|
expect(shell.openSheet).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-MOB-RTSTAY-016: without place_edit there is nothing to set the stay with', () => {
|
|
renderSheet({ can: vi.fn(() => false) })
|
|
expect(minus()).toBeDisabled()
|
|
expect(plus()).toBeDisabled()
|
|
expect(screen.queryByRole('button', { name: '2 h' })).not.toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
|
|
expect(screen.getByRole('button', { name: 'No stay' })).toBeDisabled()
|
|
})
|
|
|
|
it('FE-MOB-RTSTAY-017: a stop that carries no stay yet opens on zero', () => {
|
|
renderSheet({}, { sheet: { id: 'rtstay', payload: { placeId: 202, name: 'Bremen Marktplatz' } } })
|
|
expect(value()).toHaveTextContent('0')
|
|
expect(minus()).toBeDisabled()
|
|
})
|
|
|
|
it('FE-MOB-RTSTAY-018: a place the stage does not stop at gets no preview either', () => {
|
|
renderSheet({}, { sheet: { id: 'rtstay', payload: { placeId: 909, minutes: 30, name: 'Elsewhere' } } })
|
|
expect(screen.getByRole('dialog', { name: 'Add a stay' })).toBeInTheDocument()
|
|
expect(screen.queryByText('Arrive')).not.toBeInTheDocument()
|
|
})
|
|
|
|
describe('a stop left at a set time', () => {
|
|
// Bremen's End is 23:45, so the stay is whatever the arrival at 22:30 leaves of it,
|
|
// and there is nothing for the buttons to set.
|
|
const LEAVING = {
|
|
...STAGE,
|
|
stops: [STAGE.stops[0], { ...STAGE.stops[1], leaveAt: '23:45' }],
|
|
schedule: {
|
|
entries: [STAGE.schedule.entries[0], { arrival: '22:30', departure: '23:45', anchored: false, dayOffset: 0 }],
|
|
warnings: [],
|
|
},
|
|
} as unknown as RoadtripDay
|
|
let setAssignmentTimes: ReturnType<typeof vi.fn>
|
|
|
|
beforeEach(() => {
|
|
setAssignmentTimes = vi.fn(async () => undefined)
|
|
seedStore(useAuthStore, { user: { id: 1, role: 'user' } })
|
|
seedStore(useTripStore, {
|
|
trip: { id: 4, user_id: 1 },
|
|
assignments: { '11': [{ id: 102, day_id: 11, place_id: 202, order_index: 1, assignment_time: '22:00', assignment_end_time: '23:45' }] },
|
|
})
|
|
useTripStore.setState({ setAssignmentTimes, refreshDays: vi.fn(async () => undefined) } as never)
|
|
})
|
|
|
|
it('FE-MOB-RTSTAY-020: says when the drive leaves and what stay that makes, with nothing to set', () => {
|
|
renderSheet({ roadtripRoutes: { days: [LEAVING] } })
|
|
// Named for what it holds, as the desktop dialog is: there is no stay to add here.
|
|
expect(screen.getByRole('dialog', { name: 'Time at this stop' })).toBeInTheDocument()
|
|
expect(screen.getByText('Time at this stop')).toBeInTheDocument()
|
|
expect(screen.queryByText('Add a stay')).not.toBeInTheDocument()
|
|
expect(screen.getByText('1 h 15 min')).toBeInTheDocument()
|
|
expect(screen.getByText('This visit has an end time, so the drive leaves at 23:45.')).toBeInTheDocument()
|
|
expect(screen.getByText('22:30')).toBeInTheDocument()
|
|
expect(screen.getByText('23:45')).toBeInTheDocument()
|
|
expect(screen.queryByRole('button', { name: '5 minutes more' })).not.toBeInTheDocument()
|
|
expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-RTSTAY-021: taking the end time off keeps the Start and writes through the visit', async () => {
|
|
const { planner } = renderSheet({ roadtripRoutes: { days: [LEAVING] } })
|
|
fireEvent.click(screen.getByRole('button', { name: 'Remove end time' }))
|
|
await waitFor(() => expect(setAssignmentTimes).toHaveBeenCalledWith(4, 11, 102, { place_time: '22:00', end_time: null }))
|
|
// The ordinary sheet is back, on the stay the place carries.
|
|
await waitFor(() => expect(value()).toHaveTextContent('30'))
|
|
expect(planner.setRoadtripStay).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-MOB-RTSTAY-022: a traveller who may not change the day is not offered the removal', () => {
|
|
usePermissionsStore.setState({ permissions: { day_edit: 'trip_owner' } })
|
|
seedStore(useTripStore, { trip: { id: 4, user_id: 2 } })
|
|
renderSheet({ roadtripRoutes: { days: [LEAVING] } })
|
|
expect(screen.getByText('1 h 15 min')).toBeInTheDocument()
|
|
expect(screen.queryByRole('button', { name: 'Remove end time' })).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-RTSTAY-024: reached after the time, it says so instead of promising it', () => {
|
|
const late = {
|
|
...LEAVING,
|
|
schedule: {
|
|
entries: [STAGE.schedule.entries[0], { arrival: '23:55', departure: '23:55', anchored: false, dayOffset: 0 }],
|
|
warnings: [{ index: 1, code: 'missedLeave', minutes: 10 }],
|
|
},
|
|
} as unknown as RoadtripDay
|
|
renderSheet({ roadtripRoutes: { days: [late] } })
|
|
expect(screen.getByText('Arrives 10 min after the time you set to leave')).toBeInTheDocument()
|
|
expect(screen.queryByText('This visit has an end time, so the drive leaves at 23:45.')).not.toBeInTheDocument()
|
|
expect(screen.getAllByText('23:55')).toHaveLength(2)
|
|
})
|
|
|
|
it('FE-MOB-RTSTAY-025: travel hours over before the time say so, with no departure beside it', () => {
|
|
const closed = {
|
|
...LEAVING,
|
|
schedule: {
|
|
entries: [STAGE.schedule.entries[0], { arrival: '17:30', departure: '18:00', anchored: false, dayOffset: 0 }],
|
|
warnings: [],
|
|
},
|
|
} as unknown as RoadtripDay
|
|
renderSheet({ roadtripRoutes: { days: [closed] } })
|
|
expect(screen.getByText('The travel day ends before 23:45, so the drive goes on the next morning.')).toBeInTheDocument()
|
|
expect(screen.queryByText('18:00')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-RTSTAY-023: the close in the header writes nothing', () => {
|
|
const { shell } = renderSheet({ roadtripRoutes: { days: [LEAVING] } })
|
|
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
|
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
|
|
expect(setAssignmentTimes).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
})
|