mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(roadtrip): the drive between two days can take another way too
The drive from where one day ended to where the next begins was the one stretch of a connected road trip that offered no other ways. The band above a card's first stop had no button on the desk, and the phone did not show that drive at all. A via behind a day's last stop, where the map already files a point dropped on that drive, was deleted when the day was reordered under Days, and was left on no stop when that last stop was removed or dragged up the day. Switching the avoided road classes or the day's profile kept the old road and minutes on it until a reload. The drive in now opens the same picker as any leg, on the desk band and on a new row at the head of the phone's chain that names where it leaves. A choice is proven with the seam's own router and written behind the last stop of the day before, also when a day with a single stop lies between. The rule for that via lives once in shared (carriedSeam): it stays with the last stop while that stop stays last, and is read like any other once it is not, for the planner's drags and removals as for the server's sorts and seated nights. The cached answer for a seam now records its mode and avoided classes, so changing either asks for it again.
This commit is contained in:
@@ -32,7 +32,7 @@ const overlay = (over: Partial<AlternativeOverlay> = {}): AlternativeOverlay =>
|
||||
...over,
|
||||
})
|
||||
|
||||
const leg = (over: Partial<LegAlternatives> = {}): LegAlternatives => openLeg({ dayId: 4, index: 1, ...over })
|
||||
const leg = (over: Partial<LegAlternatives> = {}): LegAlternatives => openLeg({ dayId: 4, drive: { kind: 'leg', index: 1 }, ...over })
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ settings: { distance_unit: 'metric' } as never })
|
||||
|
||||
@@ -1341,3 +1341,60 @@ describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () =
|
||||
expect(onOpenBooking).toHaveBeenCalledWith(12)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RoadtripSidebar with the drive in from the day before (#2461)', () => {
|
||||
/** Where day 1 ended, three stops in, and a card joined to it by the road from there. */
|
||||
const yesterday = stop({ assignmentId: 9, name: 'Lüneburg', ownerDayId: 1, ownerIndex: 2 })
|
||||
const joined = (over: Partial<RoadtripDay> = {}) => day({
|
||||
dayId: 2,
|
||||
dayNumber: 2,
|
||||
arrivingLeg: leg({ distance: 120000, duration: 5400, distanceText: '120 km', durationText: '1 h 30 min' }),
|
||||
arrivingFrom: yesterday,
|
||||
arrivingLine: [[53.2, 10.4], [52.5, 13.4]],
|
||||
...over,
|
||||
})
|
||||
const pressed = () => screen.getAllByLabelText('Other ways').map(icon => icon.closest('button')?.getAttribute('aria-pressed'))
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-060: the drive in offers other ways as the drive it is, and shows pressed while they are open', () => {
|
||||
// #2461: the band above the first stop was the one drive on the rail without the
|
||||
// control, so the road between two days could not be changed at all.
|
||||
const onAskAlternatives = vi.fn()
|
||||
const first = wrap(<RoadtripSidebar routes={routes({ days: [joined()] })} onAskAlternatives={onAskAlternatives} />)
|
||||
|
||||
// The drive in, then the card's own leg.
|
||||
expect(pressed()).toEqual(['false', 'false'])
|
||||
fireEvent.click(screen.getAllByLabelText('Other ways')[0])
|
||||
expect(onAskAlternatives).toHaveBeenCalledWith(2, { kind: 'arriving' })
|
||||
first.unmount()
|
||||
|
||||
const open = wrap(
|
||||
<RoadtripSidebar routes={routes({ days: [joined()] })} onAskAlternatives={vi.fn()} openAlternatives={{ dayId: 2, drive: { kind: 'arriving' } }} />,
|
||||
)
|
||||
expect(pressed()).toEqual(['true', 'false'])
|
||||
open.unmount()
|
||||
|
||||
// The card's first leg is another drive, even at the index the drive in would borrow.
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [joined()] })} onAskAlternatives={vi.fn()} openAlternatives={{ dayId: 2, drive: { kind: 'leg', index: 0 } }} />)
|
||||
expect(pressed()).toEqual(['false', 'true'])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-061: no control for a reader, for a drive from a terminal, or for one the rail has no line for', () => {
|
||||
const reader = wrap(<RoadtripSidebar routes={routes({ days: [joined()] })} />)
|
||||
expect(screen.queryAllByLabelText('Other ways')).toHaveLength(0)
|
||||
// The band is still drawn, only not as a control.
|
||||
expect(screen.getByText('120 km in 1 h 30 min')).toBeInTheDocument()
|
||||
reader.unmount()
|
||||
|
||||
const landed = stop({
|
||||
assignmentId: -3000000141,
|
||||
name: 'Munich Airport',
|
||||
carrier: { reservationId: 70, type: 'flight', role: 'arrival', title: 'LH 2020', code: 'MUC', at: '14:30' },
|
||||
})
|
||||
const fromTerminal = wrap(<RoadtripSidebar routes={routes({ days: [joined({ arrivingFrom: landed })] })} onAskAlternatives={vi.fn()} />)
|
||||
expect(screen.getAllByLabelText('Other ways')).toHaveLength(1)
|
||||
fromTerminal.unmount()
|
||||
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [joined({ arrivingLine: undefined })] })} onAskAlternatives={vi.fn()} />)
|
||||
expect(screen.getAllByLabelText('Other ways')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,7 +17,7 @@ import { formatDistance } from '../../utils/units'
|
||||
import { formatDate, formatClockTime } from '../../utils/formatters'
|
||||
import { formatDurationShort, isServiceStopType, serviceColor, type ScheduleEntry, type ScheduleWarning, refuelsRange } from './roadtripModel'
|
||||
import { STOP_KIND_BY_KEY } from './stopKinds'
|
||||
import { destinationCount, isHop, legReroutable } from './roadtripRowModel'
|
||||
import { arrivingReroutable, destinationCount, isHop, legReroutable } from './roadtripRowModel'
|
||||
import { spurWorthLabelling } from './accessSpur'
|
||||
import StopKindPicker from './StopKindPicker'
|
||||
import StopFillPicker from './StopFillPicker'
|
||||
@@ -26,7 +26,7 @@ import { MAX_TRIP_DAYS, type RoadtripStopType } from '@trek/shared'
|
||||
import { isCarrierMode, type CarrierTerminal } from '@trek/shared/roadtrip'
|
||||
import type { QuietDay, RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes'
|
||||
import type { SpillMark } from './nightSpill'
|
||||
import type { RailDrive } from './useRouteAlternatives'
|
||||
import { ARRIVING_DRIVE, openOn, type LegAlternatives, type RailDrive } from './useRouteAlternatives'
|
||||
import { dayColor } from './dayColors'
|
||||
import type { RouteVia } from '../../types'
|
||||
import { FS } from './typeScale'
|
||||
@@ -63,8 +63,8 @@ interface RoadtripSidebarProps {
|
||||
refuel?: RefuelSearch
|
||||
onAskRefuel?: (dayId: number, dry: DryPoint & { lat: number; lng: number }) => void
|
||||
onAcceptRefuel?: (dayId: number, poi: RefuelCandidate, dry: DryPoint & { lat: number; lng: number }) => void
|
||||
/** Which leg's alternatives are on show, so the rail can mark it. */
|
||||
openAlternatives?: { dayId: number; index: number } | null
|
||||
/** Which drive's alternatives are on show, so the rail can mark it. */
|
||||
openAlternatives?: Pick<LegAlternatives, 'dayId' | 'drive'> | null
|
||||
/**
|
||||
* Opens the dialog for how long a stop takes. Absent leaves every stay read-only —
|
||||
* which is also what a viewer sees.
|
||||
@@ -1734,7 +1734,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca
|
||||
<DriveBand
|
||||
leg={day.legs[i]}
|
||||
onAskAlternatives={onAskAlternatives && legReroutable(day, i) ? () => onAskAlternatives(day.dayId, { kind: 'leg', index: i }) : undefined}
|
||||
alternativesOpen={openAlternatives?.dayId === day.dayId && openAlternatives.index === i}
|
||||
alternativesOpen={openOn(openAlternatives, day.dayId, { kind: 'leg', index: i })}
|
||||
/>
|
||||
) : null}
|
||||
{refuelBandsFor(i)}
|
||||
@@ -1906,8 +1906,17 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca
|
||||
08:00 looks like eleven minutes went missing. A day whose first stop crossed
|
||||
over instead gets that road under its own block, so this is left out there. */}
|
||||
{/* A day that opens on an arrival terminal is joined by the ride, and the
|
||||
terminal carries the booking the band names. */}
|
||||
{day.arrivingLeg ? <DriveBand leg={day.arrivingLeg} carrier={day.stops[0]?.carrier} /> : null}
|
||||
terminal carries the booking the band names. A drive by road is offered other
|
||||
ways like any leg (`arrivingReroutable`); a choice is filed behind the stop it
|
||||
leaves, on the day before, where the map files a point dropped on it. */}
|
||||
{day.arrivingLeg ? (
|
||||
<DriveBand
|
||||
leg={day.arrivingLeg}
|
||||
carrier={day.stops[0]?.carrier}
|
||||
onAskAlternatives={onAskAlternatives && arrivingReroutable(day) ? () => onAskAlternatives(day.dayId, ARRIVING_DRIVE) : undefined}
|
||||
alternativesOpen={openOn(openAlternatives, day.dayId, ARRIVING_DRIVE)}
|
||||
/>
|
||||
) : null}
|
||||
{runs.map(run => (
|
||||
run.spill ? (
|
||||
<SpillBlock key={`spill-${run.from}`} spill={run.spill}>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { MAX_PINS, pinAlternative, railLegAt } from './alternativePins'
|
||||
import { MAX_PINS, pinAlternative, railDriveOn, railLegAt } from './alternativePins'
|
||||
import type { RailLegRoute, RailLegRouter, RoadtripDay, RoadtripStop } from './useRoadtripRoutes'
|
||||
import type { OfferedRoute } from './useRouteAlternatives'
|
||||
|
||||
/**
|
||||
* FE-ALTPIN-001..010: proving a choice with the rail's own router before it is saved.
|
||||
* FE-ALTPIN-001..011: proving a choice with the rail's own router before it is saved.
|
||||
*
|
||||
* A choice used to be one via at the point where the offer strayed furthest, written
|
||||
* without asking whether the router then drove the offer. On a ferry it did not (OSRM
|
||||
@@ -149,3 +149,36 @@ describe('railLegAt', () => {
|
||||
expect(railLegAt(days, { dayId: 3, afterIndex: 0 })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('railDriveOn', () => {
|
||||
const stop = (assignmentId: number, ownerDayId: number, ownerIndex: number) =>
|
||||
({ assignmentId, ownerDayId, ownerIndex, lat: 50, lng: 10 }) as RoadtripStop
|
||||
const seg = (distance: number) => ({ distance, duration: distance / 20 }) as RoadtripDay['legs'][number]
|
||||
const LEG_LINE: [number, number][] = [[50, 10], [50.2, 10.4]]
|
||||
const DRIVE_IN: [number, number][] = [[51, 9], [50, 10]]
|
||||
const card = (over: Partial<RoadtripDay> = {}) => ({
|
||||
dayId: 2,
|
||||
stops: [stop(20, 2, 0), stop(21, 2, 1)],
|
||||
legs: [seg(40_000)],
|
||||
legLines: [LEG_LINE],
|
||||
arrivingFrom: stop(12, 1, 2),
|
||||
arrivingLeg: seg(90_000),
|
||||
arrivingLine: DRIVE_IN,
|
||||
...over,
|
||||
}) as RoadtripDay
|
||||
|
||||
it('FE-ALTPIN-011: a leg is read off its index, the drive in off the stop the day before ended on', () => {
|
||||
expect(railDriveOn(card(), { kind: 'leg', index: 0 })).toMatchObject({
|
||||
from: { assignmentId: 20 }, to: { assignmentId: 21 }, seg: { distance: 40_000 }, line: LEG_LINE,
|
||||
})
|
||||
expect(railDriveOn(card(), { kind: 'arriving' })).toMatchObject({
|
||||
from: { assignmentId: 12, ownerDayId: 1, ownerIndex: 2 }, to: { assignmentId: 20 }, seg: { distance: 90_000 }, line: DRIVE_IN,
|
||||
})
|
||||
// Nothing past the last stop, no drive in on a card nothing joins, and no road yet.
|
||||
expect(railDriveOn(card(), { kind: 'leg', index: 1 })).toBeNull()
|
||||
expect(railDriveOn(card({ arrivingFrom: undefined, arrivingLeg: undefined }), { kind: 'arriving' })).toBeNull()
|
||||
expect(railDriveOn(card({ legs: [] }), { kind: 'leg', index: 0 })).toBeNull()
|
||||
// A line the card does not have yet is no reason to refuse the drive.
|
||||
expect(railDriveOn(card({ legLines: undefined }), { kind: 'leg', index: 0 })?.line).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { furthestFrom, sameRoad, type RoadLine } from '../Map/RouteCalculator'
|
||||
import type { RouteSegment } from '@trek/shared/roadtrip'
|
||||
import type { RailLegRoute, RailLegRouter, RoadtripDay, RoadtripStop } from './useRoadtripRoutes'
|
||||
import type { OfferedRoute, ViaAnchor } from './useRouteAlternatives'
|
||||
import type { OfferedRoute, RailDrive, ViaAnchor } from './useRouteAlternatives'
|
||||
|
||||
/**
|
||||
* The most points one choice may pin to the road.
|
||||
@@ -87,14 +88,41 @@ export async function pinAlternative({
|
||||
* The leg the rail drives from the stop filed at `anchor` now, or null when no stop sits
|
||||
* there any more.
|
||||
*
|
||||
* Read across every card in order, so the stop after it is found whether the leg runs
|
||||
* inside one card or across to the next. The markers an automatic night puts on the
|
||||
* chain stand on or between stops without being one, and a terminal borrows the index of
|
||||
* a stored stop to be seated by, so neither can answer for the anchor.
|
||||
* Read across every day in order, so the stop after it is found whether the leg runs
|
||||
* inside one card or across to the next. The caller hands in the days with a single stop
|
||||
* too: they draw no card, but the drive into the day after one leaves from its stop. The
|
||||
* markers an automatic night puts on the chain stand on or between stops without being
|
||||
* one, and a terminal borrows the index of a stored stop to be seated by, so neither can
|
||||
* answer for the anchor.
|
||||
*/
|
||||
export function railLegAt(days: readonly RoadtripDay[], anchor: ViaAnchor): { from: RoadtripStop; to: RoadtripStop } | null {
|
||||
export function railLegAt(days: readonly Pick<RoadtripDay, 'stops'>[], anchor: ViaAnchor): { from: RoadtripStop; to: RoadtripStop } | null {
|
||||
const chain = days.flatMap(day => day.stops).filter(stop => !stop.automaticNight)
|
||||
const at = chain.findIndex(stop => !stop.carrier && stop.ownerDayId === anchor.dayId && stop.ownerIndex === anchor.afterIndex)
|
||||
const to = at >= 0 ? chain[at + 1] : undefined
|
||||
return to ? { from: chain[at], to } : null
|
||||
}
|
||||
|
||||
/** One drive on a card as the rail has it: its two stops, its road and the line it is drawn on. */
|
||||
export interface RailDriveOnCard {
|
||||
from: RoadtripStop
|
||||
to: RoadtripStop
|
||||
seg: RouteSegment
|
||||
line: [number, number][] | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The drive `drive` names on `day`, or null while the card has no such drive or no road
|
||||
* for it yet.
|
||||
*
|
||||
* A leg runs from stop `index` to the next one. The drive arriving at the head of a
|
||||
* connected card has no index on it: it leaves the last stop of the day before, which the
|
||||
* card keeps as `arrivingFrom`, and ends at the card's first stop. Read here once, so the
|
||||
* picker is handed the same two stops, figures and line whichever of the two it opens on.
|
||||
*/
|
||||
export function railDriveOn(day: RoadtripDay, drive: RailDrive): RailDriveOnCard | null {
|
||||
const found = drive.kind === 'arriving'
|
||||
? { from: day.arrivingFrom, to: day.stops[0], seg: day.arrivingLeg, line: day.arrivingLine }
|
||||
: { from: day.stops[drive.index], to: day.stops[drive.index + 1], seg: day.legs[drive.index], line: day.legLines?.[drive.index] }
|
||||
const { from, to, seg, line } = found
|
||||
return from && to && seg ? { from, to, seg, line } : null
|
||||
}
|
||||
|
||||
@@ -175,3 +175,59 @@ describe('reanchorAfterReorder', () => {
|
||||
expect(plan.vias).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* A via behind a day's last stop shapes the drive into the next day, on a trip with
|
||||
* connected days or a night drive: the map files a point dropped on that drive there, and
|
||||
* a way chosen for it is written there. It is no leg of its own day, so the arithmetic
|
||||
* above has to be told what it is (`carriedSeam`) or it takes it for one leading nowhere.
|
||||
*/
|
||||
describe('the drive into the next day (#2461)', () => {
|
||||
it('FE-REANCHOR-023: a wholesale reorder that leaves the last stop last keeps the drive behind it', () => {
|
||||
// Stops 10, 20, 30, 40; the via behind 40 is the join. Swapping the first two used to
|
||||
// delete it, because a last stop has no leg.
|
||||
const plan = reanchorByStopOrder([via(1, 0), via(9, 3)], [10, 20, 30, 40], [20, 10, 30, 40])
|
||||
expect(anchors(plan.vias)).toEqual({ 1: 1 })
|
||||
expect(plan.remove).toEqual([])
|
||||
expect(isEmptyReanchoring(reanchorByStopOrder([via(9, 3)], [10, 20, 30, 40], [10, 20, 30, 40]))).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-024: taking out the last stop takes the drive out of it along, instead of leaving it on no stop', () => {
|
||||
const plan = reanchorAfterRemove([via(2, 2), via(9, 3)], 3, 4)
|
||||
expect(plan.remove).toEqual([2, 9])
|
||||
expect(plan.vias).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-025: taking out any other stop leaves it behind the same last stop, one number down', () => {
|
||||
expect(anchors(reanchorAfterRemove([via(9, 3)], 1, 4).vias)).toEqual({ 9: 2 })
|
||||
expect(anchors(reanchorAfterRemove([via(9, 3)], 0, 4).vias)).toEqual({ 9: 2 })
|
||||
// A day left with one stop has no leg, but still the drive out of it.
|
||||
const single = reanchorAfterRemove([via(1, 0), via(9, 1)], 0, 2)
|
||||
expect(anchors(single.vias)).toEqual({ 9: 0 })
|
||||
expect(single.remove).toEqual([1])
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-026: a last stop dragged up the day takes its drive along; a stop dragged behind it splits that drive', () => {
|
||||
// Left alone, the via sat at an index past the end of the day.
|
||||
const up = reanchorAfterReorder([via(1, 0), via(9, 3)], 3, 1, 4)
|
||||
expect(up.remove).toEqual([9])
|
||||
expect(anchors(up.vias)).toEqual({})
|
||||
// Behind the old last stop now comes the moved one, and the point stays on the road
|
||||
// leaving the old last stop, the way an insert into any leg keeps its first half.
|
||||
expect(anchors(reanchorAfterReorder([via(9, 3)], 1, 3, 4).vias)).toEqual({ 9: 2 })
|
||||
// Anything moved in front of the last stop leaves it where it was.
|
||||
expect(isEmptyReanchoring(reanchorAfterReorder([via(9, 3)], 0, 2, 4))).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-027: a stop appended behind the last one keeps the point on the road to it', () => {
|
||||
expect(isEmptyReanchoring(reanchorAfterInsert([via(9, 3)], 4, () => true))).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-028: two stops swapped keep their leg, and the drive out of the one that is first now goes', () => {
|
||||
for (const [from, to] of [[0, 1], [1, 0]] as const) {
|
||||
const plan = reanchorAfterReorder([via(1, 0), via(9, 1)], from, to, 2)
|
||||
expect(plan.remove, `${from}->${to}`).toEqual([9])
|
||||
expect(plan.vias, `${from}->${to}`).toEqual([])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
arrivingReroutable,
|
||||
destinationCount,
|
||||
firstStopOfPlace,
|
||||
legReroutable,
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
import type { ScheduleEntry, ScheduleWarning } from './roadtripModel'
|
||||
import type { RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
|
||||
|
||||
// FE-RTROW-001 to FE-RTROW-055
|
||||
// FE-RTROW-001 to FE-RTROW-058
|
||||
|
||||
function stop(name: string, over: Partial<RoadtripStop> = {}): RoadtripStop {
|
||||
return {
|
||||
@@ -649,3 +650,53 @@ describe('roadtripRows with a ride (#2428)', () => {
|
||||
expect(stageEnd(roadtripRows(opensOnRide()))?.stop.name).toBe('Hotel')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the drive in from the day before (#2461)', () => {
|
||||
/** Where yesterday ended: stored on day 6, its third stop, and drawn on no stop of this card. */
|
||||
const yesterday = stop('Lüneburg', { ownerDayId: 6, ownerIndex: 2, legMode: null })
|
||||
const drive: RouteSegment = { ...seg(4), distance: 120_000, duration: 5_400 }
|
||||
/** A card joined to the one before it, with the drive and the line it is drawn on. */
|
||||
const joined = (over: Partial<RoadtripDay> = {}) => day([stop('Berlin', { incomingLegMode: 'driving' }), stop('Potsdam')], {
|
||||
arrivingLeg: drive,
|
||||
arrivingFrom: yesterday,
|
||||
arrivingLine: [[53.2, 10.4], [52.5, 13.4]],
|
||||
...over,
|
||||
})
|
||||
|
||||
it('FE-RTROW-056: heads the card with a row of its own that names where it leaves, and counts no stop for it', () => {
|
||||
const rows = roadtripRows(joined())
|
||||
expect(rows.map(r => r.kind)).toEqual(['arriving', 'stop', 'leg', 'stop'])
|
||||
expect(rows[0]).toMatchObject({ kind: 'arriving', seg: drive, from: { name: 'Lüneburg' }, mode: 'driving' })
|
||||
// Still two stops, numbered from one: the stop it leaves is not on this card.
|
||||
expect(stopRows(rows).map(r => r.number)).toEqual([1, 2])
|
||||
expect(destinationCount(joined())).toBe(2)
|
||||
expect(stageClocks(rows)).toEqual(stageClocks(roadtripRows(joined({ arrivingLeg: undefined }))))
|
||||
expect(stageEnd(rows)?.stop.name).toBe('Potsdam')
|
||||
})
|
||||
|
||||
it('FE-RTROW-057: no row for a hop, a ride, or a drive whose start the card was not told', () => {
|
||||
const hop: RouteSegment = { ...seg(0), distance: 80, duration: 60 }
|
||||
const ride: RouteSegment = { ...seg(0), distance: 0, duration: 9 * 3600, mode: 'flight' }
|
||||
for (const over of [{ arrivingLeg: hop }, { arrivingLeg: ride }, { arrivingFrom: undefined }, { arrivingLeg: undefined }]) {
|
||||
expect(roadtripRows(joined(over)).map(r => r.kind)).toEqual(['stop', 'leg', 'stop'])
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-RTROW-058: other ways are offered on it exactly where a leg would be offered them', () => {
|
||||
expect(arrivingReroutable(joined())).toBe(true)
|
||||
// Nothing to offer against: no drive, no line, no stop it leaves or reaches.
|
||||
expect(arrivingReroutable(joined({ arrivingLeg: undefined }))).toBe(false)
|
||||
expect(arrivingReroutable(joined({ arrivingLine: undefined }))).toBe(false)
|
||||
expect(arrivingReroutable(joined({ arrivingFrom: undefined }))).toBe(false)
|
||||
expect(arrivingReroutable(joined({ stops: [] }))).toBe(false)
|
||||
// A ride has no other way, and a hop is nothing to weigh.
|
||||
expect(arrivingReroutable(joined({ arrivingLeg: { ...drive, mode: 'train' } }))).toBe(false)
|
||||
expect(arrivingReroutable(joined({ arrivingLeg: { ...drive, distance: 80, duration: 60 } }))).toBe(false)
|
||||
// A terminal leaves nothing a via can be filed behind.
|
||||
const terminal = stop('Hamburg Airport', { carrier: { reservationId: 7, type: 'flight', role: 'arrival', title: 'LH 2020', code: null, at: '07:00' } })
|
||||
expect(arrivingReroutable(joined({ arrivingFrom: terminal }))).toBe(false)
|
||||
// An automatic night at either end is a marker on the road, not a stop anybody chose.
|
||||
expect(arrivingReroutable(joined({ arrivingFrom: night() }))).toBe(false)
|
||||
expect(arrivingReroutable(joined({ stops: [night('start'), stop('Potsdam')] }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isServiceStopType, type ScheduleEntry, type ScheduleWarning } from './roadtripModel'
|
||||
import type { CarrierTerminal, RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
|
||||
import { isCarrierMode, type CarrierTerminal, type RoadtripDay, type RoadtripStop, type RouteSegment } from '@trek/shared/roadtrip'
|
||||
import { readStay } from './stayReading'
|
||||
|
||||
/**
|
||||
@@ -21,6 +21,12 @@ export type RoadtripRow =
|
||||
| { kind: 'spill'; fromDayNumber: number; departs: string | null; stops: StopRow[] }
|
||||
| StopRow
|
||||
| { kind: 'leg'; index: number; seg: RouteSegment | undefined; mode: string | null }
|
||||
/**
|
||||
* The drive in from where the day before ended, above the card's first stop, on a trip
|
||||
* with connected days. `from` is that last stop of the day before, which the card does
|
||||
* not draw, so the row names it.
|
||||
*/
|
||||
| { kind: 'arriving'; seg: RouteSegment; from: RoadtripStop; mode: string | null }
|
||||
/**
|
||||
* A ride that leaves and lands on the day (#2428), as one row: the booking with both
|
||||
* its terminals inside it, in place of two stop rows and the leg between them. The
|
||||
@@ -156,6 +162,20 @@ export function roadtripRows(day: RoadtripDay): RoadtripRow[] {
|
||||
const rows: RoadtripRow[] = []
|
||||
const spills = day.spills ?? []
|
||||
|
||||
// The drive in from yesterday, where the desktop rail draws its band: above the first
|
||||
// stop, because that is where it happens. A hop is left out like any other, and so is
|
||||
// a ride: the terminal row it lands on already carries the booking, and a pill would
|
||||
// read "No route" under a flight that has no road.
|
||||
const arriving = day.arrivingLeg
|
||||
if (arriving && day.arrivingFrom && !isHop(arriving) && !isCarrierMode(arriving.mode)) {
|
||||
rows.push({
|
||||
kind: 'arriving',
|
||||
seg: arriving,
|
||||
from: day.arrivingFrom,
|
||||
mode: day.stops[0]?.incomingLegMode ?? day.arrivingFrom.legMode ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
let number = 0
|
||||
day.stops.forEach((stop, i) => {
|
||||
const spill = spills.find(s => s.at === i)
|
||||
@@ -250,6 +270,29 @@ export function legReroutable(day: RoadtripDay, index: number): boolean {
|
||||
&& !day.stops[index].carrier
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the drive arriving at the head of a connected card can be offered other ways.
|
||||
*
|
||||
* The same rule as `legReroutable`, read for the one drive a card holds without a stop of
|
||||
* its own at its start: it leaves the last stop of the day before (`arrivingFrom`), and a
|
||||
* choice is filed behind that stop, on that day, where the map already files a point
|
||||
* dropped on this drive. It needs its road and the line it is drawn on, since that is
|
||||
* what the picker offers as the current way. A ride has no other way, a hop is nothing to
|
||||
* weigh, a terminal leaves nothing a via can be filed behind, and an automatic night at
|
||||
* either end is a marker on the road rather than a stop anybody chose.
|
||||
*/
|
||||
export function arrivingReroutable(day: RoadtripDay): boolean {
|
||||
const seg = day.arrivingLeg
|
||||
const from = day.arrivingFrom
|
||||
const to = day.stops[0]
|
||||
return !!seg && !!from && !!to && !!day.arrivingLine
|
||||
&& !isCarrierMode(seg.mode)
|
||||
&& !isHop(seg)
|
||||
&& !from.carrier
|
||||
&& !from.automaticNight
|
||||
&& !to.automaticNight
|
||||
}
|
||||
|
||||
/** Stops that carry a number, for a count that agrees with the numbering above. */
|
||||
export function destinationCount(day: RoadtripDay): number {
|
||||
return day.stops.filter(s => !s.automaticNight && !s.carrier && !isServiceStopType(s.stopType)).length
|
||||
|
||||
@@ -776,6 +776,31 @@ describe('useRoadtripRoutes', () => {
|
||||
expect(inboundDry.lat).toBeGreaterThan(BERLIN[0])
|
||||
expect(inboundDry.lat).toBeLessThan(LUENEBURG[0])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-049: switching what the trip avoids asks for the join again, with the new classes', async () => {
|
||||
// The seam round skipped every join it already had an answer for, whatever that
|
||||
// answer had been asked with, so the drive between two days kept its old road and
|
||||
// its old minutes until the page was loaded again.
|
||||
//
|
||||
// The clock the requests are spaced by is held still and moved by hand, so neither
|
||||
// round waits for real time between its requests.
|
||||
let now = 10_000
|
||||
const clock = vi.spyOn(performance, 'now').mockImplementation(() => now)
|
||||
try {
|
||||
setting({ roadtrip_connect_days: true })
|
||||
const { days: daysList, assignments } = twoDays()
|
||||
renderHook(() => useRoadtripRoutes(7, daysList, assignments))
|
||||
await waitFor(() => expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(1))
|
||||
expect(askedFor(LUENEBURG, BERLIN)[0][1]).toMatchObject({ avoid: [] })
|
||||
|
||||
now += 10_000
|
||||
setting({ roadtrip_avoid: 'toll' })
|
||||
await waitFor(() => expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(2))
|
||||
expect(askedFor(LUENEBURG, BERLIN)[1][1]).toMatchObject({ profile: 'driving', avoid: ['toll'] })
|
||||
} finally {
|
||||
clock.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -53,16 +53,25 @@ const stopKey = (s: RoadtripStop): string =>
|
||||
`${s.lat.toFixed(5)},${s.lng.toFixed(5)},${s.legMode ?? ''},${s.incomingLegMode ?? ''}`
|
||||
|
||||
/**
|
||||
* The via points that shape the drive leaving a stop, as one comparable string.
|
||||
* Everything a seam's answer depends on besides its two stops, as one comparable string:
|
||||
* the mode it is driven in, the classes the trip avoids, and the via points that shape
|
||||
* the drive leaving `from`.
|
||||
*
|
||||
* Read in two places that must agree: when deciding whether a seam still matches the
|
||||
* answer already in hand, and when recording what an answer was fetched for. One function
|
||||
* so the two cannot drift and quietly stop refetching.
|
||||
* so the two cannot drift and quietly stop refetching. The mode and the classes are in it
|
||||
* because the vias alone were: switching motorways off, or the day's profile, re-ran the
|
||||
* seam round and found every seam already answered, so the drive between two days kept
|
||||
* its old road and its old minutes until the page was loaded again.
|
||||
*/
|
||||
const seamShape = (from: RoadtripStop, viasByDay: Record<number, RoadtripVia[]>): string =>
|
||||
viasLeaving(from, viasByDay[from.ownerDayId] ?? [])
|
||||
.map(v => `${v.lat.toFixed(5)},${v.lng.toFixed(5)}`)
|
||||
.join('|')
|
||||
const seamShape = (from: RoadtripStop, viasByDay: Record<number, RoadtripVia[]>, mode: string, avoidKey: string): string =>
|
||||
[
|
||||
mode,
|
||||
avoidKey,
|
||||
viasLeaving(from, viasByDay[from.ownerDayId] ?? [])
|
||||
.map(v => `${v.lat.toFixed(5)},${v.lng.toFixed(5)}`)
|
||||
.join('|'),
|
||||
].join('#')
|
||||
|
||||
/** The drive from one stop to the next, identified the same way `planKey` identifies them. */
|
||||
const legKey = (from: RoadtripStop, to: RoadtripStop): string => `${stopKey(from)}>${stopKey(to)}`
|
||||
@@ -558,7 +567,7 @@ export function useRoadtripRoutes(
|
||||
* day in two pieces with a gap across the middle.
|
||||
*/
|
||||
const seams = useMemo(() => {
|
||||
const out: { from: RoadtripStop; to: RoadtripStop; dayId: number }[] = []
|
||||
const out: { from: RoadtripStop; to: RoadtripStop; dayId: number; mode: string; shape: string }[] = []
|
||||
const want = (from: RoadtripStop, to: RoadtripStop, dayId: number): void => {
|
||||
// The two ends of one ride: the leg between them is the booking's, never a road.
|
||||
if (rideLegs[legKey(from, to)]) return
|
||||
@@ -569,9 +578,11 @@ export function useRoadtripRoutes(
|
||||
// now. Skipping it whenever any answer existed is what made a via on a seam do
|
||||
// nothing at all: the first answer was cached under the pair, and dragging the
|
||||
// point changed the request nobody was going to send again.
|
||||
const mode = legModeOf(from, to, dayId)
|
||||
const shape = seamShape(from, viasByDay, mode, avoidKey)
|
||||
const have = seamLegs[legKey(from, to)]
|
||||
if (have && have.shape === seamShape(from, viasByDay)) return
|
||||
out.push({ from, to, dayId })
|
||||
if (have && have.shape === shape) return
|
||||
out.push({ from, to, dayId, mode, shape })
|
||||
}
|
||||
const routingChains = window
|
||||
? [...plan, ...quietDays].sort((a, b) => a.dayNumber - b.dayNumber).filter(d => d.stops.length)
|
||||
@@ -595,8 +606,8 @@ export function useRoadtripRoutes(
|
||||
}
|
||||
}
|
||||
return out
|
||||
}, [chains, plan, quietDays, window, legsByDay, seamLegs, rideLegs, viasByDay, connectDays])
|
||||
const seamKey = seams.map(s => `${legKey(s.from, s.to)}#${seamShape(s.from, viasByDay)}`).join(';')
|
||||
}, [chains, plan, quietDays, window, legsByDay, seamLegs, rideLegs, viasByDay, connectDays, legModeOf, avoidKey])
|
||||
const seamKey = seams.map(s => `${legKey(s.from, s.to)}#${s.shape}`).join(';')
|
||||
/**
|
||||
* When the last request outside the routing round went out, across every run of the
|
||||
* effect below: a seam, or a leg asked for again on demand (see `legRouter`). One clock
|
||||
@@ -616,7 +627,7 @@ export function useRoadtripRoutes(
|
||||
void (async () => {
|
||||
for (const seam of seams) {
|
||||
if (controller.signal.aborted) return
|
||||
const mode = legModeOf(seam.from, seam.to, seam.dayId)
|
||||
const { mode, shape } = seam
|
||||
// No router knows a ride. A booked ride's own pair has its leg in `rideLegs` and
|
||||
// never reaches here; what is left is a terminal seamed to an ordinary stop on
|
||||
// another card, which is a join of no minutes rather than a road. Filed here,
|
||||
@@ -625,7 +636,7 @@ export function useRoadtripRoutes(
|
||||
const key = legKey(seam.from, seam.to)
|
||||
const legs = carrierLegsFor([seam.from, seam.to], mode, (from, to) => dayNumberOf(to.ownerDayId) - dayNumberOf(from.ownerDayId), legKey)
|
||||
const leg = legs?.[key]
|
||||
if (leg) setSeamLegs(prev => ({ ...prev, [key]: { ...leg, shape: seamShape(seam.from, viasByDay) } }))
|
||||
if (leg) setSeamLegs(prev => ({ ...prev, [key]: { ...leg, shape } }))
|
||||
continue
|
||||
}
|
||||
// Paced before the request, against a clock that outlives this effect.
|
||||
@@ -670,7 +681,7 @@ export function useRoadtripRoutes(
|
||||
seg: { ...merged, mode },
|
||||
line: r.coordinates,
|
||||
vias: [],
|
||||
shape: seamShape(seam.from, viasByDay),
|
||||
shape,
|
||||
},
|
||||
}))
|
||||
} catch {
|
||||
|
||||
@@ -9,11 +9,11 @@ vi.mock('../Map/RouteCalculator', async importOriginal => ({
|
||||
calculateAlternatives,
|
||||
}))
|
||||
|
||||
import { useRouteAlternatives, type AlternativesRequest } from './useRouteAlternatives'
|
||||
import { ARRIVING_DRIVE, openOn, sameDrive, useRouteAlternatives, type AlternativesRequest } from './useRouteAlternatives'
|
||||
import type { RailLegRouter } from './useRoadtripRoutes'
|
||||
|
||||
/**
|
||||
* FE-ALTHOOK-001..012: asking for other ways of driving one leg.
|
||||
* FE-ALTHOOK-001..013: asking for other ways of driving one leg.
|
||||
*
|
||||
* The case that carries the feature: the road the rail drives is always the first entry,
|
||||
* taken from the rail, and the offers come from the engine the rail drives the leg with.
|
||||
@@ -40,7 +40,7 @@ const route = vi.fn()
|
||||
function request(router: Partial<RailLegRouter> = {}, over: Partial<AlternativesRequest> = {}): AlternativesRequest {
|
||||
return {
|
||||
dayId: 4,
|
||||
index: 1,
|
||||
drive: { kind: 'leg', index: 1 },
|
||||
from: { lat: 53.55, lng: 9.99 },
|
||||
to: { lat: 52.52, lng: 13.4 },
|
||||
driven: { coordinates: RAIL, distance: 290_000, duration: 10_800 },
|
||||
@@ -67,7 +67,7 @@ describe('useRouteAlternatives', () => {
|
||||
|
||||
act(() => { result.current.ask(request()) })
|
||||
expect(result.current.open).toMatchObject({
|
||||
dayId: 4, index: 1, loading: true, routes: [], proving: null, engine: 'osrm',
|
||||
dayId: 4, drive: { kind: 'leg', index: 1 }, loading: true, routes: [], proving: null, engine: 'osrm',
|
||||
anchor: { dayId: 3, afterIndex: 2 },
|
||||
ends: { from: 11, to: 12 },
|
||||
})
|
||||
@@ -173,11 +173,11 @@ describe('useRouteAlternatives', () => {
|
||||
const { result } = renderHook(() => useRouteAlternatives())
|
||||
|
||||
act(() => { result.current.ask(request()) })
|
||||
act(() => { result.current.ask(request({}, { index: 2 })) })
|
||||
act(() => { result.current.ask(request({}, { drive: { kind: 'leg', index: 2 } })) })
|
||||
|
||||
expect(signals[0].aborted).toBe(true)
|
||||
expect(signals[1].aborted).toBe(false)
|
||||
expect(result.current.open?.index).toBe(2)
|
||||
expect(result.current.open?.drive).toEqual({ kind: 'leg', index: 2 })
|
||||
|
||||
act(() => { result.current.close() })
|
||||
expect(signals[1].aborted).toBe(true)
|
||||
@@ -242,9 +242,9 @@ describe('useRouteAlternatives', () => {
|
||||
expect(first?.aborted).toBe(true)
|
||||
expect(second?.aborted).toBe(false)
|
||||
|
||||
act(() => { result.current.ask(request({}, { index: 0 })) })
|
||||
act(() => { result.current.ask(request({}, { drive: { kind: 'leg', index: 0 } })) })
|
||||
expect(second?.aborted).toBe(true)
|
||||
expect(result.current.open).toMatchObject({ index: 0, proving: null })
|
||||
expect(result.current.open).toMatchObject({ drive: { kind: 'leg', index: 0 }, proving: null })
|
||||
|
||||
// Nothing open, nothing to mark.
|
||||
act(() => { result.current.close() })
|
||||
@@ -253,3 +253,19 @@ describe('useRouteAlternatives', () => {
|
||||
expect(result.current.open).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('which drive a picker is open on', () => {
|
||||
it('FE-ALTHOOK-013: the drive in from the day before is its own drive, never a leg at some index', () => {
|
||||
expect(sameDrive(ARRIVING_DRIVE, { kind: 'arriving' })).toBe(true)
|
||||
expect(sameDrive({ kind: 'leg', index: 0 }, { kind: 'leg', index: 0 })).toBe(true)
|
||||
expect(sameDrive({ kind: 'leg', index: 0 }, { kind: 'leg', index: 1 })).toBe(false)
|
||||
expect(sameDrive(ARRIVING_DRIVE, { kind: 'leg', index: 0 })).toBe(false)
|
||||
expect(sameDrive({ kind: 'leg', index: 0 }, ARRIVING_DRIVE)).toBe(false)
|
||||
|
||||
const open = { dayId: 2, drive: ARRIVING_DRIVE }
|
||||
expect(openOn(open, 2, { kind: 'arriving' })).toBe(true)
|
||||
expect(openOn(open, 3, { kind: 'arriving' })).toBe(false)
|
||||
expect(openOn(open, 2, { kind: 'leg', index: 0 })).toBe(false)
|
||||
expect(openOn(null, 2, ARRIVING_DRIVE)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,13 +11,30 @@ export interface OfferedRoute extends RouteAlternative {
|
||||
}
|
||||
|
||||
/**
|
||||
* Which drive on a card a picker is asked about: the leg from stop `index` to the next one.
|
||||
* Which drive on a card a picker is asked about: the leg from stop `index` to the next
|
||||
* one, or the drive `arriving` at the card's first stop from where the day before ended.
|
||||
*
|
||||
* A tagged union rather than a bare index, so the drive arriving at the head of a
|
||||
* connected card can join it as a target of its own instead of borrowing an index no
|
||||
* stop has.
|
||||
* connected card is a target of its own instead of borrowing an index no stop has.
|
||||
*/
|
||||
export type RailDrive = { kind: 'leg'; index: number }
|
||||
export type RailDrive = { kind: 'leg'; index: number } | { kind: 'arriving' }
|
||||
|
||||
/** The drive into a card from the day before. A card has one at most, so it needs no index. */
|
||||
export const ARRIVING_DRIVE: RailDrive = { kind: 'arriving' }
|
||||
|
||||
/** Whether two drives are the same one of their card. */
|
||||
export function sameDrive(a: RailDrive, b: RailDrive): boolean {
|
||||
if (a.kind === 'arriving' || b.kind === 'arriving') return a.kind === b.kind
|
||||
return a.index === b.index
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the picker is open on this drive of this card. The one test the rail, the
|
||||
* planner and the phone ask, so a drive shows pressed exactly where the picker is.
|
||||
*/
|
||||
export function openOn(open: Pick<LegAlternatives, 'dayId' | 'drive'> | null | undefined, dayId: number, drive: RailDrive): boolean {
|
||||
return !!open && open.dayId === dayId && sameDrive(open.drive, drive)
|
||||
}
|
||||
|
||||
/** Where the vias that shape a leg are stored: after stop `afterIndex` of day `dayId`. */
|
||||
export interface ViaAnchor {
|
||||
@@ -27,9 +44,10 @@ export interface ViaAnchor {
|
||||
|
||||
/** Which leg is being reconsidered, and what was offered for it. */
|
||||
export interface LegAlternatives {
|
||||
/** The card the drive is drawn on. */
|
||||
dayId: number
|
||||
/** Index of the leg within the day: the drive from stop `index` to `index + 1`. */
|
||||
index: number
|
||||
/** Which drive of that card: one of its legs, or the drive arriving at its head. */
|
||||
drive: RailDrive
|
||||
/** Where a choice is written. Read off the stop the leg leaves, when it was asked about. */
|
||||
anchor: ViaAnchor
|
||||
/**
|
||||
@@ -52,7 +70,7 @@ export interface LegAlternatives {
|
||||
/** Everything a picker needs to know about the leg it is opened on. */
|
||||
export interface AlternativesRequest {
|
||||
dayId: number
|
||||
index: number
|
||||
drive: RailDrive
|
||||
from: { lat: number; lng: number }
|
||||
to: { lat: number; lng: number }
|
||||
/** The leg as the rail drives it now: its line, its figures, and the engine behind them. */
|
||||
@@ -115,8 +133,8 @@ export function useRouteAlternatives(): RouteAlternativesState {
|
||||
stopProving()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
const { dayId, index, from, to, driven, anchor, ends, router } = request
|
||||
const leg = { dayId, index, anchor, ends, engine: router.engine, route: router.route, proving: null }
|
||||
const { dayId, drive, from, to, driven, anchor, ends, router } = request
|
||||
const leg = { dayId, drive, anchor, ends, engine: router.engine, route: router.route, proving: null }
|
||||
setOpen({ ...leg, routes: [], loading: true, error: false })
|
||||
|
||||
const current: OfferedRoute = {
|
||||
|
||||
@@ -329,10 +329,16 @@ function legText(seg: RouteSegment | undefined, { t, unit }: RowChrome): string
|
||||
* reads as a button that was pressed and stuck. Offline it keeps its place but dims,
|
||||
* because a choice is saved as a via and vias are online only. Without `onAlternatives`
|
||||
* (no permission, or a leg that cannot be rerouted) the column stays empty.
|
||||
*
|
||||
* With `origin` it is the drive in from where the day before ended, at the head of the
|
||||
* card. The stop it leaves is not on this card, so its name goes above the pill; without
|
||||
* it the chain would open on a drive from nowhere.
|
||||
*/
|
||||
export function RtLegRow({ seg, mode, chrome, onAlternatives, alternativesOpen = false, alternativesDisabled = false }: {
|
||||
export function RtLegRow({ seg, mode, origin, chrome, onAlternatives, alternativesOpen = false, alternativesDisabled = false }: {
|
||||
seg: RouteSegment | undefined
|
||||
mode: string | null
|
||||
/** The place the drive leaves from, named when it is not the stop drawn above it. */
|
||||
origin?: string
|
||||
chrome: RowChrome
|
||||
/** Asks for other ways of driving this leg. Absent means the leg offers none. */
|
||||
onAlternatives?: () => void
|
||||
@@ -343,6 +349,12 @@ export function RtLegRow({ seg, mode, chrome, onAlternatives, alternativesOpen =
|
||||
const { t } = chrome
|
||||
const Icon = mode && LEG_ICONS[mode] ? LEG_ICONS[mode] : mode?.startsWith('plugin:') ? Zap : CarFront
|
||||
const text = legText(seg, chrome)
|
||||
const pill = (
|
||||
<span className="my-1.5 flex min-w-0 items-center gap-[7px] rounded-[13px] bg-[color:var(--m-ic)] px-[11px] py-[7px]">
|
||||
<Icon size={14} strokeWidth={2} className="flex-none text-m-muted" aria-hidden="true" />
|
||||
<span className="truncate text-[0.75rem] font-semibold tabular-nums text-m-ink">{text}</span>
|
||||
</span>
|
||||
)
|
||||
return (
|
||||
<div className="grid items-center gap-x-[10px]" style={{ gridTemplateColumns: '34px 1fr auto' }}>
|
||||
{/* flex-col, not flex: in a row the dashes would stretch sideways and read as
|
||||
@@ -351,10 +363,14 @@ export function RtLegRow({ seg, mode, chrome, onAlternatives, alternativesOpen =
|
||||
<span className="flex min-h-[40px] flex-col items-center" aria-hidden="true">
|
||||
<span className="w-[2px] flex-1" style={{ backgroundImage: 'repeating-linear-gradient(var(--m-conn) 0 4px, transparent 4px 8px)' }} />
|
||||
</span>
|
||||
<span className="my-1.5 flex min-w-0 items-center gap-[7px] rounded-[13px] bg-[color:var(--m-ic)] px-[11px] py-[7px]">
|
||||
<Icon size={14} strokeWidth={2} className="flex-none text-m-muted" aria-hidden="true" />
|
||||
<span className="truncate text-[0.75rem] font-semibold tabular-nums text-m-ink">{text}</span>
|
||||
</span>
|
||||
{origin ? (
|
||||
<span className="min-w-0">
|
||||
<span className="mt-1.5 block truncate font-geist text-[0.65625rem] font-semibold text-m-muted">
|
||||
{t('roadtrip.leg.arrivingFrom', { name: origin })}
|
||||
</span>
|
||||
{pill}
|
||||
</span>
|
||||
) : pill}
|
||||
{onAlternatives ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -16,7 +16,8 @@ import { formatDistance } from '../../../../utils/units'
|
||||
import { formatClockTime } from '../../../../utils/formatters'
|
||||
import { isRtlLanguage } from '../../../../i18n'
|
||||
import type { MTripTabPanelProps } from '../MTripShell'
|
||||
import { legReroutable, type StopRow } from '../../../../components/Roadtrip/roadtripRowModel'
|
||||
import { arrivingReroutable, legReroutable, type StopRow } from '../../../../components/Roadtrip/roadtripRowModel'
|
||||
import { ARRIVING_DRIVE, type RailDrive } from '../../../../components/Roadtrip/useRouteAlternatives'
|
||||
import { dayBookings } from '../../../../components/Roadtrip/stopBookings'
|
||||
import { getDayOrder } from '../../../../utils/dayOrder'
|
||||
import type { Reservation } from '../../../../types'
|
||||
@@ -276,18 +277,22 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
if (row.kind === 'leg') {
|
||||
// Only where the desk rail offers it too (legReroutable), and with the
|
||||
// card's day id, the one the desk passes: the planner finds the day each
|
||||
// stop is stored on by itself.
|
||||
if (row.kind === 'leg' || row.kind === 'arriving') {
|
||||
// Only where the desk rail offers it too (legReroutable, arrivingReroutable),
|
||||
// and with the card's day id, the one the desk passes: the planner finds the
|
||||
// day each stop is stored on by itself.
|
||||
const leg = row.kind === 'leg'
|
||||
const drive: RailDrive = leg ? { kind: 'leg', index: row.index } : ARRIVING_DRIVE
|
||||
const reroutable = leg ? legReroutable(stage, row.index) : arrivingReroutable(stage)
|
||||
return (
|
||||
<RtLegRow
|
||||
key={`l${i}`}
|
||||
seg={row.seg}
|
||||
mode={row.mode}
|
||||
origin={leg ? undefined : row.from.name}
|
||||
chrome={chrome}
|
||||
onAlternatives={alts.canAsk && legReroutable(stage, row.index) ? () => alts.ask(stage.dayId, row.index) : undefined}
|
||||
alternativesOpen={alts.isOpenFor(stage.dayId, row.index)}
|
||||
onAlternatives={alts.canAsk && reroutable ? () => alts.ask(stage.dayId, drive) : undefined}
|
||||
alternativesOpen={alts.isOpenFor(stage.dayId, drive)}
|
||||
alternativesDisabled={!alts.editable}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useTransition } from 'react'
|
||||
import { alternativesBusy, alternativesPhase, type AlternativeOverlay, type AlternativesPhase } from '../../../../components/Roadtrip/alternativeOverlays'
|
||||
import type { LegAlternatives } from '../../../../components/Roadtrip/useRouteAlternatives'
|
||||
import { openOn, type LegAlternatives, type RailDrive } from '../../../../components/Roadtrip/useRouteAlternatives'
|
||||
import type { MTripShellApi, TripPlanner } from '../MTripShell'
|
||||
|
||||
/**
|
||||
@@ -39,8 +39,9 @@ export interface MRtAlternativesController {
|
||||
canConfirm: boolean
|
||||
/** True while the chosen road is being checked against the router and written. */
|
||||
saving: boolean
|
||||
isOpenFor: (dayId: number, legIndex: number) => boolean
|
||||
ask: (dayId: number, legIndex: number) => void
|
||||
/** True while the picker is open on this drive of this card: a leg, or the drive in. */
|
||||
isOpenFor: (dayId: number, drive: RailDrive) => boolean
|
||||
ask: (dayId: number, drive: RailDrive) => void
|
||||
pick: (index: number) => void
|
||||
confirm: () => void
|
||||
cancel: () => void
|
||||
@@ -80,7 +81,7 @@ export function useMRtAlternatives(planner: TripPlanner, shell: MTripShellApi):
|
||||
// picker, which the close button already does without pretending to save.
|
||||
const canConfirm = !!picked && phase === 'choose' && !open?.routes[picked.index]?.current && editable && !saving
|
||||
|
||||
const isOpenFor = (dayId: number, legIndex: number) => open?.dayId === dayId && open.index === legIndex
|
||||
const isOpenFor = (dayId: number, drive: RailDrive) => openOn(open, dayId, drive)
|
||||
|
||||
// The day chips, a swipe and the all days switch all move the stage under an open
|
||||
// picker. `close` is the planner's stable callback, so this runs when the leg or the
|
||||
@@ -90,11 +91,11 @@ export function useMRtAlternatives(planner: TripPlanner, shell: MTripShellApi):
|
||||
if (open && open.dayId !== selectedDayId) close()
|
||||
}, [open, selectedDayId, close])
|
||||
|
||||
const ask = (dayId: number, legIndex: number) => {
|
||||
const ask = (dayId: number, drive: RailDrive) => {
|
||||
// The planner's own ask toggles a leg that is already open, which is the desk
|
||||
// button's second click. Here the pressed button on the chain is the way back to the
|
||||
// answer on the map, so it must not close what it is pointing at.
|
||||
if (!isOpenFor(dayId, legIndex)) {
|
||||
if (!isOpenFor(dayId, drive)) {
|
||||
// A pick belongs to the leg it was made on. Carried over, it would light a road
|
||||
// on the next leg by its position in a list that leg never offered.
|
||||
planner.setHighlightedAlternative(null)
|
||||
@@ -103,7 +104,7 @@ export function useMRtAlternatives(planner: TripPlanner, shell: MTripShellApi):
|
||||
planner.refuel.close()
|
||||
// The card's day, as the desk passes it: the planner looks the stops up on that
|
||||
// card and works out the day each one is stored on by itself.
|
||||
planner.askRouteAlternatives(dayId, { kind: 'leg', index: legIndex })
|
||||
planner.askRouteAlternatives(dayId, drive)
|
||||
}
|
||||
if (shell.rtView === 'list') shell.toggleRtView()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { roadtripPreferencesRepo } from '../../repo/roadtripPreferencesRepo'
|
||||
// FE-TP-ROAD-001 to FE-TP-ROAD-127
|
||||
// FE-TP-ROAD-001 to FE-TP-ROAD-134
|
||||
import React from 'react'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { TranslationProvider } from '../../i18n/TranslationContext'
|
||||
@@ -163,7 +163,10 @@ vi.mock('../../components/Roadtrip/useRoadtripRoutes', () => ({
|
||||
useRoadtripRoutes: (...args: unknown[]) => { rt.routesArgs.current = args; return rt.routes },
|
||||
}))
|
||||
vi.mock('../../components/Roadtrip/useRoadtripCorridor', () => ({ useRoadtripCorridor: () => rt.corridor }))
|
||||
vi.mock('../../components/Roadtrip/useRouteAlternatives', () => ({
|
||||
// Only the hook is a fixture. The drive helpers beside it stay real, since which drive a
|
||||
// picker is open on is part of what these cases are about.
|
||||
vi.mock('../../components/Roadtrip/useRouteAlternatives', async importOriginal => ({
|
||||
...(await importOriginal<typeof import('../../components/Roadtrip/useRouteAlternatives')>()),
|
||||
useRouteAlternatives: () => (rt.altFresh.current ? { ...rt.alt } : rt.alt),
|
||||
}))
|
||||
vi.mock('../../components/Roadtrip/useFollowTrack', () => ({
|
||||
@@ -319,6 +322,7 @@ beforeEach(() => {
|
||||
rt.routes.legRouter.mockReset().mockImplementation(() => ({ mode: 'driving', avoid: [], engine: 'osrm', route: rt.legRoute }))
|
||||
rt.alt.prove.mockReset().mockImplementation(() => new AbortController().signal)
|
||||
rt.routes.days = []
|
||||
rt.routes.quietDays = []
|
||||
rt.routesArgs.current = []
|
||||
rt.routes.lines = []
|
||||
rt.routes.lineDays = []
|
||||
@@ -924,7 +928,7 @@ describe('useTripPlanner road trip: other ways of driving a leg', () => {
|
||||
/** The picker as `useRouteAlternatives` leaves it once asked: the rail's road, then the offers. */
|
||||
const openWith = (offers: Array<Record<string, unknown>>, over: Record<string, unknown> = {}) => {
|
||||
rt.alt.open = {
|
||||
dayId: 5, index: 0, loading: false, error: false, proving: null,
|
||||
dayId: 5, drive: { kind: 'leg', index: 0 }, loading: false, error: false, proving: null,
|
||||
anchor: { dayId: 5, afterIndex: 0 },
|
||||
ends: { from: 11, to: 12 },
|
||||
engine: 'osrm',
|
||||
@@ -955,7 +959,7 @@ describe('useTripPlanner road trip: other ways of driving a leg', () => {
|
||||
const [request] = rt.alt.ask.mock.calls[0] as [Record<string, unknown>]
|
||||
expect(request).toMatchObject({
|
||||
dayId: 5,
|
||||
index: 0,
|
||||
drive: { kind: 'leg', index: 0 },
|
||||
from: { lat: 53.55, lng: 9.99 },
|
||||
to: { lat: 52.52, lng: 13.4 },
|
||||
// "Current" is the rail's own leg: its line, its figures, nothing asked for again.
|
||||
@@ -971,7 +975,7 @@ describe('useTripPlanner road trip: other ways of driving a leg', () => {
|
||||
|
||||
it('FE-TP-ROAD-035: asking again for the leg already open closes it instead', async () => {
|
||||
routedDay()
|
||||
rt.alt.open = { dayId: 5, index: 0, routes: [], loading: false, error: false }
|
||||
rt.alt.open = { dayId: 5, drive: { kind: 'leg', index: 0 }, routes: [], loading: false, error: false }
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
act(() => { result.current.askRouteAlternatives(5, { kind: 'leg', index: 0 }) })
|
||||
@@ -1075,7 +1079,7 @@ describe('useTripPlanner road trip: other ways of driving a leg', () => {
|
||||
|
||||
it('FE-TP-ROAD-042: choosing an index nothing was offered at does nothing', async () => {
|
||||
routedDay()
|
||||
rt.alt.open = { dayId: 5, index: 0, routes: [], loading: false, error: false }
|
||||
rt.alt.open = { dayId: 5, drive: { kind: 'leg', index: 0 }, routes: [], loading: false, error: false }
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
await act(async () => { await result.current.chooseRouteAlternative(3) })
|
||||
@@ -1288,7 +1292,7 @@ describe('useTripPlanner road trip: other ways of driving a leg', () => {
|
||||
it('FE-TP-ROAD-043: the overlays and the frame come from the open leg, and empty when it closes', async () => {
|
||||
routedDay()
|
||||
rt.alt.open = {
|
||||
dayId: 5, index: 0, loading: false, error: false,
|
||||
dayId: 5, drive: { kind: 'leg', index: 0 }, loading: false, error: false,
|
||||
routes: [
|
||||
{ coordinates: [[53.5, 10], [52.5, 13]], distance: 290_000, duration: 10_800, divergence: { lat: 53, lng: 11 } },
|
||||
{ coordinates: [[53.5, 10], [53, 12], [52.5, 13]], distance: 310_000, duration: 11_400, divergence: { lat: 53, lng: 12 } },
|
||||
@@ -1307,7 +1311,7 @@ describe('useTripPlanner road trip: other ways of driving a leg', () => {
|
||||
// the overlay depends only on the picker: flipping the mode off left pale blue
|
||||
// alternatives drawn on an ordinary planner map with no way to dismiss them.
|
||||
routedDay()
|
||||
rt.alt.open = { dayId: 5, index: 0, routes: [], loading: false, error: false }
|
||||
rt.alt.open = { dayId: 5, drive: { kind: 'leg', index: 0 }, routes: [], loading: false, error: false }
|
||||
const { result } = await renderRoadtrip()
|
||||
rt.alt.close.mockClear()
|
||||
|
||||
@@ -1319,7 +1323,7 @@ describe('useTripPlanner road trip: other ways of driving a leg', () => {
|
||||
|
||||
it('FE-TP-ROAD-045: closing the picker clears the road it had lit up', async () => {
|
||||
routedDay()
|
||||
rt.alt.open = { dayId: 5, index: 0, routes: [], loading: false, error: false }
|
||||
rt.alt.open = { dayId: 5, drive: { kind: 'leg', index: 0 }, routes: [], loading: false, error: false }
|
||||
const { result, rerender } = await renderRoadtrip()
|
||||
|
||||
act(() => { result.current.setHighlightedAlternative(1) })
|
||||
@@ -2103,7 +2107,7 @@ describe('useTripPlanner road trip: somewhere to fill up', () => {
|
||||
// be judged. Averaging the two frames would have shown neither properly.
|
||||
drivenCard()
|
||||
rt.alt.open = {
|
||||
dayId: 6, index: 0, loading: false, error: false,
|
||||
dayId: 6, drive: { kind: 'leg', index: 0 }, loading: false, error: false,
|
||||
// Two of them, because one road is not a choice and the picker draws nothing for it.
|
||||
routes: [
|
||||
{ coordinates: [[53, 10], [51, 10]], distance: 222_000, duration: 8_400, divergence: { lat: 52, lng: 10 } },
|
||||
@@ -2401,7 +2405,7 @@ describe('useTripPlanner road trip: the phone feed', () => {
|
||||
// whatever identity the hook keeps.
|
||||
seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] })
|
||||
sessionStorage.setItem('trip-tab-42', 'roadtrip')
|
||||
rt.alt.open = { dayId: 5, index: 0, routes: [], loading: true, error: false }
|
||||
rt.alt.open = { dayId: 5, drive: { kind: 'leg', index: 0 }, routes: [], loading: true, error: false }
|
||||
rt.altFresh.current = true
|
||||
|
||||
const { result, rerender } = await renderPhone()
|
||||
@@ -2419,7 +2423,7 @@ describe('useTripPlanner road trip: the phone feed', () => {
|
||||
// a tab that no longer shows the bar must not leave the other roads drawn on its map.
|
||||
seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] })
|
||||
sessionStorage.setItem('trip-tab-42', 'roadtrip')
|
||||
rt.alt.open = { dayId: 5, index: 0, routes: [], loading: false, error: false }
|
||||
rt.alt.open = { dayId: 5, drive: { kind: 'leg', index: 0 }, routes: [], loading: false, error: false }
|
||||
|
||||
const { result } = await renderPhone()
|
||||
rt.alt.close.mockClear()
|
||||
@@ -2804,3 +2808,200 @@ describe('useTripPlanner road trip: a ride on the drive (#2428)', () => {
|
||||
expect(result.current.roadtripConnections).toContain(70)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useTripPlanner road trip: the drive between two days (#2461)', () => {
|
||||
/** The road the rail drives from the last stop of day 5 to the first of day 6, and one north of it. */
|
||||
const SEAM_LINE: [number, number][] = [[53.25, 10.41], [52.9, 12.0], [52.52, 13.4]]
|
||||
const NORTH: [number, number][] = [[53.25, 10.41], [53.3, 11.6], [53.0, 12.9], [52.52, 13.4]]
|
||||
|
||||
const lastOfDay5 = { assignmentId: 13, placeId: 1013, name: 'Lüneburg', lat: 53.25, lng: 10.41, ownerDayId: 5, ownerIndex: 2 }
|
||||
const firstOfDay6 = { assignmentId: 21, placeId: 1021, name: 'Berlin', lat: 52.52, lng: 13.4, ownerDayId: 6, ownerIndex: 0 }
|
||||
|
||||
/**
|
||||
* Two connected cards. Day 5 ends on its third stop, so the drive into day 6 is filed
|
||||
* behind index 2 of day 5, which nothing on card 6 could name.
|
||||
*/
|
||||
const connectedCards = () => {
|
||||
seedTrip({ days: [buildDay({ id: 5, day_number: 1 }), buildDay({ id: 6, day_number: 2 })] })
|
||||
rt.corridor.day = { dayId: 6, dayNumber: 2 }
|
||||
rt.routes.days = [
|
||||
{
|
||||
dayId: 5,
|
||||
dayNumber: 1,
|
||||
stops: [
|
||||
{ assignmentId: 11, placeId: 1011, lat: 53.55, lng: 9.99 },
|
||||
{ assignmentId: 12, placeId: 1012, lat: 53.4, lng: 10.2 },
|
||||
lastOfDay5,
|
||||
],
|
||||
legs: [{ distance: 20_000, duration: 1_200 }, { distance: 25_000, duration: 1_500 }],
|
||||
geometry: [[53.55, 9.99], [53.4, 10.2], [53.25, 10.41]],
|
||||
},
|
||||
{
|
||||
dayId: 6,
|
||||
dayNumber: 2,
|
||||
stops: [firstOfDay6, { assignmentId: 22, placeId: 1022, lat: 52.3, lng: 14.0 }],
|
||||
legs: [{ distance: 60_000, duration: 3_000 }],
|
||||
arrivingFrom: lastOfDay5,
|
||||
arrivingLeg: { distance: 280_000, duration: 10_000 },
|
||||
arrivingLine: SEAM_LINE,
|
||||
geometry: [...SEAM_LINE, [52.3, 14.0]],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const openOnSeam = (over: Record<string, unknown> = {}) => {
|
||||
rt.alt.open = {
|
||||
dayId: 6, drive: { kind: 'arriving' }, loading: false, error: false, proving: null,
|
||||
anchor: { dayId: 5, afterIndex: 2 },
|
||||
ends: { from: 13, to: 21 },
|
||||
engine: 'osrm',
|
||||
route: rt.legRoute,
|
||||
routes: [
|
||||
{ coordinates: SEAM_LINE, distance: 280_000, duration: 10_000, divergence: null, current: true },
|
||||
{ coordinates: NORTH, distance: 300_000, duration: 10_800, divergence: null },
|
||||
],
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
it('FE-TP-ROAD-128: asking about the drive in hands over the pair from yesterday, filed behind the stop it leaves', async () => {
|
||||
connectedCards()
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
act(() => { result.current.askRouteAlternatives(6, { kind: 'arriving' }) })
|
||||
|
||||
const [request] = rt.alt.ask.mock.calls[0] as [Record<string, unknown>]
|
||||
expect(request).toMatchObject({
|
||||
dayId: 6,
|
||||
drive: { kind: 'arriving' },
|
||||
from: { lat: 53.25, lng: 10.41 },
|
||||
to: { lat: 52.52, lng: 13.4 },
|
||||
// The road the rail drives across the join, as the current way.
|
||||
driven: { coordinates: SEAM_LINE, distance: 280_000, duration: 10_000 },
|
||||
// Day 5, behind its last stop: where the map files a point dropped on this drive.
|
||||
anchor: { dayId: 5, afterIndex: 2 },
|
||||
ends: { from: 13, to: 21 },
|
||||
})
|
||||
// The seam's own router, asked under the card it arrives on.
|
||||
const [from, to, cardDayId] = rt.routes.legRouter.mock.calls[0] as [{ assignmentId: number }, { assignmentId: number }, number]
|
||||
expect([from.assignmentId, to.assignmentId, cardDayId]).toEqual([13, 21, 6])
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-129: a card with no drive in asks nothing, and asking again closes the picker on it', async () => {
|
||||
connectedCards()
|
||||
const { result, rerender } = await renderRoadtrip()
|
||||
|
||||
act(() => { result.current.askRouteAlternatives(5, { kind: 'arriving' }) })
|
||||
expect(rt.alt.ask).not.toHaveBeenCalled()
|
||||
|
||||
// Open on the drive in: the first leg of the same card is another drive.
|
||||
openOnSeam()
|
||||
rerender()
|
||||
act(() => { result.current.askRouteAlternatives(6, { kind: 'leg', index: 0 }) })
|
||||
expect(rt.alt.ask).toHaveBeenCalledTimes(1)
|
||||
rt.alt.close.mockClear()
|
||||
|
||||
act(() => { result.current.askRouteAlternatives(6, { kind: 'arriving' }) })
|
||||
expect(rt.alt.close).toHaveBeenCalled()
|
||||
expect(rt.alt.ask).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-130: a way chosen for the drive in is proven, then written behind the last stop of the day before', async () => {
|
||||
connectedCards()
|
||||
openOnSeam()
|
||||
rt.legRoute.mockResolvedValue({ coordinates: NORTH, distance: 300_000, duration: 10_800, fellBack: false })
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
await act(async () => { await result.current.chooseRouteAlternative(1) })
|
||||
|
||||
expect(rt.legRoute).toHaveBeenCalledTimes(1)
|
||||
const [pins] = rt.legRoute.mock.calls[0] as [Array<{ lat: number; lng: number }>]
|
||||
expect(pins).toHaveLength(1)
|
||||
// Replacing what bent that drive before, on day 5 and not on the card it is drawn on.
|
||||
expect(rt.vias.addMany).toHaveBeenCalledWith(5, [{ after_order_index: 2, ...pins[0] }], [2])
|
||||
expect(rt.alt.close).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-131: taking the router own road on a bent drive in clears it in one write', async () => {
|
||||
connectedCards()
|
||||
rt.vias.byDay = { 5: [via(9, 5, 2, 0, 53.4, 11.8)] }
|
||||
openOnSeam({
|
||||
routes: [
|
||||
{ coordinates: NORTH, distance: 300_000, duration: 10_800, divergence: null, current: true },
|
||||
{ coordinates: SEAM_LINE, distance: 280_000, duration: 10_000, divergence: null, direct: true },
|
||||
],
|
||||
})
|
||||
rt.legRoute.mockResolvedValue({ coordinates: SEAM_LINE, distance: 280_000, duration: 10_000, fellBack: false })
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
await act(async () => { await result.current.chooseRouteAlternative(1) })
|
||||
|
||||
expect(rt.legRoute.mock.calls[0][0]).toEqual([])
|
||||
expect(rt.vias.addMany).toHaveBeenCalledWith(5, [], [2])
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-134: a drive in that leaves a day of a single stop is written behind that stop', async () => {
|
||||
// Such a day draws no card, so the cards alone do not hold the stop the drive in leaves
|
||||
// from. Checked against them only, every choice on this drive was refused as a leg that
|
||||
// had changed.
|
||||
seedTrip({ days: [buildDay({ id: 5, day_number: 1 }), buildDay({ id: 6, day_number: 2 })] })
|
||||
rt.corridor.day = { dayId: 6, dayNumber: 2 }
|
||||
const lone = { ...lastOfDay5, ownerIndex: 0 }
|
||||
rt.routes.quietDays = [{ dayId: 5, dayNumber: 1, date: null, title: null, stops: [lone] }]
|
||||
rt.routes.days = [{
|
||||
dayId: 6,
|
||||
dayNumber: 2,
|
||||
stops: [firstOfDay6, { assignmentId: 22, placeId: 1022, lat: 52.3, lng: 14.0 }],
|
||||
legs: [{ distance: 60_000, duration: 3_000 }],
|
||||
arrivingFrom: lone,
|
||||
arrivingLeg: { distance: 280_000, duration: 10_000 },
|
||||
arrivingLine: SEAM_LINE,
|
||||
geometry: [...SEAM_LINE, [52.3, 14.0]],
|
||||
}]
|
||||
openOnSeam({ anchor: { dayId: 5, afterIndex: 0 } })
|
||||
rt.legRoute.mockResolvedValue({ coordinates: NORTH, distance: 300_000, duration: 10_800, fellBack: false })
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
await act(async () => { await result.current.chooseRouteAlternative(1) })
|
||||
|
||||
const [pins] = rt.legRoute.mock.calls[0] as [Array<{ lat: number; lng: number }>]
|
||||
expect(rt.vias.addMany).toHaveBeenCalledWith(5, [{ after_order_index: 0, ...pins[0] }], [0])
|
||||
expect(toasts).not.toContainEqual(expect.objectContaining({ message: 'This leg changed while the way was being checked, so nothing was saved.' }))
|
||||
})
|
||||
|
||||
/** Day 5 with three stops, a via on its first leg, and one behind its last: the drive into day 6. */
|
||||
const threeStops = () => {
|
||||
seedTrip({
|
||||
days: [buildDay({ id: 5, day_number: 1 }), buildDay({ id: 6, day_number: 2 })],
|
||||
assignments: {
|
||||
'5': [stopAt(11, 5, 0), stopAt(12, 5, 1), stopAt(13, 5, 2)],
|
||||
'6': [stopAt(21, 6, 0)],
|
||||
},
|
||||
})
|
||||
rt.corridor.day = { dayId: 5, dayNumber: 1 }
|
||||
rt.vias.byDay = { 5: [via(1, 5, 0), via(9, 5, 2, 0, 53.4, 11.8)] }
|
||||
}
|
||||
|
||||
it('FE-TP-ROAD-132: a drag under Days that leaves the last stop last keeps the drive into the next day', async () => {
|
||||
// It used to be deleted: measured as a leg, the last stop leads nowhere.
|
||||
threeStops()
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
await act(async () => result.current.handleReorder(5, [12, 11, 13]))
|
||||
|
||||
await waitFor(() => expect(rt.vias.reanchor).toHaveBeenCalledWith(5, { vias: [{ id: 1, after_order_index: 1 }], remove: [] }))
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-133: a last stop taken out or dragged up the day takes the drive behind it along', async () => {
|
||||
// Left where it was, the via sat on a number no stop has and bent nothing, or bent
|
||||
// the drive of whichever stop came to stand there.
|
||||
threeStops()
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
await act(async () => { await result.current.reorderRoadtripStop(5, 13, 0) })
|
||||
expect(rt.vias.reanchor).toHaveBeenLastCalledWith(5, { vias: [{ id: 1, after_order_index: 1 }], remove: [9] })
|
||||
|
||||
await act(async () => { await result.current.handleRemoveAssignment(5, 13) })
|
||||
expect(rt.vias.reanchor).toHaveBeenLastCalledWith(5, { vias: [], remove: [9] })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -41,9 +41,9 @@ import { useRoadtripVias } from '../../components/Roadtrip/useRoadtripVias'
|
||||
import { useRefuelSearch } from '../../components/Roadtrip/useRefuelSearch'
|
||||
import type { RefuelCandidate } from '../../components/Roadtrip/refuelSuggestion'
|
||||
import { useFollowTrack } from '../../components/Roadtrip/useFollowTrack'
|
||||
import { useRouteAlternatives, type RailDrive } from '../../components/Roadtrip/useRouteAlternatives'
|
||||
import { openOn, useRouteAlternatives, type RailDrive } from '../../components/Roadtrip/useRouteAlternatives'
|
||||
import { alternativesBusy, buildAlternativeOverlays } from '../../components/Roadtrip/alternativeOverlays'
|
||||
import { pinAlternative, railLegAt, type PinProof } from '../../components/Roadtrip/alternativePins'
|
||||
import { pinAlternative, railDriveOn, railLegAt, type PinProof } from '../../components/Roadtrip/alternativePins'
|
||||
import { stopArrival } from '../../components/Roadtrip/stopArrival'
|
||||
import type { CorridorPoi } from '../../components/Roadtrip/useCorridorPois'
|
||||
import { projectOntoRoute, sliceAtMeters, type LatLng } from '../../components/Roadtrip/corridor'
|
||||
@@ -1584,8 +1584,12 @@ export function useTripPlanner() {
|
||||
* The rail as it stands now, for a choice that is written several router answers after
|
||||
* the render that started it. Checked against before anything is written.
|
||||
*/
|
||||
const railDaysRef = useRef(roadtripRoutes.days)
|
||||
useEffect(() => { railDaysRef.current = roadtripRoutes.days }, [roadtripRoutes.days])
|
||||
const railDaysRef = useRef<Parameters<typeof railLegAt>[0]>(roadtripRoutes.days)
|
||||
// The days with a single stop as well, in day order: they draw no card, but a drive
|
||||
// into the day after one leaves from its stop, and a choice for that drive is filed there.
|
||||
useEffect(() => {
|
||||
railDaysRef.current = [...roadtripRoutes.days, ...roadtripRoutes.quietDays].sort((a, b) => a.dayNumber - b.dayNumber)
|
||||
}, [roadtripRoutes.days, roadtripRoutes.quietDays])
|
||||
|
||||
/**
|
||||
* Asks the rail's own router for other ways of one drive on a card.
|
||||
@@ -1593,26 +1597,32 @@ export function useTripPlanner() {
|
||||
* The picker is handed everything about the leg as the rail has it: the road it is on
|
||||
* now, which heads the list as the current one; where a choice would be written; and
|
||||
* the router with the leg's own mode and avoided classes, not the trip-wide profile.
|
||||
*
|
||||
* The drive arriving at the head of a connected card is asked about the same way, as
|
||||
* the pair from the last stop of the day before to the card's first. Its router is the
|
||||
* one the rail drives that seam with, under the card it arrives on, and a choice is
|
||||
* filed behind the stop it leaves, where the map already files a point dropped on it.
|
||||
* It used to be the one drive on the rail that could not be offered another way.
|
||||
*/
|
||||
const askRouteAlternatives = useCallback((dayId: number, drive: RailDrive) => {
|
||||
const day = roadtripRoutes.days.find(d => d.dayId === dayId)
|
||||
const from = day?.stops[drive.index]
|
||||
const to = day?.stops[drive.index + 1]
|
||||
const leg = day?.legs[drive.index]
|
||||
const router = from && to ? roadtripRoutes.legRouter?.(from, to, dayId) : undefined
|
||||
if (!day || !from || !to || !leg || !router) return
|
||||
if (routeAlternatives.open?.dayId === dayId && routeAlternatives.open.index === drive.index) {
|
||||
const found = day ? railDriveOn(day, drive) : null
|
||||
const router = found ? roadtripRoutes.legRouter?.(found.from, found.to, dayId) : undefined
|
||||
if (!found || !router) return
|
||||
if (openOn(routeAlternatives.open, dayId, drive)) {
|
||||
routeAlternatives.close()
|
||||
return
|
||||
}
|
||||
const { from, to, seg, line } = found
|
||||
routeAlternatives.ask({
|
||||
dayId,
|
||||
index: drive.index,
|
||||
drive,
|
||||
from: { lat: from.lat, lng: from.lng },
|
||||
to: { lat: to.lat, lng: to.lng },
|
||||
driven: { coordinates: day.legLines?.[drive.index] ?? [], distance: leg.distance, duration: leg.duration },
|
||||
driven: { coordinates: line ?? [], distance: seg.distance, duration: seg.duration },
|
||||
// The vias of a leg are filed behind the stop it leaves, on the day that stop is
|
||||
// stored on, which on a card holding a night drive is not the card's own day.
|
||||
// stored on, which on a card holding a night drive or a drive in from yesterday is
|
||||
// not the card's own day.
|
||||
anchor: { dayId: from.ownerDayId, afterIndex: from.ownerIndex },
|
||||
ends: { from: from.assignmentId, to: to.assignmentId },
|
||||
router,
|
||||
|
||||
@@ -10,12 +10,12 @@ import type { LegAlternatives } from '../../src/components/Roadtrip/useRouteAlte
|
||||
export function openLeg(over: Partial<LegAlternatives> = {}): LegAlternatives {
|
||||
return {
|
||||
dayId: 2,
|
||||
index: 0,
|
||||
drive: { kind: 'leg', index: 0 },
|
||||
routes: [],
|
||||
loading: false,
|
||||
error: false,
|
||||
proving: null,
|
||||
anchor: { dayId: over.dayId ?? 2, afterIndex: over.index ?? 0 },
|
||||
anchor: { dayId: over.dayId ?? 2, afterIndex: over.drive?.kind === 'leg' ? over.drive.index : 0 },
|
||||
ends: { from: 1, to: 2 },
|
||||
engine: 'osrm',
|
||||
route: vi.fn(),
|
||||
|
||||
@@ -145,7 +145,7 @@ const DRAWN = ['places', 'route', 'routeColors', 'accessLines', 'focusPoints'] a
|
||||
/** A picker open on leg 0 of day 3, with or without roads back yet. */
|
||||
function withPicker(overlays: AlternativeOverlay[], over: Partial<TripPlanner> = {}): Partial<TripPlanner> {
|
||||
const base = buildPlanner()
|
||||
const open: LegAlternatives = openLeg({ dayId: 3, index: 0, loading: overlays.length === 0 })
|
||||
const open: LegAlternatives = openLeg({ dayId: 3, drive: { kind: 'leg', index: 0 }, loading: overlays.length === 0 })
|
||||
return {
|
||||
routeAlternatives: { ...base.routeAlternatives, open },
|
||||
alternativeOverlays: overlays,
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { Reservation, TranslationFn } from '../../../../src/types'
|
||||
import type { RefuelSearch } from '../../../../src/components/Roadtrip/useRefuelSearch'
|
||||
import type { RefuelCandidate } from '../../../../src/components/Roadtrip/refuelSuggestion'
|
||||
|
||||
// FE-MOB-RTROW-001 to FE-MOB-RTROW-055
|
||||
// FE-MOB-RTROW-001 to FE-MOB-RTROW-056
|
||||
|
||||
// Same echo strategy as tests/helpers/mobileTrip: assertions stay on keys, not copy.
|
||||
const t: TranslationFn = (key, params) =>
|
||||
@@ -492,6 +492,20 @@ describe('RtLegRow', () => {
|
||||
expect(open.className).not.toContain('bg-m-act')
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-056: the drive in from the day before names where it leaves, above its pill', () => {
|
||||
// The stop it leaves is on the card before, so the chain would open on a drive from
|
||||
// nowhere without it. Only a leg given an origin says one.
|
||||
const onAlternatives = vi.fn()
|
||||
render(<RtLegRow seg={SEG} mode="driving" origin="Hakone" chrome={chrome} onAlternatives={onAlternatives} />)
|
||||
|
||||
const origin = screen.getByText('roadtrip.leg.arrivingFrom:Hakone')
|
||||
const drive = screen.getByText('roadtrip.leg.driveText:210 km,2 h 40 min')
|
||||
expect(origin.compareDocumentPosition(drive) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
expect(origin.closest('button')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'roadtrip.alt.ask' }))
|
||||
expect(onAlternatives).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-040: offline the button keeps its place but does nothing', () => {
|
||||
const onAlternatives = vi.fn()
|
||||
render(<RtLegRow seg={SEG} mode="driving" chrome={chrome} onAlternatives={onAlternatives} alternativesDisabled />)
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { CarrierTerminal, RoadtripDay, RoadtripRoutes, RoadtripStop, RouteS
|
||||
import type { LegAlternatives } from '../../../../src/components/Roadtrip/useRouteAlternatives'
|
||||
import { openLeg } from '../../../helpers/legAlternatives'
|
||||
|
||||
// FE-MOB-RTTAB-001 to FE-MOB-RTTAB-057
|
||||
// FE-MOB-RTTAB-001 to FE-MOB-RTTAB-060
|
||||
//
|
||||
// The stage bar pictures the place its day ends at. It reads that place out of the trip
|
||||
// store rather than the planner, the unfiltered list, so the picture tests seed the store.
|
||||
@@ -625,7 +625,7 @@ describe('MRoadtripTab', () => {
|
||||
function withPicker(open: Partial<LegAlternatives>, over: Partial<TripPlanner> = {}): TripPlanner {
|
||||
const base = buildPlanner()
|
||||
return planner({
|
||||
routeAlternatives: { ...base.routeAlternatives, open: openLeg({ dayId: 2, index: 0, loading: true, ...open }) },
|
||||
routeAlternatives: { ...base.routeAlternatives, open: openLeg({ dayId: 2, drive: { kind: 'leg', index: 0 }, loading: true, ...open }) },
|
||||
...over,
|
||||
})
|
||||
}
|
||||
@@ -676,7 +676,7 @@ describe('MRoadtripTab', () => {
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-047: the leg with the picker open shows pressed, and tapping it goes back to the answer without asking again', () => {
|
||||
const p = withPicker({ index: 0 })
|
||||
const p = withPicker({ drive: { kind: 'leg', index: 0 } })
|
||||
const { shell } = renderTab(p)
|
||||
|
||||
const [open, other] = askButtons()
|
||||
@@ -728,11 +728,66 @@ describe('MRoadtripTab', () => {
|
||||
first.unmount()
|
||||
|
||||
// The chain is a look back at the stage, not a way out of the question.
|
||||
const here = withPicker({ dayId: 2, index: 1 })
|
||||
const here = withPicker({ dayId: 2, drive: { kind: 'leg', index: 1 } })
|
||||
renderTab(here)
|
||||
expect(here.routeAlternatives.close).not.toHaveBeenCalled()
|
||||
expect(askButtons()[1]).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
/**
|
||||
* The stage joined to the day before by the road from where that day ended, with no
|
||||
* night drive between them: the drive in is drawn at the head of this card.
|
||||
*/
|
||||
const joinedStage = () => stage({
|
||||
spills: [],
|
||||
arrivingLeg: seg('95 km', '1 h 10 min'),
|
||||
arrivingFrom: {
|
||||
assignmentId: 499, ownerDayId: 1, ownerIndex: 2, placeId: 99, name: 'Hakone',
|
||||
lat: 35.23, lng: 139.02, time: null, dwellMinutes: null,
|
||||
legMode: null, incomingLegMode: null, stopType: null,
|
||||
},
|
||||
arrivingLine: [[35.23, 139.02], [35.36, 138.73]],
|
||||
} as Partial<RoadtripDay>)
|
||||
const joined = (over: Partial<TripPlanner> = {}) => planner({ roadtripRoutes: routes({ days: [joinedStage()] }), ...over })
|
||||
|
||||
it('FE-MOB-RTTAB-058: the drive in from the day before heads the chain, names where it leaves, and asks as the drive it is', () => {
|
||||
// #2461: the phone had no row for this drive at all, so it could neither be read
|
||||
// nor offered another way.
|
||||
const p = joined()
|
||||
renderTab(p)
|
||||
|
||||
const origin = screen.getByText('roadtrip.leg.arrivingFrom:Hakone')
|
||||
expect(screen.getByText('roadtrip.leg.driveText:95 km,1 h 10 min')).toBeInTheDocument()
|
||||
// Above the first stop, where the desk rail draws its band.
|
||||
expect(origin.compareDocumentPosition(screen.getByText('Fuji Viewpoint')) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
// A drive, not a stop: the count and the numbering stay as they were.
|
||||
expect(screen.getByText('roadtrip.day.stopCount:2')).toBeInTheDocument()
|
||||
expect(screen.queryByText('3')).toBeNull()
|
||||
|
||||
// The drive in, then the two legs the stage offered before.
|
||||
expect(askButtons()).toHaveLength(3)
|
||||
fireEvent.click(askButtons()[0])
|
||||
expect(p.askRouteAlternatives).toHaveBeenCalledWith(2, { kind: 'arriving' })
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-059: the drive in and the first leg are told apart while a picker is open on either', () => {
|
||||
const onDriveIn = withPicker({ drive: { kind: 'arriving' } }, { roadtripRoutes: routes({ days: [joinedStage()] }) })
|
||||
const first = renderTab(onDriveIn)
|
||||
expect(askButtons().map(b => b.getAttribute('aria-pressed'))).toEqual(['true', 'false', 'false'])
|
||||
// Pressed, it goes back to the answer instead of asking again.
|
||||
fireEvent.click(askButtons()[0])
|
||||
expect(onDriveIn.askRouteAlternatives).not.toHaveBeenCalled()
|
||||
first.unmount()
|
||||
|
||||
renderTab(withPicker({ drive: { kind: 'leg', index: 0 } }, { roadtripRoutes: routes({ days: [joinedStage()] }) }))
|
||||
expect(askButtons().map(b => b.getAttribute('aria-pressed'))).toEqual(['false', 'true', 'false'])
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-060: a reader sees the drive in without the question', () => {
|
||||
renderTab(joined({ can: vi.fn(() => false) }))
|
||||
expect(screen.getByText('roadtrip.leg.arrivingFrom:Hakone')).toBeInTheDocument()
|
||||
expect(askButtons()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// The rides and the hire car the routing round seams into the day, and the day's
|
||||
|
||||
@@ -31,7 +31,7 @@ function overlays(routes: OfferedRoute[] = ROUTES) {
|
||||
|
||||
function planner(open: Partial<LegAlternatives> | null, over: Partial<TripPlanner> = {}): TripPlanner {
|
||||
const base = buildPlanner()
|
||||
const leg = open ? openLeg({ dayId: 2, index: 0, routes: ROUTES, ...open }) : null
|
||||
const leg = open ? openLeg({ dayId: 2, drive: { kind: 'leg', index: 0 }, routes: ROUTES, ...open }) : null
|
||||
return buildPlanner({
|
||||
selectedDayId: 2,
|
||||
routeAlternatives: { ...base.routeAlternatives, open: leg },
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { LegAlternatives, OfferedRoute } from '../../../../src/components/R
|
||||
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import { openLeg } from '../../../helpers/legAlternatives'
|
||||
|
||||
// FE-MOB-RTALTH-001 to FE-MOB-RTALTH-010
|
||||
// FE-MOB-RTALTH-001 to FE-MOB-RTALTH-011
|
||||
//
|
||||
// The hook holds no picker state, so every case is a planner shape and what the hook
|
||||
// reads out of it or asks of it.
|
||||
@@ -26,7 +26,7 @@ const OVERLAYS = buildAlternativeOverlays(ROUTES, {
|
||||
})
|
||||
|
||||
function leg(over: Partial<LegAlternatives> = {}): LegAlternatives {
|
||||
return openLeg({ dayId: 2, index: 1, routes: ROUTES, ...over })
|
||||
return openLeg({ dayId: 2, drive: { kind: 'leg', index: 1 }, routes: ROUTES, ...over })
|
||||
}
|
||||
|
||||
/** A planner with the picker open on leg 1 of day 2, answered with three roads. */
|
||||
@@ -49,13 +49,13 @@ describe('useMRtAlternatives', () => {
|
||||
it('FE-MOB-RTALTH-001: a leg is open only when both its day and its position match', () => {
|
||||
const { result } = setup()
|
||||
|
||||
expect(result.current.isOpenFor(2, 1)).toBe(true)
|
||||
expect(result.current.isOpenFor(2, { kind: 'leg', index: 1 })).toBe(true)
|
||||
// The same position on another card, and another leg of the same card.
|
||||
expect(result.current.isOpenFor(1, 1)).toBe(false)
|
||||
expect(result.current.isOpenFor(2, 0)).toBe(false)
|
||||
expect(result.current.isOpenFor(1, { kind: 'leg', index: 1 })).toBe(false)
|
||||
expect(result.current.isOpenFor(2, { kind: 'leg', index: 0 })).toBe(false)
|
||||
|
||||
const closed = setup(planner({}, null))
|
||||
expect(closed.result.current.isOpenFor(2, 1)).toBe(false)
|
||||
expect(closed.result.current.isOpenFor(2, { kind: 'leg', index: 1 })).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-MOB-RTALTH-002: reads the phase and the picked road off the planner, and has neither while closed', () => {
|
||||
@@ -98,7 +98,7 @@ describe('useMRtAlternatives', () => {
|
||||
const shell = buildShell({ rtView: 'list' })
|
||||
const { result } = setup(p, shell)
|
||||
|
||||
act(() => { result.current.ask(2, 0) })
|
||||
act(() => { result.current.ask(2, { kind: 'leg', index: 0 }) })
|
||||
|
||||
expect(p.setHighlightedAlternative).toHaveBeenCalledWith(null)
|
||||
expect(p.refuel.close).toHaveBeenCalledTimes(1)
|
||||
@@ -111,7 +111,7 @@ describe('useMRtAlternatives', () => {
|
||||
// Already on the map, the switch would take the traveller back to the chain.
|
||||
const onMap = buildShell({ rtView: 'map' })
|
||||
const second = setup(planner({}, null), onMap)
|
||||
act(() => { second.result.current.ask(2, 0) })
|
||||
act(() => { second.result.current.ask(2, { kind: 'leg', index: 0 }) })
|
||||
expect(second.planner.askRouteAlternatives).toHaveBeenCalledWith(2, { kind: 'leg', index: 0 })
|
||||
expect(onMap.toggleRtView).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -121,7 +121,7 @@ describe('useMRtAlternatives', () => {
|
||||
const shell = buildShell({ rtView: 'list' })
|
||||
const { result } = setup(p, shell)
|
||||
|
||||
act(() => { result.current.ask(2, 1) })
|
||||
act(() => { result.current.ask(2, { kind: 'leg', index: 1 }) })
|
||||
|
||||
// The planner's ask toggles an open leg shut, and the pick on it is still wanted.
|
||||
expect(p.askRouteAlternatives).not.toHaveBeenCalled()
|
||||
@@ -208,6 +208,19 @@ describe('useMRtAlternatives', () => {
|
||||
expect(p.chooseRouteAlternative).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTALTH-011: the drive in from the day before is a drive of its own, told apart from the first leg', () => {
|
||||
const { result } = setup(planner({}, leg({ drive: { kind: 'arriving' } })))
|
||||
expect(result.current.isOpenFor(2, { kind: 'arriving' })).toBe(true)
|
||||
expect(result.current.isOpenFor(2, { kind: 'leg', index: 0 })).toBe(false)
|
||||
expect(result.current.isOpenFor(1, { kind: 'arriving' })).toBe(false)
|
||||
|
||||
// Open on a leg, asking about the drive in is a new question and goes to the planner.
|
||||
const p = planner({}, leg())
|
||||
const onMap = setup(p, buildShell({ rtView: 'map' }))
|
||||
act(() => { onMap.result.current.ask(2, { kind: 'arriving' }) })
|
||||
expect(p.askRouteAlternatives).toHaveBeenCalledWith(2, { kind: 'arriving' })
|
||||
})
|
||||
|
||||
it('FE-MOB-RTALTH-009: the map lifts its floor by the bar and the gap it keeps from the dock', () => {
|
||||
// The same 15px a bar standing over the dock keeps from it. This is the only such bar
|
||||
// left: the stage bar that used to hold the slot the rest of the time is gone.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import { carriedSeam } from '@trek/shared/roadtrip';
|
||||
|
||||
/**
|
||||
* Where a booked night sits in its day, and how the drawn roads follow when a stop
|
||||
@@ -176,7 +177,8 @@ export function reseatOwnStop(
|
||||
* its stop, a stop that left the day hands its road to the stop before it, and a
|
||||
* stop that is last has no leg to keep a via on. A via behind the day's last stop
|
||||
* bends the drive into the next day and stays with that stop while it is still last,
|
||||
* whatever its number is now; measured as a leg it would be dropped.
|
||||
* whatever its number is now; measured as a leg it would be dropped. That last rule is
|
||||
* the planner's own (`carriedSeam`), so the two cannot disagree about it.
|
||||
*
|
||||
* Returns what changed, or null when nothing did.
|
||||
*/
|
||||
@@ -185,14 +187,12 @@ export function carryVias(db: SeatConnection, dayId: number, previousIds: number
|
||||
const vias = db.prepare('SELECT id, after_order_index, sequence FROM roadtrip_vias WHERE day_id = ?').all(dayId) as PinnedVia[];
|
||||
if (!vias.length) return null;
|
||||
|
||||
const previousLast = previousIds.length - 1;
|
||||
const nextLast = nextIds.length - 1;
|
||||
const lastStayed = previousLast >= 0 && previousIds[previousLast] === nextIds[nextLast];
|
||||
const seam = carriedSeam(previousIds, nextIds);
|
||||
const remove: number[] = [];
|
||||
const moved: { id: number; after_order_index: number }[] = [];
|
||||
for (const via of vias) {
|
||||
const next = lastStayed && via.after_order_index === previousLast
|
||||
? nextLast
|
||||
const next = seam && via.after_order_index === seam.from
|
||||
? seam.to
|
||||
: legAfter(via.after_order_index, previousIds, nextIds);
|
||||
if (next === null) remove.push(via.id);
|
||||
else if (next !== via.after_order_index) moved.push({ id: via.id, after_order_index: next });
|
||||
|
||||
@@ -342,12 +342,9 @@ export class AssignmentsService {
|
||||
|
||||
// A via behind the day's last stop bends the drive into the next day, on a trip
|
||||
// with connected days or a night drive (the planner's `anchorFor` files it there).
|
||||
// `reanchorByStopOrder` gives a last stop no leg and deletes what follows it, which
|
||||
// is right for a stop the sort made last and wrong for one that was last already.
|
||||
const lastAt = previousIds.length - 1;
|
||||
const seam = previousIds[lastAt] === nextIds[lastAt] ? lastAt : null;
|
||||
const vias = this.dbs.all<AnchoredVia>('SELECT id, after_order_index, lat, lng FROM roadtrip_vias WHERE day_id = ?', dayId)
|
||||
.filter(via => via.after_order_index !== seam);
|
||||
// It stays with that stop while the sort leaves it last (`carriedSeam`, applied
|
||||
// inside `reanchorByStopOrder`), the same rule the planner's own drags follow.
|
||||
const vias = this.dbs.all<AnchoredVia>('SELECT id, after_order_index, lat, lng FROM roadtrip_vias WHERE day_id = ?', dayId);
|
||||
const plan = reanchorByStopOrder(vias, previousIds, nextIds);
|
||||
for (const viaId of plan.remove) {
|
||||
this.dbs.run('DELETE FROM roadtrip_vias WHERE id = ? AND day_id = ?', viaId, dayId);
|
||||
|
||||
@@ -573,6 +573,24 @@ describe('updateTime', () => {
|
||||
expect(update.vias?.vias.map(v => [v.id, v.after_order_index])).toEqual([[afterB, 2], [intoTomorrow, 3]]);
|
||||
});
|
||||
|
||||
it('ASG-SVC-042: a sort that moves the last stop up the day takes the drive behind it along, never leaving it on no stop', () => {
|
||||
// The rule the planner applies too (carriedSeam): a via behind the last stop stays
|
||||
// there only while that stop is last. Once another stop is, the via follows its stop
|
||||
// like any other, and nothing is left on an index behind the day's end.
|
||||
const { day, ids: [a, b, c] } = dayOf([['09:00', 0], ['12:00', 1], ['18:00', 2]]);
|
||||
const afterA = addVia(day.id, 0);
|
||||
const intoTomorrow = addVia(day.id, 2);
|
||||
|
||||
const update = svc.updateTime(c, '08:00', null);
|
||||
|
||||
expect(dayOrder(day.id)).toEqual([c, a, b]);
|
||||
expect(viaAnchors(day.id)).toEqual([
|
||||
{ id: afterA, after_order_index: 1, sequence: 0 },
|
||||
{ id: intoTomorrow, after_order_index: 0, sequence: 0 },
|
||||
]);
|
||||
expect(update.vias?.dayId).toBe(day.id);
|
||||
});
|
||||
|
||||
it('ASG-SVC-040: an End saved with the start as it stood leaves a day dragged out of time order alone', () => {
|
||||
// B belongs first by time, and the traveller put it second on purpose.
|
||||
const { day, ids: [a, b, c] } = dayOf([['14:00', 0], ['10:00', 1], ['16:00', 2]]);
|
||||
|
||||
@@ -235,6 +235,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.fillDefault': 'استخدام الإعداد الافتراضي الخاص بي',
|
||||
'roadtrip.stop.fillSet': 'حدد مقدار التعبئة في هذه المحطة',
|
||||
'roadtrip.leg.pending': 'لا يوجد مسار',
|
||||
'roadtrip.leg.arrivingFrom': 'من {name}',
|
||||
'roadtrip.stop.addTitle': 'إضافة كمحطة توقف',
|
||||
'roadtrip.stop.kind': 'نوع المحطة',
|
||||
'roadtrip.stop.landsOn': 'اليوم {day}، المحطة {position}',
|
||||
|
||||
@@ -241,6 +241,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.fillDefault': 'Usar meu padrão',
|
||||
'roadtrip.stop.fillSet': 'Definir quanto abastecer nesta parada',
|
||||
'roadtrip.leg.pending': 'Sem rota',
|
||||
'roadtrip.leg.arrivingFrom': 'Saindo de {name}',
|
||||
'roadtrip.stop.addTitle': 'Adicionar como parada',
|
||||
'roadtrip.stop.kind': 'Tipo de parada',
|
||||
'roadtrip.stop.landsOn': 'Dia {day}, como parada {position}',
|
||||
|
||||
@@ -243,6 +243,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.fillDefault': 'Usa el meu valor per defecte',
|
||||
'roadtrip.stop.fillSet': 'Defineix quant s’omple en aquesta parada',
|
||||
'roadtrip.leg.pending': 'Sense ruta',
|
||||
'roadtrip.leg.arrivingFrom': 'Des de {name}',
|
||||
'roadtrip.stop.addTitle': 'Afegeix com a parada',
|
||||
'roadtrip.stop.kind': 'Tipus de parada',
|
||||
'roadtrip.stop.landsOn': 'Dia {day}, com a parada {position}',
|
||||
|
||||
@@ -239,6 +239,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.fillDefault': 'Použít moji výchozí hodnotu',
|
||||
'roadtrip.stop.fillSet': 'Nastavit, na kolik se tady tankuje',
|
||||
'roadtrip.leg.pending': 'Bez trasy',
|
||||
'roadtrip.leg.arrivingFrom': 'Z místa {name}',
|
||||
'roadtrip.stop.addTitle': 'Přidat jako zastávku',
|
||||
'roadtrip.stop.kind': 'Druh zastávky',
|
||||
'roadtrip.stop.landsOn': '{day}. den, jako {position}. zastávka',
|
||||
|
||||
@@ -238,6 +238,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.fillDefault': 'Meinen Standard nehmen',
|
||||
'roadtrip.stop.fillSet': 'Festlegen, wie voll hier getankt wird',
|
||||
'roadtrip.leg.pending': 'Keine Route',
|
||||
'roadtrip.leg.arrivingFrom': 'Ab {name}',
|
||||
'roadtrip.stop.addTitle': 'Als Stopp hinzufügen',
|
||||
'roadtrip.stop.kind': 'Art des Stopps',
|
||||
'roadtrip.stop.landsOn': 'Tag {day}, als Stopp {position}',
|
||||
|
||||
@@ -236,6 +236,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.fillDefault': 'Use my default',
|
||||
'roadtrip.stop.fillSet': 'Set how full this stop fills',
|
||||
'roadtrip.leg.pending': 'No route',
|
||||
'roadtrip.leg.arrivingFrom': 'From {name}',
|
||||
'roadtrip.stop.addTitle': 'Add as a stop',
|
||||
'roadtrip.stop.kind': 'Kind of stop',
|
||||
'roadtrip.stop.landsOn': 'Day {day}, as stop {position}',
|
||||
|
||||
@@ -242,6 +242,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.fillDefault': 'Usar mi valor por defecto',
|
||||
'roadtrip.stop.fillSet': 'Definir cuánto se reposta en esta parada',
|
||||
'roadtrip.leg.pending': 'Sin ruta',
|
||||
'roadtrip.leg.arrivingFrom': 'Desde {name}',
|
||||
'roadtrip.stop.addTitle': 'Añadir como parada',
|
||||
'roadtrip.stop.kind': 'Tipo de parada',
|
||||
'roadtrip.stop.landsOn': 'Día {day}, como parada {position}',
|
||||
|
||||
@@ -243,6 +243,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.fillDefault': 'Utiliser ma valeur par défaut',
|
||||
'roadtrip.stop.fillSet': 'Définir le niveau de remplissage de cet arrêt',
|
||||
'roadtrip.leg.pending': 'Pas d’itinéraire',
|
||||
'roadtrip.leg.arrivingFrom': 'Depuis {name}',
|
||||
'roadtrip.stop.addTitle': 'Ajouter comme arrêt',
|
||||
'roadtrip.stop.kind': 'Type d’arrêt',
|
||||
'roadtrip.stop.landsOn': 'Jour {day}, en tant qu’arrêt {position}',
|
||||
|
||||
@@ -244,6 +244,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.fillDefault': 'Χρήση της προεπιλογής μου',
|
||||
'roadtrip.stop.fillSet': 'Όρισε πόσο γεμίζει αυτή η στάση',
|
||||
'roadtrip.leg.pending': 'Χωρίς διαδρομή',
|
||||
'roadtrip.leg.arrivingFrom': 'Από {name}',
|
||||
'roadtrip.stop.addTitle': 'Προσθήκη ως στάση',
|
||||
'roadtrip.stop.kind': 'Είδος στάσης',
|
||||
'roadtrip.stop.landsOn': 'Ημέρα {day}, ως στάση {position}',
|
||||
|
||||
@@ -240,6 +240,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.fillDefault': 'Az alapértelmezettet használom',
|
||||
'roadtrip.stop.fillSet': 'Beállítás: mennyire tölt fel ez a megálló',
|
||||
'roadtrip.leg.pending': 'Nincs útvonal',
|
||||
'roadtrip.leg.arrivingFrom': 'Indulás: {name}',
|
||||
'roadtrip.stop.addTitle': 'Hozzáadás megállóként',
|
||||
'roadtrip.stop.kind': 'Megálló típusa',
|
||||
'roadtrip.stop.landsOn': '{day}. nap, {position}. megállóként',
|
||||
|
||||
@@ -242,6 +242,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.fillDefault': 'Pakai bawaan saya',
|
||||
'roadtrip.stop.fillSet': 'Atur seberapa penuh perhentian ini mengisi',
|
||||
'roadtrip.leg.pending': 'Tidak ada rute',
|
||||
'roadtrip.leg.arrivingFrom': 'Dari {name}',
|
||||
'roadtrip.stop.addTitle': 'Tambahkan sebagai perhentian',
|
||||
'roadtrip.stop.kind': 'Jenis perhentian',
|
||||
'roadtrip.stop.landsOn': 'Hari {day}, sebagai perhentian {position}',
|
||||
|
||||
@@ -242,6 +242,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.fillDefault': 'Usa il mio valore predefinito',
|
||||
'roadtrip.stop.fillSet': 'Imposta quanto si riempie in questa sosta',
|
||||
'roadtrip.leg.pending': 'Nessun percorso',
|
||||
'roadtrip.leg.arrivingFrom': 'Da {name}',
|
||||
'roadtrip.stop.addTitle': 'Aggiungi come sosta',
|
||||
'roadtrip.stop.kind': 'Tipo di sosta',
|
||||
'roadtrip.stop.landsOn': 'Giorno {day}, come sosta {position}',
|
||||
|
||||
@@ -238,6 +238,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.fillDefault': '自分の既定値を使う',
|
||||
'roadtrip.stop.fillSet': 'この停車地でどこまで補給するか決める',
|
||||
'roadtrip.leg.pending': 'ルートなし',
|
||||
'roadtrip.leg.arrivingFrom': '{name}から',
|
||||
'roadtrip.stop.addTitle': '立ち寄り先として追加',
|
||||
'roadtrip.stop.kind': '立ち寄りの種類',
|
||||
'roadtrip.stop.landsOn': '{day} 日目、{position} 番目の立ち寄り先',
|
||||
|
||||
@@ -235,6 +235,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.fillDefault': '내 기본값 사용',
|
||||
'roadtrip.stop.fillSet': '이 정차지에서 얼마나 충전할지 설정',
|
||||
'roadtrip.leg.pending': '경로 없음',
|
||||
'roadtrip.leg.arrivingFrom': '{name}에서 출발',
|
||||
'roadtrip.stop.addTitle': '경유지로 추가',
|
||||
'roadtrip.stop.kind': '경유지 종류',
|
||||
'roadtrip.stop.landsOn': '{day}일차, {position}번째 경유지',
|
||||
|
||||
@@ -242,6 +242,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.fillDefault': 'Mijn standaard gebruiken',
|
||||
'roadtrip.stop.fillSet': 'Instellen hoe vol er hier wordt getankt',
|
||||
'roadtrip.leg.pending': 'Geen route',
|
||||
'roadtrip.leg.arrivingFrom': 'Vanaf {name}',
|
||||
'roadtrip.stop.addTitle': 'Als stop toevoegen',
|
||||
'roadtrip.stop.kind': 'Soort stop',
|
||||
'roadtrip.stop.landsOn': 'Dag {day}, als stop {position}',
|
||||
|
||||
@@ -240,6 +240,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.fillDefault': 'Użyj mojej wartości domyślnej',
|
||||
'roadtrip.stop.fillSet': 'Ustaw, do ile tankować na tym postoju',
|
||||
'roadtrip.leg.pending': 'Brak trasy',
|
||||
'roadtrip.leg.arrivingFrom': 'Z miejsca {name}',
|
||||
'roadtrip.stop.addTitle': 'Dodaj jako przystanek',
|
||||
'roadtrip.stop.kind': 'Rodzaj przystanku',
|
||||
'roadtrip.stop.landsOn': 'Dzień {day}, jako przystanek {position}',
|
||||
|
||||
@@ -242,6 +242,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.fillDefault': 'Взять моё значение по умолчанию',
|
||||
'roadtrip.stop.fillSet': 'Задать, насколько заправляться на этой остановке',
|
||||
'roadtrip.leg.pending': 'Нет маршрута',
|
||||
'roadtrip.leg.arrivingFrom': 'Отправление: {name}',
|
||||
'roadtrip.stop.addTitle': 'Добавить как остановку',
|
||||
'roadtrip.stop.kind': 'Тип остановки',
|
||||
'roadtrip.stop.landsOn': 'День {day}, остановка {position}',
|
||||
|
||||
@@ -239,6 +239,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.fillDefault': 'Använd mitt standardvärde',
|
||||
'roadtrip.stop.fillSet': 'Ange hur mycket det här stoppet fyller',
|
||||
'roadtrip.leg.pending': 'Ingen rutt',
|
||||
'roadtrip.leg.arrivingFrom': 'Från {name}',
|
||||
'roadtrip.stop.addTitle': 'Lägg till som stopp',
|
||||
'roadtrip.stop.kind': 'Typ av stopp',
|
||||
'roadtrip.stop.landsOn': 'Dag {day}, som stopp {position}',
|
||||
|
||||
@@ -240,6 +240,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.fillDefault': 'Kendi varsayılanımı kullan',
|
||||
'roadtrip.stop.fillSet': 'Bu molada ne kadar doldurulacağını ayarla',
|
||||
'roadtrip.leg.pending': 'Rota yok',
|
||||
'roadtrip.leg.arrivingFrom': 'Çıkış: {name}',
|
||||
'roadtrip.stop.addTitle': 'Mola olarak ekle',
|
||||
'roadtrip.stop.kind': 'Mola türü',
|
||||
'roadtrip.stop.landsOn': '{day}. gün, {position}. mola',
|
||||
|
||||
@@ -241,6 +241,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.fillDefault': 'Узяти моє значення за замовчуванням',
|
||||
'roadtrip.stop.fillSet': 'Задати, наскільки заправлятися на цій зупинці',
|
||||
'roadtrip.leg.pending': 'Немає маршруту',
|
||||
'roadtrip.leg.arrivingFrom': 'Відправлення: {name}',
|
||||
'roadtrip.stop.addTitle': 'Додати як зупинку',
|
||||
'roadtrip.stop.kind': 'Тип зупинки',
|
||||
'roadtrip.stop.landsOn': 'День {day}, зупинка {position}',
|
||||
|
||||
@@ -239,6 +239,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.fillDefault': 'Dùng mặc định của tôi',
|
||||
'roadtrip.stop.fillSet': 'Đặt mức đổ tại điểm dừng này',
|
||||
'roadtrip.leg.pending': 'Không có lộ trình',
|
||||
'roadtrip.leg.arrivingFrom': 'Từ {name}',
|
||||
'roadtrip.stop.addTitle': 'Thêm làm điểm dừng',
|
||||
'roadtrip.stop.kind': 'Loại điểm dừng',
|
||||
'roadtrip.stop.landsOn': 'Ngày {day}, là điểm dừng {position}',
|
||||
|
||||
@@ -226,6 +226,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.fillDefault': '使用我的預設值',
|
||||
'roadtrip.stop.fillSet': '設定這一站加到多少',
|
||||
'roadtrip.leg.pending': '無路線',
|
||||
'roadtrip.leg.arrivingFrom': '從 {name} 出發',
|
||||
'roadtrip.stop.addTitle': '新增為停靠點',
|
||||
'roadtrip.stop.kind': '停靠類型',
|
||||
'roadtrip.stop.landsOn': '第 {day} 天,第 {position} 個停靠點',
|
||||
|
||||
@@ -227,6 +227,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.fillDefault': '使用我的默认值',
|
||||
'roadtrip.stop.fillSet': '设置这一站加到多少',
|
||||
'roadtrip.leg.pending': '无路线',
|
||||
'roadtrip.leg.arrivingFrom': '从 {name} 出发',
|
||||
'roadtrip.stop.addTitle': '添加为停靠点',
|
||||
'roadtrip.stop.kind': '停靠类型',
|
||||
'roadtrip.stop.landsOn': '第 {day} 天,第 {position} 个停靠点',
|
||||
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
refuelStopTypeFor,
|
||||
leaveAfter,
|
||||
scheduleStopOf,
|
||||
carriedSeam,
|
||||
reanchorAfterRemove,
|
||||
reanchorByStopOrder,
|
||||
} from './roadtripModel';
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
@@ -1169,3 +1172,29 @@ describe('a check-in holds a booked night the way a pinned time does', () => {
|
||||
expect(schedule.warnings).toContainEqual({ index: 1, code: 'late', minutes: 314 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('carriedSeam', () => {
|
||||
it('FE-ROADTRIP-MODEL-107: the via behind a last stop stays with it while it is last, whatever its number', () => {
|
||||
expect(carriedSeam([10, 20, 30], [20, 10, 30])).toEqual({ from: 2, to: 2 });
|
||||
// A stop taken out ahead of it, and a night seated ahead of it.
|
||||
expect(carriedSeam([10, 20, 30], [10, 30])).toEqual({ from: 2, to: 1 });
|
||||
expect(carriedSeam([10, 20, 30], [10, 20, 25, 30])).toEqual({ from: 2, to: 3 });
|
||||
// Another stop is last now, or the day has none.
|
||||
expect(carriedSeam([10, 20, 30], [30, 10, 20])).toBeNull();
|
||||
expect(carriedSeam([10, 20, 30], [10, 20, 30, 40])).toBeNull();
|
||||
expect(carriedSeam([10, 20, 30], [10, 20])).toBeNull();
|
||||
expect(carriedSeam([], [10])).toBeNull();
|
||||
expect(carriedSeam([10], [])).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ROADTRIP-MODEL-108: the reorder and the removal the planner and the server run both keep to it', () => {
|
||||
const via = (id: number, after: number) => ({ id, after_order_index: after, lat: 53, lng: 10 });
|
||||
expect(reanchorByStopOrder([via(9, 2)], [10, 20, 30], [10, 30])).toEqual({
|
||||
vias: [{ id: 9, after_order_index: 1 }],
|
||||
remove: [],
|
||||
});
|
||||
expect(reanchorByStopOrder([via(9, 2)], [10, 20, 30], [20, 10, 30])).toEqual({ vias: [], remove: [] });
|
||||
expect(reanchorAfterRemove([via(9, 2)], 2, 3)).toEqual({ vias: [], remove: [9] });
|
||||
expect(reanchorAfterRemove([via(9, 2)], 0, 3)).toEqual({ vias: [{ id: 9, after_order_index: 1 }], remove: [] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -542,6 +542,36 @@ export interface Reanchoring {
|
||||
|
||||
const EMPTY_REANCHORING: Reanchoring = { vias: [], remove: [] };
|
||||
|
||||
/**
|
||||
* Where a via filed behind a day's last stop goes once the day's stops change: from its
|
||||
* old index `from` to `to`, the index that stop has now. Null when the stop is no longer
|
||||
* the day's last, and such a via is then read like any other.
|
||||
*
|
||||
* Such a via bends no leg of its own day. It shapes the drive from the day's last stop
|
||||
* into the next day, on a trip with connected days or a night drive: the planner files a
|
||||
* point dropped on that drive there (`anchorFor`), and a way chosen for it is written
|
||||
* there. Read as a leg it leads nowhere and is dropped, which is right for a stop that has
|
||||
* just become last and wrong for one that was last already: sorting the day, or taking a
|
||||
* stop out of its middle, deleted a drive into tomorrow somebody had picked. And a last
|
||||
* stop taken out, or dragged up the day, left its via on a number no stop has, bending
|
||||
* nothing at all.
|
||||
*
|
||||
* The one rule for every writer that renumbers a day, the planner's drags and removals as
|
||||
* much as the server's sorts and seated nights. `previousIds` and `nextIds` are the day's
|
||||
* located stops in order, before and after.
|
||||
*/
|
||||
export function carriedSeam(
|
||||
previousIds: readonly number[],
|
||||
nextIds: readonly number[],
|
||||
): { from: number; to: number } | null {
|
||||
const from = previousIds.length - 1;
|
||||
const to = nextIds.length - 1;
|
||||
return from >= 0 && to >= 0 && previousIds[from] === nextIds[to] ? { from, to } : null;
|
||||
}
|
||||
|
||||
/** The stops of a day as their own positions, for asking `carriedSeam` about a positional edit. */
|
||||
const positions = (count: number): number[] => Array.from({ length: count }, (_, i) => i);
|
||||
|
||||
function collect(vias: AnchoredVia[], at: (index: number) => number | null): Reanchoring {
|
||||
const moved: ReanchoredVia[] = [];
|
||||
const remove: number[] = [];
|
||||
@@ -574,8 +604,15 @@ export function reanchorAfterInsert(
|
||||
export function reanchorAfterRemove(vias: AnchoredVia[], position: number, stopCount: number): Reanchoring {
|
||||
if (!vias.length) return EMPTY_REANCHORING;
|
||||
|
||||
if (stopCount <= 2) return { vias: [], remove: vias.map((v) => v.id) };
|
||||
const before = positions(stopCount);
|
||||
const after = before.filter((i) => i !== position);
|
||||
const seam = carriedSeam(before, after);
|
||||
const last = stopCount - 1;
|
||||
// A day left with one stop has no leg, and only the drive out of it into the next day
|
||||
// can keep its points.
|
||||
if (stopCount <= 2) return collect(vias, (i) => (i === last ? (seam?.to ?? null) : null));
|
||||
return collect(vias, (i) => {
|
||||
if (i === last) return seam?.to ?? null;
|
||||
if (position === 0) return i === 0 ? null : i - 1;
|
||||
if (position === stopCount - 1) return i === position - 1 ? null : i;
|
||||
if (i === position) return position - 1;
|
||||
@@ -585,9 +622,14 @@ export function reanchorAfterRemove(vias: AnchoredVia[], position: number, stopC
|
||||
|
||||
export function reanchorByStopOrder(vias: AnchoredVia[], previousIds: number[], nextIds: number[]): Reanchoring {
|
||||
if (!vias.length) return EMPTY_REANCHORING;
|
||||
const seam = carriedSeam(previousIds, nextIds);
|
||||
const moved: ReanchoredVia[] = [];
|
||||
const remove: number[] = [];
|
||||
for (const via of vias) {
|
||||
if (seam && via.after_order_index === seam.from) {
|
||||
if (seam.to !== seam.from) moved.push({ id: via.id, after_order_index: seam.to });
|
||||
continue;
|
||||
}
|
||||
const stopId = previousIds[via.after_order_index]!;
|
||||
const next = stopId === undefined ? -1 : nextIds.indexOf(stopId);
|
||||
|
||||
@@ -603,7 +645,12 @@ export function reanchorByStopOrder(vias: AnchoredVia[], previousIds: number[],
|
||||
export function reanchorAfterReorder(vias: AnchoredVia[], from: number, to: number, stopCount: number): Reanchoring {
|
||||
if (!vias.length || from === to) return EMPTY_REANCHORING;
|
||||
|
||||
if (stopCount <= 2) return EMPTY_REANCHORING;
|
||||
// Two stops swapped keep their one leg, driven the other way round. The drive into the
|
||||
// next day left from the stop that is first now, so its points go.
|
||||
if (stopCount <= 2) {
|
||||
const seam = vias.filter((v) => v.after_order_index === stopCount - 1).map((v) => v.id);
|
||||
return seam.length ? { vias: [], remove: seam } : EMPTY_REANCHORING;
|
||||
}
|
||||
const afterRemove = reanchorAfterRemove(vias, from, stopCount);
|
||||
const dropped = new Set(afterRemove.remove);
|
||||
const movedTo = new Map(afterRemove.vias.map((v) => [v.id, v.after_order_index] as const));
|
||||
|
||||
Reference in New Issue
Block a user