mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(roadtrip): other ways come from the router that draws the leg, and a choice is saved once it holds
The picker of other ways asked OSRM with nothing avoided while the rail drives a trip that avoids a road class through Valhalla, so the list had no current road, called a road the rail was not on the fastest, and choosing it changed nothing. A choice was stored as one via where the offer strayed furthest, without asking whether the router then drove it: a point on a ferry was pulled to the pier and the drive went overland, and a way weighed away from motorways kept the motorway after its one point. An OSRM answer standing in for a Valhalla that did not respond was cached as the avoided road, unmarked. The rail now hands out a router per leg with the leg's own mode, avoided classes and engine, and its days carry the line of every leg. Offers come from that engine, and the rail's own leg always heads the list as Current. A choice is pinned and proven against that router with at most three pins before anything is written; a way it will not follow is not saved and says why, with a hint when it crosses by ferry, and a leg that changed meanwhile or a device offline writes nothing. The OSRM stand-in is filed under the plain key only and flags the day as not avoided.
This commit is contained in:
@@ -13,6 +13,10 @@ import {
|
||||
parsePluginProfile,
|
||||
withHotelBookends,
|
||||
calculateAlternatives,
|
||||
avoidedClasses,
|
||||
routeEngineFor,
|
||||
sameRoad,
|
||||
furthestFrom,
|
||||
} from './RouteCalculator'
|
||||
|
||||
// Every route now goes to the FOSSGIS per-profile hosts. The car-only project-osrm.org
|
||||
@@ -1049,3 +1053,196 @@ describe('turning round at a stop', () => {
|
||||
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: '' } }))
|
||||
})
|
||||
})
|
||||
|
||||
// ── a trip that avoids something ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The rail drives a trip with avoided classes through Valhalla, and everything that talks
|
||||
* about one of its legs has to use the same engine. The offers used to come from OSRM with
|
||||
* nothing avoided, so the picker described a different drive from the rail's; and an OSRM
|
||||
* stand-in for a Valhalla that did not answer was filed as the avoided road for the rest
|
||||
* of the session, without a word on the rail.
|
||||
*/
|
||||
describe('a trip that avoids something', () => {
|
||||
const VALHALLA = 'https://valhalla1.openstreetmap.de/route'
|
||||
|
||||
/** Polyline6, the way Valhalla sends its shapes. */
|
||||
const encode6 = (points: [number, number][]): string => {
|
||||
const part = (value: number): string => {
|
||||
let n = value < 0 ? ~(value << 1) : value << 1
|
||||
let out = ''
|
||||
while (n >= 0x20) { out += String.fromCharCode((0x20 | (n & 0x1f)) + 63); n >>= 5 }
|
||||
return out + String.fromCharCode(n + 63)
|
||||
}
|
||||
let lat = 0
|
||||
let lng = 0
|
||||
return points.map(([pLat, pLng]) => {
|
||||
const a = Math.round(pLat * 1e6)
|
||||
const b = Math.round(pLng * 1e6)
|
||||
const piece = part(a - lat) + part(b - lng)
|
||||
lat = a
|
||||
lng = b
|
||||
return piece
|
||||
}).join('')
|
||||
}
|
||||
const trip = (points: [number, number][], summary: Record<string, unknown> = {}) => ({
|
||||
legs: [{ shape: encode6(points) }],
|
||||
summary: { length: 100, time: 4000, has_toll: false, has_highway: false, has_ferry: false, ...summary },
|
||||
})
|
||||
const NORTH: [number, number][] = [[61, 10], [61.5, 11], [61, 12]]
|
||||
const SOUTH: [number, number][] = [[61, 10], [60.5, 11], [61, 12]]
|
||||
const DIRECT: [number, number][] = [[61, 10], [61, 11], [61, 12]]
|
||||
const from = { lat: 61, lng: 10 }
|
||||
const to = { lat: 61, lng: 12 }
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-059: the ways come from Valhalla with the trip classes, and OSRM is never asked', async () => {
|
||||
let osrm = 0
|
||||
const bodies: Array<Record<string, unknown>> = []
|
||||
server.use(
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () => { osrm++; return HttpResponse.json(buildOsrmRouteResponse()) }),
|
||||
http.post(VALHALLA, async ({ request }) => {
|
||||
bodies.push(await request.json() as Record<string, unknown>)
|
||||
return HttpResponse.json({
|
||||
trip: trip(DIRECT, { time: 3600 }),
|
||||
alternates: [{ trip: trip(NORTH, { time: 4200, has_ferry: true }) }, { trip: trip(SOUTH, { time: 4800 }) }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const routes = await calculateAlternatives(from, to, 'driving', { avoid: ['toll'] })
|
||||
|
||||
expect(osrm).toBe(0)
|
||||
expect(bodies).toHaveLength(1)
|
||||
expect(bodies[0]).toMatchObject({ alternates: 2, costing_options: { auto: { use_tolls: 0 } } })
|
||||
expect(bodies[0].locations).toHaveLength(2)
|
||||
expect(routes.map(r => r.engine)).toEqual(['valhalla', 'valhalla', 'valhalla'])
|
||||
expect(routes.map(r => r.duration)).toEqual([3600, 4200, 4800])
|
||||
expect(routes.map(r => r.hasFerry)).toEqual([false, true, false])
|
||||
// Pinnable like any other offer: measured against the preferred one.
|
||||
expect(routes[0].divergence).toBeNull()
|
||||
expect(routes[1].divergence).toEqual({ lat: 61.5, lng: 11 })
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-060: a Valhalla with nothing to say is the router not answering, not the OSRM roads', async () => {
|
||||
let osrm = 0
|
||||
server.use(
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () => { osrm++; return HttpResponse.json(buildOsrmRouteResponse()) }),
|
||||
http.post(VALHALLA, () => new HttpResponse(null, { status: 503 })),
|
||||
)
|
||||
|
||||
await expect(calculateAlternatives({ lat: 62, lng: 10 }, { lat: 62, lng: 12 }, 'driving', { avoid: ['ferry'] })).rejects.toThrow()
|
||||
expect(osrm).toBe(0)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-061: one way back is offered a second only for a class the trip does not avoid yet', async () => {
|
||||
const asked: Array<Record<string, number>> = []
|
||||
server.use(http.post(VALHALLA, async ({ request }) => {
|
||||
const body = await request.json() as { alternates?: number; costing_options: { auto: Record<string, number> } }
|
||||
asked.push(body.costing_options.auto)
|
||||
if (body.alternates) return HttpResponse.json({ trip: trip(DIRECT, { has_toll: true }) })
|
||||
// Asked to leave out the tolls as well: a road that really is without them.
|
||||
return HttpResponse.json({ trip: trip(NORTH, { time: 5000, has_toll: false }) })
|
||||
}))
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 61, lng: 10.5 }, { lat: 61, lng: 12 }, 'driving', { avoid: ['motorway'] })
|
||||
|
||||
// Motorways are avoided already, so the first extra question is about tolls, and it
|
||||
// keeps the trip's own class in.
|
||||
expect(asked).toEqual([{ use_highways: 0 }, { use_highways: 0, use_tolls: 0 }])
|
||||
expect(routes).toHaveLength(2)
|
||||
expect(routes[1]).toMatchObject({ avoids: 'toll', engine: 'valhalla', duration: 5000 })
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-062: only a driving leg is weighed; a walk is asked of OSRM as before', async () => {
|
||||
let valhalla = 0
|
||||
server.use(
|
||||
http.get(`${FOSSGIS.walking}/:coords`, () => HttpResponse.json({ code: 'Ok', routes: [{ geometry: { coordinates: [[10, 61], [12, 61]] }, distance: 1000, duration: 900 }] })),
|
||||
http.post(VALHALLA, () => { valhalla++; return HttpResponse.json({ trip: trip(DIRECT) }) }),
|
||||
)
|
||||
|
||||
const routes = await calculateAlternatives(from, to, 'walking', { avoid: ['toll'] })
|
||||
|
||||
expect(valhalla).toBe(0)
|
||||
expect(routes).toHaveLength(1)
|
||||
expect(routes[0].engine).toBeUndefined()
|
||||
expect(avoidedClasses('walking', ['toll'])).toEqual([])
|
||||
expect(avoidedClasses('driving', ['toll', 'ferry'])).toEqual(['ferry', 'toll'])
|
||||
expect(routeEngineFor('driving', ['toll'])).toBe('valhalla')
|
||||
expect(routeEngineFor('driving', [])).toBe('osrm')
|
||||
expect(routeEngineFor('plugin:ev/fast', ['toll'])).toBe('plugin')
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-063: an OSRM stand-in says nothing was avoided, and is not filed as the avoided road', async () => {
|
||||
const [a, b] = freshWaypoints()
|
||||
let osrm = 0
|
||||
let valhallaUp = false
|
||||
server.use(
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () => { osrm++; return HttpResponse.json(buildLegsResponse()) }),
|
||||
http.post(VALHALLA, () => (valhallaUp
|
||||
? HttpResponse.json({ trip: { legs: [{ shape: encode6([[a.lat, a.lng], [b.lat, b.lng]]), summary: { length: 5, time: 700, has_toll: false, has_ferry: true } }] } })
|
||||
: new HttpResponse(null, { status: 503 }))),
|
||||
)
|
||||
|
||||
const fallback = await calculateRouteWithLegs([a, b], { avoid: ['toll'] })
|
||||
expect(osrm).toBe(1)
|
||||
expect(fallback.avoidance).toEqual({ asked: ['toll'], achieved: [], fellBack: true })
|
||||
expect(fallback.distance).toBe(4200)
|
||||
|
||||
// Valhalla is back: the same drive asks it again instead of keeping the OSRM answer.
|
||||
valhallaUp = true
|
||||
const weighed = await calculateRouteWithLegs([a, b], { avoid: ['toll'] })
|
||||
expect(weighed.avoidance).toEqual({ asked: ['toll'], achieved: ['toll'] })
|
||||
expect(weighed.distance).toBe(5000)
|
||||
expect(weighed.hasFerry).toBe(true)
|
||||
|
||||
// The stand-in was filed where it is true: as the plain OSRM answer, which an ordinary
|
||||
// caller now gets without asking again, and without the avoidance attached.
|
||||
const plain = await calculateRouteWithLegs([a, b])
|
||||
expect(osrm).toBe(1)
|
||||
expect(plain.distance).toBe(4200)
|
||||
expect(plain.avoidance).toBeUndefined()
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-064: a road that runs the whole of another and turns off to a point and back is not that road', () => {
|
||||
// The shape of a leg bent by a via: every point of the plain road lies on it, so
|
||||
// asked only one way round the two read as one.
|
||||
const plainRoad = { coordinates: [[61, 10], [61, 11], [61, 12]] as [number, number][], distance: 100_000, duration: 3600 }
|
||||
const spur = { coordinates: [[61, 10], [61, 11], [61.3, 11], [61, 11], [61, 12]] as [number, number][], distance: 166_000, duration: 5400 }
|
||||
|
||||
expect(sameRoad(plainRoad, spur)).toBe(false)
|
||||
expect(sameRoad(spur, plainRoad)).toBe(false)
|
||||
expect(sameRoad(plainRoad, { ...plainRoad, distance: 104_000, duration: 3900 })).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-065: one road drawn with a different spacing of points is still one road', () => {
|
||||
// Two engines' lines of one long road share almost no vertex. Measured to the nearest
|
||||
// sampled vertex, a point on the same road sat half a sample off it: kilometres here.
|
||||
// The dense line is sampled every tenth vertex, 0.05 degrees apart; the sparse one sits
|
||||
// exactly between those samples, 1.8 km from the nearest, on the very same road.
|
||||
const dense = Array.from({ length: 2001 }, (_, i) => [50, i * 0.005] as [number, number])
|
||||
const sparse: [number, number][] = [
|
||||
[50, 0],
|
||||
...Array.from({ length: 200 }, (_, i) => [50, 0.025 + i * 0.05] as [number, number]),
|
||||
[50, 10],
|
||||
]
|
||||
const a = { coordinates: dense, distance: 700_000, duration: 25_000 }
|
||||
const b = { coordinates: sparse, distance: 712_000, duration: 27_000 }
|
||||
|
||||
expect(sameRoad(a, b)).toBe(true)
|
||||
const off = furthestFrom([[50.02, 5]], dense)
|
||||
expect(off?.index).toBe(0)
|
||||
expect(off?.km).toBeCloseTo(2.22, 1)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-066: the avoidance offer beside an OSRM leg says when it crosses by ferry', async () => {
|
||||
server.use(
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'Ok', routes: [{ geometry: { coordinates: [[10, 63], [11, 63], [12, 63]] }, distance: 100000, duration: 3600 }] })),
|
||||
http.post(VALHALLA, () => HttpResponse.json({ trip: trip([[63, 10], [63.6, 11], [63, 12]], { has_highway: false, has_ferry: true }) })),
|
||||
)
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 63, lng: 10 }, { lat: 63, lng: 12 })
|
||||
|
||||
expect(routes).toHaveLength(2)
|
||||
expect(routes[1]).toMatchObject({ avoids: 'motorway', engine: 'valhalla', hasFerry: true })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, SnappedWay
|
||||
import { haversineKm } from '../../utils/geo'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
import { countRoute } from './routeUsageCounter'
|
||||
import { valhallaRouteAvoiding, valhallaRun, valhallaAvailable, legAvoids, type AvoidClass } from './valhallaRoute'
|
||||
import { valhallaRouteAvoiding, valhallaRun, valhallaAvailable, valhallaAlternates, valhallaSpacing, legAvoids, type AvoidClass, type ValhallaLeg } from './valhallaRoute'
|
||||
import type { RouteUsageSurface } from '@trek/shared'
|
||||
|
||||
// FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the
|
||||
@@ -216,6 +216,33 @@ export function parsePluginProfile(profile: string): { pluginId: string; profile
|
||||
return { pluginId: rest.slice(0, slash), profileId: rest.slice(slash + 1) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Which engine priced a route: OSRM, the second engine that weighs road classes away, or
|
||||
* a route provider plugin. Their figures are three different speed models, so a figure
|
||||
* from one read against a figure from another measures the models, not the roads.
|
||||
*/
|
||||
export type RouteEngine = 'osrm' | 'valhalla' | 'plugin'
|
||||
|
||||
/**
|
||||
* The classes a request really weighs away, in a stable order, and none when it cannot.
|
||||
*
|
||||
* Only the driving profile has classes to leave out and only Valhalla can leave them out,
|
||||
* so every other request is asked plainly whatever the trip says. One function for the
|
||||
* day routing and for the offers beside it, so the two cannot disagree about which engine
|
||||
* a leg belongs to.
|
||||
*/
|
||||
export function avoidedClasses(profile: RouteProfileKey, avoid: readonly AvoidClass[]): AvoidClass[] {
|
||||
return profile === 'driving' && avoid.length > 0 && valhallaAvailable()
|
||||
? [...avoid].sort((a, b) => a.localeCompare(b))
|
||||
: []
|
||||
}
|
||||
|
||||
/** The engine `calculateRouteWithLegs` hands a request with this profile and avoidance to. */
|
||||
export function routeEngineFor(profile: RouteProfileKey, avoid: readonly AvoidClass[]): RouteEngine {
|
||||
if (parsePluginProfile(profile)) return 'plugin'
|
||||
return avoidedClasses(profile, avoid).length ? 'valhalla' : 'osrm'
|
||||
}
|
||||
|
||||
/** Fetches a full route via OSRM and returns coordinates, distance, and duration estimates for driving/walking. */
|
||||
export async function calculateRoute(
|
||||
waypoints: Waypoint[],
|
||||
@@ -480,9 +507,7 @@ export async function calculateRouteWithLegs(
|
||||
return { coordinates: [], distance: 0, duration: 0, legs: [] }
|
||||
}
|
||||
|
||||
const avoiding = profile === 'driving' && avoid.length > 0 && valhallaAvailable()
|
||||
? [...avoid].sort((a, b) => a.localeCompare(b))
|
||||
: []
|
||||
const avoiding = avoidedClasses(profile, avoid)
|
||||
|
||||
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||
// The cached result carries formatted leg distances, so the active distance unit is
|
||||
@@ -497,23 +522,30 @@ export async function calculateRouteWithLegs(
|
||||
// way is worse: this Map is shared with the ordinary day planner, the studio and the
|
||||
// booking geometry, none of which ask for avoidance, so an avoid-routed answer stored
|
||||
// under the plain key would hand them a road nobody drives and a second engine's
|
||||
// times. `avoiding` is empty whenever the request goes to OSRM after all, so the key
|
||||
// also separates the two engines.
|
||||
// times. So the avoiding key only ever holds a Valhalla answer: when Valhalla cannot
|
||||
// answer and OSRM drives the request after all, that answer is filed under the plain
|
||||
// key, where it is exactly what an ordinary caller would have got. Filed under the
|
||||
// avoiding key it stood in for the avoided road until the next reload, however soon
|
||||
// Valhalla came back.
|
||||
const pluginScope = profile.startsWith('plugin:') ? `:${tripId ?? ''}:${dayId ?? ''}` : ''
|
||||
const avoidScope = avoiding.length ? `:avoid=${avoiding.join(',')}` : ''
|
||||
const cacheKey = `${profile}:${getDistanceUnit()}:${coords}${pluginScope}${avoidScope}`
|
||||
const plainKey = `${profile}:${getDistanceUnit()}:${coords}${pluginScope}`
|
||||
const cacheKey = `${plainKey}${avoidScope}`
|
||||
const cached = routeCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
if (avoiding.length) {
|
||||
const routed = await routeAvoidingWithLegs(waypoints, avoiding, signal)
|
||||
// Null is a Valhalla that could not answer at all. Falling through to OSRM keeps a
|
||||
// day drawn rather than blank, and `avoidance` stays absent on that result so the
|
||||
// rail can say the avoidance did not happen instead of implying it did.
|
||||
if (routed) {
|
||||
cacheRoute(cacheKey, routed)
|
||||
return routed
|
||||
}
|
||||
// Null is a Valhalla that could not answer at all. OSRM keeps the day drawn rather
|
||||
// than blank, and the answer says that nothing was avoided, with the reason, so the
|
||||
// rail flags the day instead of implying the setting held on a road nobody weighed.
|
||||
const plain = routeCache.get(plainKey) ?? await osrmWithLegs(waypoints, coords, profile, signal)
|
||||
cacheRoute(plainKey, plain)
|
||||
return { ...plain, avoidance: { asked: avoiding, achieved: [], fellBack: true } }
|
||||
}
|
||||
|
||||
// Plugin profile (`plugin:<id>/<profile>`): the server invokes that routeProvider
|
||||
@@ -558,6 +590,18 @@ export async function calculateRouteWithLegs(
|
||||
return result
|
||||
}
|
||||
|
||||
const result = await osrmWithLegs(waypoints, coords, profile, signal)
|
||||
cacheRoute(cacheKey, result)
|
||||
return result
|
||||
}
|
||||
|
||||
/** The OSRM answer for a chain, one leg per waypoint pair. Throws like every OSRM call here. */
|
||||
async function osrmWithLegs(
|
||||
waypoints: Waypoint[],
|
||||
coords: string,
|
||||
profile: RouteProfileKey,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RouteWithLegs> {
|
||||
// Written as literals rather than narrowing `profile`: its type is an open string union
|
||||
// (plugins name their own modes), which no comparison narrows to the three OSRM knows.
|
||||
const osrmProfile: 'driving' | 'walking' | 'cycling' =
|
||||
@@ -592,9 +636,7 @@ export async function calculateRouteWithLegs(
|
||||
)
|
||||
|
||||
const snapped = readSnapped(data, waypoints)
|
||||
const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs, ...(snapped ? { snapped } : {}) }
|
||||
cacheRoute(cacheKey, result)
|
||||
return result
|
||||
return { coordinates, distance: route.distance, duration: route.duration, legs, ...(snapped ? { snapped } : {}) }
|
||||
}
|
||||
|
||||
/** Store a route and drop the oldest once the cache is over its cap. */
|
||||
@@ -659,6 +701,7 @@ async function routeAvoidingWithLegs(
|
||||
legs,
|
||||
snapped: run.snapped,
|
||||
avoidance: { asked: avoid, achieved: avoid.filter(cls => legAvoids(run.total, cls)) },
|
||||
hasFerry: run.total.hasFerry,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -714,7 +757,7 @@ function formatDuration(seconds: number): string {
|
||||
return `${m} min`
|
||||
}
|
||||
|
||||
/** One way of driving a leg, as OSRM offers it. */
|
||||
/** One way of driving a leg, as a router offers it. */
|
||||
export interface RouteAlternative {
|
||||
coordinates: [number, number][]
|
||||
distance: number
|
||||
@@ -724,20 +767,24 @@ export interface RouteAlternative {
|
||||
/** Set when this way exists because a road class was left out of it. */
|
||||
avoids?: AvoidClass
|
||||
/**
|
||||
* Which engine priced this route, when it was not the one that priced the rest.
|
||||
* Which engine priced this route. Absent means OSRM, which is what answers every route
|
||||
* of a trip that avoids nothing, except the avoidance offer on a default install.
|
||||
*
|
||||
* Absent for everything OSRM answered, which is every route in a list except the
|
||||
* avoidance offer on a default install. It exists because the two engines do not
|
||||
* agree on speed: measured over twelve European legs the per-leg drive times run
|
||||
* from 14.7 % under OSRM's to 13.2 % over, and the sign depends on the region:
|
||||
* Valhalla is faster on Spanish autovía and slower through a city. So a figure from
|
||||
* one of them subtracted from a figure from the other is not a difference in
|
||||
* driving time, it is the gap between two speed models, and the reader has no way
|
||||
* of telling the two apart.
|
||||
* It exists because the engines do not agree on speed: measured over twelve European
|
||||
* legs the per-leg drive times run from 14.7 % under OSRM's to 13.2 % over, and the
|
||||
* sign depends on the region: Valhalla is faster on Spanish autovía and slower through
|
||||
* a city. So a figure from one of them subtracted from a figure from the other is not a
|
||||
* difference in driving time, it is the gap between two speed models, and the reader
|
||||
* has no way of telling the two apart.
|
||||
*/
|
||||
engine?: 'valhalla'
|
||||
engine?: RouteEngine
|
||||
/** Whether the way crosses by ferry, where the engine that drew it says so. */
|
||||
hasFerry?: boolean
|
||||
}
|
||||
|
||||
/** What two routes are compared by: their line, and their figures where there is none. */
|
||||
export type RoadLine = Pick<RouteAlternative, 'coordinates' | 'distance' | 'duration'>
|
||||
|
||||
/**
|
||||
* Road classes worth asking the router to leave out, in the order they are tried.
|
||||
*
|
||||
@@ -780,10 +827,16 @@ const SAME_ROAD_KM = 0.5
|
||||
*
|
||||
* The geometry does not care which engine drew it, so it decides whenever there is one.
|
||||
* The numbers stay as the fallback for a line with no shape to compare.
|
||||
*
|
||||
* Measured both ways round. Asked only whether `b` stays near `a`, a road that runs the
|
||||
* whole of `b` and then turns off to a point and back passed as `b`, because every point
|
||||
* of `b` is on it; that is exactly the shape of a leg bent by a via, and the picker and
|
||||
* the check behind a choice both have to tell it from the plain road.
|
||||
*/
|
||||
function sameRoad(a: RouteAlternative, b: RouteAlternative): boolean {
|
||||
const stray = furthestFrom(b.coordinates, a.coordinates)
|
||||
if (stray) return stray.km < SAME_ROAD_KM
|
||||
export function sameRoad(a: RoadLine, b: RoadLine): boolean {
|
||||
const there = furthestFrom(b.coordinates, a.coordinates)
|
||||
const back = furthestFrom(a.coordinates, b.coordinates)
|
||||
if (there && back) return Math.max(there.km, back.km) < SAME_ROAD_KM
|
||||
return Math.abs(a.distance - b.distance) < 200 && Math.abs(a.duration - b.duration) < 60
|
||||
}
|
||||
|
||||
@@ -862,12 +915,18 @@ async function valhallaExcluding(
|
||||
// and every routing request an instance makes still lands in the one counter.
|
||||
if (!signal?.aborted) countRoute({ ...sample, failed: !leg })
|
||||
if (!leg || !legAvoids(leg, exclude)) return null
|
||||
return valhallaOffer(leg)
|
||||
}
|
||||
|
||||
/** A Valhalla leg as an offer, carrying the engine that priced it and what it crosses. */
|
||||
function valhallaOffer(leg: ValhallaLeg): RouteAlternative {
|
||||
return {
|
||||
coordinates: leg.coordinates,
|
||||
distance: leg.distance,
|
||||
duration: leg.duration,
|
||||
divergence: null,
|
||||
engine: 'valhalla',
|
||||
hasFerry: leg.hasFerry,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -917,12 +976,89 @@ async function osrmExcluding(
|
||||
* It is what makes a choice persistable: saving that point as a via forces the router back
|
||||
* onto this road on every future request, without storing a polyline that would go stale
|
||||
* with the next OSM update.
|
||||
*
|
||||
* `avoid` is the classes the trip weighs away. A driving leg of such a trip is routed by
|
||||
* Valhalla, so its offers come from Valhalla too, weighed the same way, and never from
|
||||
* OSRM: a list holding both engines is what offered a road the rail was not on as the
|
||||
* quicker one, and clicking it changed nothing.
|
||||
*/
|
||||
export async function calculateAlternatives(
|
||||
from: Waypoint,
|
||||
to: Waypoint,
|
||||
profile: 'driving' | 'walking' | 'cycling' = 'driving',
|
||||
{ signal, limit = 3 }: { signal?: AbortSignal; limit?: number } = {},
|
||||
{ signal, limit = 3, avoid = [] }: { signal?: AbortSignal; limit?: number; avoid?: readonly AvoidClass[] } = {},
|
||||
): Promise<RouteAlternative[]> {
|
||||
const avoiding = avoidedClasses(profile, avoid)
|
||||
const routes = avoiding.length
|
||||
? await alternativesAvoiding(from, to, avoiding, limit, signal)
|
||||
: await alternativesFromOsrm(from, to, profile, limit, signal)
|
||||
if (!routes.length) return routes
|
||||
|
||||
// The first route is what the router would have given anyway; the others are measured
|
||||
// against it so each one can be pinned by the point that makes it different.
|
||||
const [primary, ...rest] = routes
|
||||
for (const alt of rest) alt.divergence = furthestPointFrom(alt.coordinates, primary.coordinates)
|
||||
return routes
|
||||
}
|
||||
|
||||
/**
|
||||
* The offers for a leg of a trip that weighs classes away, every one of them Valhalla's.
|
||||
*
|
||||
* Throws when Valhalla has nothing to say, so the picker reports a router that is not
|
||||
* answering. Quietly offering OSRM's roads instead is the very mix this path exists to end.
|
||||
*
|
||||
* One way back gets the question the OSRM path asks too: the same drive with one more
|
||||
* class left out, only of those the trip does not already avoid, and only where the road
|
||||
* that comes back really is without it. Paced like the pieces of a split day, because the
|
||||
* public instance allows one request a second.
|
||||
*/
|
||||
async function alternativesAvoiding(
|
||||
from: Waypoint,
|
||||
to: Waypoint,
|
||||
avoiding: AvoidClass[],
|
||||
limit: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RouteAlternative[]> {
|
||||
const sample = {
|
||||
profile: 'driving' as const,
|
||||
surface: 'alternatives' as const,
|
||||
selfHosted: !!useSettingsStore.getState().settings.routing_base_url?.trim(),
|
||||
waypoints: 2,
|
||||
km: haversineKm(from, to),
|
||||
}
|
||||
const answered = await valhallaAlternates(from, to, 'driving', avoiding, Math.max(0, limit - 1), signal)
|
||||
if (!signal?.aborted) countRoute({ ...sample, failed: !answered })
|
||||
if (!answered?.length) throw new Error('No route found')
|
||||
|
||||
const routes: RouteAlternative[] = []
|
||||
for (const leg of answered) {
|
||||
const offer = valhallaOffer(leg)
|
||||
if (!routes.some(r => sameRoad(r, offer))) routes.push(offer)
|
||||
}
|
||||
if (routes.length >= 2) return routes
|
||||
|
||||
for (const cls of EXCLUDABLE_CLASSES) {
|
||||
if (avoiding.includes(cls)) continue
|
||||
await valhallaSpacing(signal)
|
||||
if (signal?.aborted) break
|
||||
const run = await valhallaRun([from, to], 'driving', [...avoiding, cls], signal)
|
||||
if (!signal?.aborted) countRoute({ ...sample, failed: !run })
|
||||
if (!run || !legAvoids(run.total, cls)) continue
|
||||
const detour = valhallaOffer(run.total)
|
||||
if (routes.some(r => sameRoad(r, detour))) continue
|
||||
routes.push({ ...detour, avoids: cls })
|
||||
break
|
||||
}
|
||||
return routes
|
||||
}
|
||||
|
||||
/** The offers for a leg of a trip that avoids nothing: OSRM's, and one avoidance offer. */
|
||||
async function alternativesFromOsrm(
|
||||
from: Waypoint,
|
||||
to: Waypoint,
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
limit: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RouteAlternative[]> {
|
||||
const coords = `${from.lng},${from.lat};${to.lng},${to.lat}`
|
||||
const url = `${routeBaseFor(profile)}/${coords}?alternatives=${limit}&overview=full&geometries=geojson`
|
||||
@@ -960,11 +1096,6 @@ export async function calculateAlternatives(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The first route is what the router would have given anyway; the others are measured
|
||||
// against it so each one can be pinned by the point that makes it different.
|
||||
const [primary, ...rest] = routes
|
||||
for (const alt of rest) alt.divergence = furthestPointFrom(alt.coordinates, primary.coordinates)
|
||||
return routes
|
||||
}
|
||||
|
||||
@@ -982,31 +1113,78 @@ function furthestPointFrom(line: [number, number][], reference: [number, number]
|
||||
* nothing for it. The winner is converted once at the end, because `sameRoad` needs a
|
||||
* real distance rather than a rank, and half a degree is a different number of metres in
|
||||
* Andalusia than in Lapland.
|
||||
*
|
||||
* `index` is where on `line` the point sits, so points taken off one line can be put back
|
||||
* in the order that line drives through them.
|
||||
*/
|
||||
function furthestFrom(
|
||||
export function furthestFrom(
|
||||
line: [number, number][],
|
||||
reference: [number, number][],
|
||||
): { lat: number; lng: number; km: number } | null {
|
||||
): { index: number; lat: number; lng: number; km: number } | null {
|
||||
if (!line.length || !reference.length) return null
|
||||
// Every tenth vertex is plenty: alternatives differ over kilometres, not metres, and a
|
||||
// full cross product of two thousand-point lines is not worth the milliseconds.
|
||||
const step = Math.max(1, Math.floor(reference.length / 200))
|
||||
let best: { lat: number; lng: number } | null = null
|
||||
let bestIndex = -1
|
||||
let bestNeighbour: [number, number] | null = null
|
||||
let bestDist = -1
|
||||
for (let i = 0; i < line.length; i += Math.max(1, Math.floor(line.length / 200))) {
|
||||
const [lat, lng] = line[i]
|
||||
let nearest = Infinity
|
||||
let nearestPoint: [number, number] | null = null
|
||||
for (let j = 0; j < reference.length; j += step) {
|
||||
const dLat = lat - reference[j][0]
|
||||
const dLng = (lng - reference[j][1]) * Math.cos((lat * Math.PI) / 180)
|
||||
const d = dLat * dLat + dLng * dLng
|
||||
if (d < nearest) { nearest = d; nearestPoint = reference[j] }
|
||||
}
|
||||
if (nearest > bestDist) { bestDist = nearest; best = { lat, lng }; bestNeighbour = nearestPoint }
|
||||
const near = nearestOn(line[i], reference, step)
|
||||
if (near.d > bestDist) { bestDist = near.d; bestIndex = i; bestNeighbour = near.at }
|
||||
}
|
||||
if (!best) return null
|
||||
const km = bestNeighbour ? haversineKm(best, { lat: bestNeighbour[0], lng: bestNeighbour[1] }) : 0
|
||||
return { ...best, km }
|
||||
if (bestIndex < 0) return null
|
||||
const [lat, lng] = line[bestIndex]
|
||||
const km = bestNeighbour ? haversineKm({ lat, lng }, { lat: bestNeighbour[0], lng: bestNeighbour[1] }) : 0
|
||||
return { index: bestIndex, lat, lng, km }
|
||||
}
|
||||
|
||||
/**
|
||||
* Where `reference` passes closest to `point`, as a squared distance in the same scaled
|
||||
* degrees and the spot on the line it was measured to.
|
||||
*
|
||||
* Coarse, then fine. The sampled vertices find the stretch, and the segments either side
|
||||
* of the nearest one find the road itself. Measured to the sampled vertex alone, a point
|
||||
* on the very same road sat up to half a sample apart from it, which on a thousand
|
||||
* kilometre leg is kilometres: two engines' lines of one motorway then read as two roads.
|
||||
*/
|
||||
function nearestOn(
|
||||
point: [number, number],
|
||||
reference: [number, number][],
|
||||
step: number,
|
||||
): { d: number; at: [number, number] } {
|
||||
const cos = Math.cos((point[0] * Math.PI) / 180)
|
||||
const squared = (p: [number, number]): number => {
|
||||
const dLat = point[0] - p[0]
|
||||
const dLng = (point[1] - p[1]) * cos
|
||||
return dLat * dLat + dLng * dLng
|
||||
}
|
||||
let nearestIndex = 0
|
||||
let nearest = Infinity
|
||||
for (let j = 0; j < reference.length; j += step) {
|
||||
const d = squared(reference[j])
|
||||
if (d < nearest) { nearest = d; nearestIndex = j }
|
||||
}
|
||||
let at = reference[nearestIndex]
|
||||
const last = Math.min(reference.length - 1, nearestIndex + step)
|
||||
for (let k = Math.max(0, nearestIndex - step); k < last; k++) {
|
||||
const on = closestOnSegment(point, reference[k], reference[k + 1], cos)
|
||||
const d = squared(on)
|
||||
if (d < nearest) { nearest = d; at = on }
|
||||
}
|
||||
return { d: nearest, at }
|
||||
}
|
||||
|
||||
/** The point of segment a-b closest to `p`, with longitude scaled by `cos` so the plane is fair. */
|
||||
function closestOnSegment(
|
||||
p: [number, number],
|
||||
a: [number, number],
|
||||
b: [number, number],
|
||||
cos: number,
|
||||
): [number, number] {
|
||||
const dx = (b[1] - a[1]) * cos
|
||||
const dy = b[0] - a[0]
|
||||
const length = dx * dx + dy * dy
|
||||
if (length === 0) return a
|
||||
const t = Math.max(0, Math.min(1, (((p[1] - a[1]) * cos) * dx + (p[0] - a[0]) * dy) / length))
|
||||
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { valhallaBase, valhallaAvailable, valhallaRouteAvoiding, valhallaRun, runFrom, legAvoids } from './valhallaRoute'
|
||||
import { valhallaBase, valhallaAvailable, valhallaRouteAvoiding, valhallaRun, valhallaAlternates, alternatesFrom, runFrom, legAvoids } from './valhallaRoute'
|
||||
|
||||
const FOSSGIS_VALHALLA = 'https://valhalla1.openstreetmap.de/route'
|
||||
|
||||
@@ -365,3 +365,62 @@ describe('valhallaRun over a whole day', () => {
|
||||
await expect(valhallaRun(stops(6), 'driving', [])).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('valhallaAlternates', () => {
|
||||
/** The same leg read three ways, the second of them crossing by ferry. */
|
||||
const withAlternates = (alternates: unknown[]) => ({
|
||||
...answer({ time: 11_460 }),
|
||||
alternates,
|
||||
})
|
||||
|
||||
it('VALHALLA-ALT-001: reads the preferred way, then every alternate, each with its own flags', () => {
|
||||
const ways = alternatesFrom(withAlternates([
|
||||
answer({ time: 12_000, has_ferry: true }),
|
||||
answer({ time: 13_000, has_toll: true }),
|
||||
]))
|
||||
|
||||
expect(ways?.map(w => w.duration)).toEqual([11_460, 12_000, 13_000])
|
||||
expect(ways?.map(w => w.hasFerry)).toEqual([false, true, false])
|
||||
expect(ways?.map(w => w.hasToll)).toEqual([false, false, true])
|
||||
expect(ways?.[1].coordinates).toEqual(SHAPE_POINTS)
|
||||
})
|
||||
|
||||
it('VALHALLA-ALT-002: an alternate that cannot be read is dropped, a preferred way that cannot is no answer', () => {
|
||||
// One bad entry is no reason to lose the leg's other ways, but the others are offered
|
||||
// against the preferred one and mean nothing without it.
|
||||
expect(alternatesFrom(withAlternates([{ trip: { legs: [] } }, answer({ time: 13_000 })]))?.map(w => w.duration))
|
||||
.toEqual([11_460, 13_000])
|
||||
expect(alternatesFrom(answer())).toHaveLength(1)
|
||||
expect(alternatesFrom({ alternates: [answer()] })).toBeNull()
|
||||
expect(alternatesFrom({ error_code: 442, error: 'No path could be found for input' })).toBeNull()
|
||||
})
|
||||
|
||||
it('VALHALLA-ALT-003: asks for the leg between exactly its two ends, weighed the way the trip is, with the number of alternates', async () => {
|
||||
const bodies: Record<string, unknown>[] = []
|
||||
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
bodies.push(await request.json() as Record<string, unknown>)
|
||||
return HttpResponse.json(withAlternates([answer({ time: 12_000 })]))
|
||||
}))
|
||||
|
||||
const ways = await valhallaAlternates(HAMBURG, BERLIN, 'driving', ['toll', 'ferry'], 2)
|
||||
await valhallaAlternates(HAMBURG, BERLIN, 'driving', ['toll'], 0)
|
||||
|
||||
expect(ways).toHaveLength(2)
|
||||
expect(bodies[0]).toMatchObject({ alternates: 2, costing: 'auto', costing_options: { auto: { use_tolls: 0, use_ferry: 0 } } })
|
||||
expect(bodies[0].locations).toHaveLength(2)
|
||||
// No alternates asked for, none named: the request stays the plain one.
|
||||
expect(bodies[1]).not.toHaveProperty('alternates')
|
||||
})
|
||||
|
||||
it('VALHALLA-ALT-004: a refusal is asked once more, then is no answer; an instance with its own OSRM asks nothing', async () => {
|
||||
let calls = 0
|
||||
server.use(http.post(FOSSGIS_VALHALLA, () => { calls++; return new HttpResponse(null, { status: 429 }) }))
|
||||
|
||||
await expect(valhallaAlternates(HAMBURG, BERLIN, 'driving', ['toll'], 2)).resolves.toBeNull()
|
||||
expect(calls).toBe(2)
|
||||
|
||||
setSettings({ routing_base_url: 'https://osrm.example.org' })
|
||||
await expect(valhallaAlternates(HAMBURG, BERLIN, 'driving', ['toll'], 2)).resolves.toBeNull()
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -204,14 +204,7 @@ export async function valhallaRun(
|
||||
// rate limit on the last piece of a split day would otherwise throw away the pieces
|
||||
// that already answered and ask for all of them again — three times over, against a
|
||||
// host that allows one request a second.
|
||||
let run = await requestRun(base, chunk, profile, avoid, signal)
|
||||
for (let attempt = 0; !run && attempt < CHUNK_RETRIES && !signal?.aborted; attempt++) {
|
||||
// Not when the host just proved it is not a Valhalla at all: that answer will not
|
||||
// change on a second ask, and asking anyway is the request the memo exists to save.
|
||||
if (notValhalla.has(base)) break
|
||||
await pause(CHUNK_PAUSE_MS, signal)
|
||||
run = await requestRun(base, chunk, profile, avoid, signal)
|
||||
}
|
||||
const run = await askWithRetry(base, () => requestRun(base, chunk, profile, avoid, signal), signal)
|
||||
// A day is one answer or none. Half a day of legs would be worse than no answer:
|
||||
// the schedule chains leg times blindly and cannot tell that it is missing some, so
|
||||
// it would print a complete, plausible, wrong timetable rather than a gap.
|
||||
@@ -260,14 +253,39 @@ function pause(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
/** One request, at most ten locations. */
|
||||
async function requestRun(
|
||||
base: string,
|
||||
/**
|
||||
* Waits out the host's one request a second before the next question is put.
|
||||
*
|
||||
* For a caller that asks this engine several things in a row on its own, such as the
|
||||
* offers for one leg: the same pause the pieces of a split day keep, so a picker cannot
|
||||
* fire at a pace the day routing was careful never to.
|
||||
*/
|
||||
export function valhallaSpacing(signal?: AbortSignal): Promise<void> {
|
||||
return pause(CHUNK_PAUSE_MS, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* One question, and one more try after a pause when it earned no answer.
|
||||
*
|
||||
* Not when the host just proved it is not a Valhalla at all: that answer will not change
|
||||
* on a second ask, and asking anyway is the request the memo exists to save.
|
||||
*/
|
||||
async function askWithRetry<T>(base: string, ask: () => Promise<T | null>, signal?: AbortSignal): Promise<T | null> {
|
||||
let answer = await ask()
|
||||
for (let attempt = 0; !answer && attempt < CHUNK_RETRIES && !signal?.aborted; attempt++) {
|
||||
if (notValhalla.has(base)) break
|
||||
await pause(CHUNK_PAUSE_MS, signal)
|
||||
answer = await ask()
|
||||
}
|
||||
return answer
|
||||
}
|
||||
|
||||
/** The request body for a chain of waypoints, weighted away from `avoid`. */
|
||||
function routeBody(
|
||||
waypoints: Waypoint[],
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
avoid: readonly AvoidClass[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<Omit<ValhallaRun, 'snapped'> | null> {
|
||||
): Record<string, unknown> {
|
||||
const costing = COSTING[profile]
|
||||
// All of them in one request: the options are independent weightings, so asking to
|
||||
// leave out tolls and ferries together is one question rather than two routes to
|
||||
@@ -275,13 +293,16 @@ async function requestRun(
|
||||
// away together answered 656.9 km against 339.2 km for the plain route.
|
||||
const options: Record<string, number> = {}
|
||||
for (const cls of avoid) options[AVOID_OPTION[cls]] = 0
|
||||
const body = {
|
||||
return {
|
||||
locations: waypoints.map(w => ({ lat: w.lat, lon: w.lng, radius: SNAP_RADIUS_M })),
|
||||
costing,
|
||||
costing_options: { [costing]: options },
|
||||
directions_options: { units: 'kilometers' },
|
||||
}
|
||||
}
|
||||
|
||||
/** The parsed answer to one POST, or null for every failure. */
|
||||
async function postRoute(base: string, body: Record<string, unknown>, signal?: AbortSignal): Promise<unknown> {
|
||||
try {
|
||||
const response = await fetch(`${base}/route`, {
|
||||
method: 'POST',
|
||||
@@ -299,12 +320,66 @@ async function requestRun(
|
||||
if (response.status === 404 || response.status === 405) notValhalla.add(base)
|
||||
return null
|
||||
}
|
||||
return runFrom(await response.json())
|
||||
return await response.json()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** One request, at most ten locations. */
|
||||
async function requestRun(
|
||||
base: string,
|
||||
waypoints: Waypoint[],
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
avoid: readonly AvoidClass[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<Omit<ValhallaRun, 'snapped'> | null> {
|
||||
const data = await postRoute(base, routeBody(waypoints, profile, avoid), signal)
|
||||
return data === null ? null : runFrom(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* The ways of driving one leg this engine would offer, its preferred one first, or null.
|
||||
*
|
||||
* The offers for a trip that avoids something have to come from the engine that routes
|
||||
* that trip's days, or the list and the rail describe two different drives: OSRM's
|
||||
* quickest road beside a day Valhalla had routed was offered as the faster way, and
|
||||
* taking it changed nothing. Valhalla answers `alternates` between exactly two locations,
|
||||
* which is all one leg is, and weighs every one of them the way it weighed the day.
|
||||
*/
|
||||
export async function valhallaAlternates(
|
||||
from: Waypoint,
|
||||
to: Waypoint,
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
avoid: readonly AvoidClass[],
|
||||
count: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ValhallaLeg[] | null> {
|
||||
const base = valhallaBase()
|
||||
if (!base || notValhalla.has(base)) return null
|
||||
const body = { ...routeBody([from, to], profile, avoid), ...(count > 0 ? { alternates: count } : {}) }
|
||||
return askWithRetry(base, async () => {
|
||||
const data = await postRoute(base, body, signal)
|
||||
return data === null ? null : alternatesFrom(data)
|
||||
}, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every way in an answer that asked for alternates: `trip` first, then each
|
||||
* `alternates[].trip`, read the way `runFrom` reads a single leg.
|
||||
*
|
||||
* Null when the preferred one cannot be read, because the others are offered against it.
|
||||
* An alternate that cannot be read is dropped on its own: one bad entry is no reason to
|
||||
* lose the leg's other ways.
|
||||
*/
|
||||
export function alternatesFrom(data: unknown): ValhallaLeg[] | null {
|
||||
const primary = runFrom(data)
|
||||
if (!primary) return null
|
||||
const extra = (data as { alternates?: unknown })?.alternates
|
||||
const others = Array.isArray(extra) ? extra.map(alt => runFrom(alt)?.total ?? null) : []
|
||||
return [primary.total, ...others.filter((leg): leg is ValhallaLeg => leg !== null)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Several legs read as one, the way a caller that only wants the whole drive sees it.
|
||||
*
|
||||
|
||||
@@ -5,9 +5,10 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import RoadtripAlternativesBar from './RoadtripAlternativesBar'
|
||||
import type { AlternativeOverlay } from './alternativeOverlays'
|
||||
import type { LegAlternatives } from './useRouteAlternatives'
|
||||
import { openLeg } from '../../../tests/helpers/legAlternatives'
|
||||
|
||||
/**
|
||||
* FE-ALTBAR-001..009 — the ways of driving one leg, offered over the map.
|
||||
* FE-ALTBAR-001..012: the ways of driving one leg, offered over the map.
|
||||
*
|
||||
* Every value shown comes from the same overlay the map draws. Working them out
|
||||
* here separately is what once made the list label the driven route "Fastest"
|
||||
@@ -31,9 +32,7 @@ const overlay = (over: Partial<AlternativeOverlay> = {}): AlternativeOverlay =>
|
||||
...over,
|
||||
})
|
||||
|
||||
const leg = (over: Partial<LegAlternatives> = {}): LegAlternatives => ({
|
||||
dayId: 4, index: 1, routes: [], loading: false, error: false, ...over,
|
||||
})
|
||||
const leg = (over: Partial<LegAlternatives> = {}): LegAlternatives => openLeg({ dayId: 4, index: 1, ...over })
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ settings: { distance_unit: 'metric' } as never })
|
||||
@@ -158,4 +157,36 @@ describe('RoadtripAlternativesBar', () => {
|
||||
)
|
||||
expect(screen.getAllByLabelText(/avoidance router/i)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-ALTBAR-011: while a choice is checked and saved the list stands still, and the one in hand says so', () => {
|
||||
// A second click would race the first, and the router's answer to it could land on
|
||||
// the leg after it.
|
||||
const onChoose = vi.fn()
|
||||
render(
|
||||
<RoadtripAlternativesBar
|
||||
open={leg({ proving: 1 })}
|
||||
overlays={[overlay({ index: 0, note: 'Current' }), overlay({ index: 1 }), overlay({ index: 2 })]}
|
||||
onChoose={onChoose}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
const [, current, checked, other] = screen.getAllByRole('button')
|
||||
expect([current, checked, other].every(chip => (chip as HTMLButtonElement).disabled)).toBe(true)
|
||||
expect(checked).toHaveAttribute('aria-busy', 'true')
|
||||
expect(other).toHaveAttribute('aria-busy', 'false')
|
||||
expect(checked.parentElement).toHaveAttribute('aria-busy', 'true')
|
||||
fireEvent.click(other)
|
||||
expect(onChoose).not.toHaveBeenCalled()
|
||||
// Closing stays available: it is how a check nobody wants to wait for is abandoned.
|
||||
expect(screen.getByRole('button', { name: /close/i })).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-ALTBAR-012: with nothing being checked every offer can be taken', () => {
|
||||
render(
|
||||
<RoadtripAlternativesBar open={leg()} overlays={[overlay({ index: 0 }), overlay({ index: 1 })]} onChoose={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
const chips = screen.getAllByRole('button').slice(1)
|
||||
expect(chips.every(chip => !(chip as HTMLButtonElement).disabled)).toBe(true)
|
||||
expect(chips[0].parentElement).toHaveAttribute('aria-busy', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React from 'react'
|
||||
import { Shuffle, X, AlertTriangle, Info } from 'lucide-react'
|
||||
import { Shuffle, X, AlertTriangle, Info, Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
import type { LegAlternatives } from './useRouteAlternatives'
|
||||
import { alternativesPhase, alternativeSubline, type AlternativeOverlay } from './alternativeOverlays'
|
||||
import { alternativesBusy, alternativesPhase, alternativeSubline, type AlternativeOverlay } from './alternativeOverlays'
|
||||
|
||||
interface RoadtripAlternativesBarProps {
|
||||
open: LegAlternatives | null
|
||||
@@ -35,6 +35,9 @@ export default function RoadtripAlternativesBar({
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
if (!open) return null
|
||||
const phase = alternativesPhase(open, overlays)
|
||||
// While a choice is checked against the router and written, the list stands still: a
|
||||
// second click would race the first, and its answer could land on the leg after it.
|
||||
const busy = alternativesBusy(open)
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto flex max-w-[min(92vw,640px)] flex-col gap-2 rounded-2xl border border-edge-faint bg-surface-elevated px-3 py-2.5 shadow-modal backdrop-blur">
|
||||
@@ -64,25 +67,31 @@ export default function RoadtripAlternativesBar({
|
||||
// One route back means there is genuinely only one sensible way to drive it.
|
||||
<p className="text-caption text-content-muted">{t('roadtrip.alt.onlyOne')}</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<div className="flex flex-wrap gap-1.5" aria-busy={busy}>
|
||||
{overlays.map(alt => (
|
||||
<button
|
||||
key={alt.index}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
aria-busy={open.proving === alt.index}
|
||||
onClick={() => onChoose(alt.index)}
|
||||
onMouseEnter={() => onHighlight?.(alt.index)}
|
||||
onMouseLeave={() => onHighlight?.(null)}
|
||||
onFocus={() => onHighlight?.(alt.index)}
|
||||
onBlur={() => onHighlight?.(null)}
|
||||
className="flex items-center gap-2 rounded-xl border border-edge bg-surface-card px-2.5 py-1.5 text-start transition-colors hover:border-content-faint"
|
||||
className="flex items-center gap-2 rounded-xl border border-edge bg-surface-card px-2.5 py-1.5 text-start transition-colors hover:border-content-faint disabled:cursor-default disabled:opacity-60 disabled:hover:border-edge"
|
||||
>
|
||||
<span
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
// theme-lint-disable — the very colour this route is drawn in on the map;
|
||||
// a token here would break the one link between list and picture.
|
||||
style={{ background: alt.color }}
|
||||
aria-hidden
|
||||
/>
|
||||
{open.proving === alt.index ? (
|
||||
<Loader2 size={10} className="shrink-0 animate-spin text-content-faint" aria-hidden />
|
||||
) : (
|
||||
<span
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
// theme-lint-disable: the very colour this route is drawn in on the map;
|
||||
// a token here would break the one link between list and picture.
|
||||
style={{ background: alt.color }}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<span className="flex flex-col">
|
||||
<span className="text-caption font-medium tabular-nums text-content">
|
||||
{formatDistance(alt.distance / 1000, distanceUnit)}
|
||||
|
||||
@@ -1164,6 +1164,17 @@ describe('RoadtripSidebar with a ride (#2428)', () => {
|
||||
expect(screen.getAllByLabelText('Other ways')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-059: asking names the drive as a leg of its card, not as a bare position', () => {
|
||||
// A tagged drive rather than an index, so the road arriving at the head of a card can
|
||||
// be asked about as what it is instead of under an index no stop has.
|
||||
const onAskAlternatives = vi.fn()
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [flightDay()], totalStops: 2 })} onOpenBooking={vi.fn()} onAskAlternatives={onAskAlternatives} />)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Other ways'))
|
||||
|
||||
expect(onAskAlternatives).toHaveBeenCalledWith(1, { kind: 'leg', index: 0 })
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-051: a ride landing tomorrow leaves its departure as a row of its own, with the road into it', () => {
|
||||
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), terminal('departure')]
|
||||
const overnight = day({
|
||||
|
||||
@@ -26,6 +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 { dayColor } from './dayColors'
|
||||
import type { RouteVia } from '../../types'
|
||||
import { FS } from './typeScale'
|
||||
@@ -51,8 +52,8 @@ interface RoadtripSidebarProps {
|
||||
onReorderStop?: (dayId: number, assignmentId: number, toIndex: number) => void
|
||||
/** Moves a stop onto a different day. Absent means moves stay inside their own day. */
|
||||
onMoveStopToDay?: (fromDayId: number, assignmentId: number, toDayId: number, toIndex: number) => void
|
||||
/** Asks for other ways of driving one leg (#1797). */
|
||||
onAskAlternatives?: (dayId: number, legIndex: number) => void
|
||||
/** Asks for other ways of driving one drive on a card (#1797). */
|
||||
onAskAlternatives?: (dayId: number, drive: RailDrive) => void
|
||||
/**
|
||||
* The one-shot search for somewhere to fill up before the tank runs out.
|
||||
*
|
||||
@@ -1732,7 +1733,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca
|
||||
{i < last && (!day.stops[i + 1].automaticNight || day.legs[i]?.distance !== 0) ? (
|
||||
<DriveBand
|
||||
leg={day.legs[i]}
|
||||
onAskAlternatives={onAskAlternatives && legReroutable(day, i) ? () => onAskAlternatives(day.dayId, i) : undefined}
|
||||
onAskAlternatives={onAskAlternatives && legReroutable(day, i) ? () => onAskAlternatives(day.dayId, { kind: 'leg', index: i }) : undefined}
|
||||
alternativesOpen={openAlternatives?.dayId === day.dayId && openAlternatives.index === i}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { alternativeSubline, alternativesPhase, buildAlternativeOverlays, type AlternativeOverlay } from './alternativeOverlays'
|
||||
import { alternativeSubline, alternativesBusy, alternativesPhase, buildAlternativeOverlays, type AlternativeOverlay } from './alternativeOverlays'
|
||||
import { formatDurationShort } from './roadtripModel'
|
||||
import { ALT_PRIMARY, ALT_SECONDARY, ALT_LABEL_PRIMARY_BG, ALT_LABEL_SECONDARY_BG } from './alternativeColors'
|
||||
import type { RouteAlternative } from '../Map/RouteCalculator'
|
||||
|
||||
/**
|
||||
* FE-ALTOVL-001..017: turning the router's answers into something drawable.
|
||||
* FE-ALTOVL-001..021: turning the router's answers into something drawable.
|
||||
*
|
||||
* Two decisions live here and neither is cosmetic: which of the offered roads is
|
||||
* drawn as the one you are on, and where each label hangs. A label anchored on a
|
||||
@@ -221,6 +221,56 @@ describe('buildAlternativeOverlays', () => {
|
||||
expect(out[0].note).toBe('Current')
|
||||
expect(out[1].color).toBe(ALT_SECONDARY)
|
||||
})
|
||||
|
||||
it('FE-ALTOVL-018: the blue belongs to the road being driven, never to the quickest offer', () => {
|
||||
// The picker always lists the rail's road as the current one. After a choice took, the
|
||||
// old road came back as the router's quickest and was painted blue as if still driven.
|
||||
const out = buildAlternativeOverlays(
|
||||
[
|
||||
alt({ duration: 11_400, coordinates: line(20, 0), current: true }),
|
||||
alt({ duration: 10_800, coordinates: line(20, 1) }),
|
||||
alt({ duration: 12_000, coordinates: line(20, 2) }),
|
||||
],
|
||||
LABELS,
|
||||
)
|
||||
expect(out.map(o => o.color)).toEqual([ALT_PRIMARY, ALT_SECONDARY, ALT_SECONDARY])
|
||||
expect(out.map(o => o.note)).toEqual(['Current', 'Fastest', ''])
|
||||
// Current takes part in the comparison: it is the rail's engine's own figure.
|
||||
expect(out[0].slowerThanQuickest).toBe(600)
|
||||
})
|
||||
|
||||
it('FE-ALTOVL-019: on a leg the second engine drives, its offers are the comparable ones', () => {
|
||||
// A trip that avoids something is routed by Valhalla, rail and offers alike. Read
|
||||
// against OSRM they would all be marked as another engine's and none could be timed.
|
||||
const out = buildAlternativeOverlays(
|
||||
[
|
||||
alt({ duration: 6_778, coordinates: line(20, 0), current: true, engine: 'valhalla' }),
|
||||
alt({ duration: 6_400, coordinates: line(20, 1), engine: 'valhalla' }),
|
||||
alt({ duration: 5_600, coordinates: line(20, 2) }),
|
||||
],
|
||||
LABELS,
|
||||
'valhalla',
|
||||
)
|
||||
expect(out.map(o => o.otherEngine)).toEqual([false, false, true])
|
||||
// The quickest is elected among Valhalla's figures, so the OSRM one cannot take it.
|
||||
expect(out[1].note).toBe('Fastest')
|
||||
expect(out[0].slowerThanQuickest).toBe(378)
|
||||
expect(out[2].slowerThanQuickest).toBe(0)
|
||||
})
|
||||
|
||||
it('FE-ALTOVL-020: on a leg a plugin prices, every router offer is read as another engine', () => {
|
||||
const out = buildAlternativeOverlays(
|
||||
[
|
||||
alt({ duration: 9_000, coordinates: line(20, 0), current: true, engine: 'plugin' }),
|
||||
alt({ duration: 7_200, coordinates: line(20, 1) }),
|
||||
],
|
||||
LABELS,
|
||||
'plugin',
|
||||
)
|
||||
expect(out.map(o => o.otherEngine)).toEqual([false, true])
|
||||
expect(out[1].note).toBe('')
|
||||
expect(out.every(o => o.slowerThanQuickest === 0)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('alternativesPhase', () => {
|
||||
@@ -253,5 +303,19 @@ describe('alternativeSubline', () => {
|
||||
// The caller words the difference; the figure is the same short duration the map prints.
|
||||
expect(alternativeSubline(overlay({ note: '', slowerThanQuickest: 1800 }), slower))
|
||||
.toBe(`${formatDurationShort(1800)} slower`)
|
||||
// Another engine's road has no difference to print, only its own time.
|
||||
expect(alternativeSubline(overlay({ note: '', otherEngine: true, label: '2 h', slowerThanQuickest: 0 }), slower))
|
||||
.toBe('2 h')
|
||||
})
|
||||
})
|
||||
|
||||
describe('alternativesBusy', () => {
|
||||
it('FE-ALTOVL-021: a picker is busy exactly while one of its offers is being checked', () => {
|
||||
expect(alternativesBusy(null)).toBe(false)
|
||||
expect(alternativesBusy(undefined)).toBe(false)
|
||||
expect(alternativesBusy({ proving: null })).toBe(false)
|
||||
// Index 0 is a real offer, not "nothing".
|
||||
expect(alternativesBusy({ proving: 0 })).toBe(true)
|
||||
expect(alternativesBusy({ proving: 2 })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { formatDurationShort } from './roadtripModel'
|
||||
import { ALT_PRIMARY, ALT_SECONDARY, ALT_LABEL_PRIMARY_BG, ALT_LABEL_SECONDARY_BG } from './alternativeColors'
|
||||
import type { RouteAlternative } from '../Map/RouteCalculator'
|
||||
import type { RouteAlternative, RouteEngine } from '../Map/RouteCalculator'
|
||||
import type { LegAlternatives } from './useRouteAlternatives'
|
||||
|
||||
/** One offered route, ready to draw: the line, its colour, and where its label sits. */
|
||||
@@ -88,29 +88,35 @@ function mostDistinctPoint(
|
||||
* Each label hangs where its own route is furthest from all the others, so a label always
|
||||
* sits on a stretch only that route uses. Two routes that share their first and last
|
||||
* thirds still get their labels on the middle third, where they actually differ.
|
||||
*
|
||||
* `railEngine` is the engine the rail drives this leg with. Only routes that engine
|
||||
* priced can be read against the road being driven; every other one is marked as the
|
||||
* other engine's and takes part in no comparison.
|
||||
*/
|
||||
export function buildAlternativeOverlays(
|
||||
routes: (RouteAlternative & { current?: boolean; direct?: boolean })[] | undefined,
|
||||
labels: { fastest: string; current: string; noMotorway: string; noToll: string; noFerry: string },
|
||||
railEngine: RouteEngine = 'osrm',
|
||||
): AlternativeOverlay[] {
|
||||
if (!routes?.length) return []
|
||||
// A single answer is not a choice; drawing it would just double the route already there.
|
||||
if (routes.length < 2) return []
|
||||
|
||||
// The quickest of what came back, which is what Apple calls out — not necessarily the
|
||||
// first entry, since the road currently driven is put at the top when there is one.
|
||||
// first entry, since the road currently driven is put at the top.
|
||||
//
|
||||
// Only among the routes one engine priced. The avoidance offer on a default install
|
||||
// comes from the second engine, whose speed model differs by up to a seventh either
|
||||
// way depending on the region, so letting it into this election decides which road is
|
||||
// blue and how much slower every other road is called on nothing but that gap. On a
|
||||
// Spanish motorway leg that is enough to crown a toll-free B-road detour and label
|
||||
// the motorway somebody is actually driving as the slower way round.
|
||||
// Only among the routes the rail's own engine priced, the current road included. An
|
||||
// offer from another engine runs on a speed model that differs by up to a seventh
|
||||
// either way depending on the region, so letting it into this election decides which
|
||||
// road is fastest and how much slower every other road is called on nothing but that
|
||||
// gap. On a Spanish motorway leg that is enough to crown a toll-free B-road detour and
|
||||
// label the motorway somebody is actually driving as the slower way round.
|
||||
//
|
||||
// Index 0 is the fallback rather than -1: the first entry is the road being driven
|
||||
// when there is one and the router's own pick otherwise, so if a list ever held
|
||||
// nothing but second-engine routes, that is still the one to call primary.
|
||||
const comparable = routes.map((r, i) => (r.engine ? -1 : i)).filter(i => i >= 0)
|
||||
// nothing the rail's engine priced, that is still the one to call primary.
|
||||
const engineOf = (route: RouteAlternative): RouteEngine => route.engine ?? 'osrm'
|
||||
const comparable = routes.map((r, i) => (engineOf(r) === railEngine ? i : -1)).filter(i => i >= 0)
|
||||
const quickest = comparable.length
|
||||
? comparable.reduce((best, i) => (routes[i].duration < routes[best].duration ? i : best), comparable[0])
|
||||
: 0
|
||||
@@ -119,10 +125,11 @@ export function buildAlternativeOverlays(
|
||||
return routes.map((route, index) => {
|
||||
const others = routes.filter((_, i) => i !== index).map(r => r.coordinates)
|
||||
const at = mostDistinctPoint(route.coordinates, others)
|
||||
// Blue is the road you are on: the one currently driven, or the router's own pick
|
||||
// when nothing has been bent. Everything else is the pale blue of an offer.
|
||||
const primary = route.current || (!anyCurrent && index === quickest)
|
||||
const otherEngine = !!route.engine
|
||||
// Blue is the road you are on, which the picker always lists as the current one,
|
||||
// however slow it is. Only a list without it (none is built that way any more) falls
|
||||
// back to the quickest. Everything else is the pale blue of an offer.
|
||||
const primary = anyCurrent ? !!route.current : index === quickest
|
||||
const otherEngine = engineOf(route) !== railEngine
|
||||
return {
|
||||
index,
|
||||
coordinates: route.coordinates,
|
||||
@@ -146,7 +153,7 @@ export function buildAlternativeOverlays(
|
||||
// that is the only thing to measure against. The bar reads a zero as "no
|
||||
// difference worth printing" and falls back to naming what the road is, which
|
||||
// for these is always the class left out of it.
|
||||
slowerThanQuickest: otherEngine || !!routes[quickest].engine
|
||||
slowerThanQuickest: otherEngine || engineOf(routes[quickest]) !== railEngine
|
||||
? 0
|
||||
: Math.max(0, Math.round(route.duration - routes[quickest].duration)),
|
||||
otherEngine,
|
||||
@@ -189,5 +196,17 @@ export function alternativesPhase(
|
||||
* instead of each keeping its own.
|
||||
*/
|
||||
export function alternativeSubline(alt: AlternativeOverlay, slower: (time: string) => string): string {
|
||||
return alt.note || slower(formatDurationShort(alt.slowerThanQuickest))
|
||||
if (alt.note) return alt.note
|
||||
// Another engine's road has no difference to the quickest, only its own time: a "0 min
|
||||
// slower" under it would read as a tie that nobody measured.
|
||||
if (alt.otherEngine) return alt.label
|
||||
return slower(formatDurationShort(alt.slowerThanQuickest))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a choice is being checked and saved, so neither bar takes another one meanwhile.
|
||||
* One reading for the desk and the phone, which both stand still while it runs.
|
||||
*/
|
||||
export function alternativesBusy(open: Pick<LegAlternatives, 'proving'> | null | undefined): boolean {
|
||||
return open?.proving != null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { MAX_PINS, pinAlternative, 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.
|
||||
*
|
||||
* 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
|
||||
* pulls the point to the pier and drives through Calais), and on a way weighed away from
|
||||
* motorways it kept the motorway after the one point. These cases pin down how the pins
|
||||
* are found, how many, in which order, and when there is no verdict at all.
|
||||
*/
|
||||
|
||||
/** The road being driven, and a detour that leaves it twice before rejoining. */
|
||||
const RAIL: [number, number][] = [[53.55, 9.99], [53.0, 11.5], [52.52, 13.4]]
|
||||
const DETOUR: [number, number][] = [[53.55, 9.99], [53.4, 11.5], [53.3, 12.8], [52.52, 13.4]]
|
||||
|
||||
const offer = (over: Partial<OfferedRoute> = {}): OfferedRoute =>
|
||||
({ coordinates: DETOUR, distance: 310_000, duration: 11_400, divergence: null, ...over })
|
||||
const current = { coordinates: RAIL, distance: 290_000, duration: 10_800 }
|
||||
const answer = (coordinates: [number, number][], over: Partial<RailLegRoute> = {}): RailLegRoute =>
|
||||
({ coordinates, distance: 300_000, duration: 11_000, ...over })
|
||||
|
||||
const signal = () => new AbortController().signal
|
||||
type Route = RailLegRouter['route']
|
||||
|
||||
describe('pinAlternative', () => {
|
||||
it('FE-ALTPIN-001: the router own preference is asked with no pin at all', async () => {
|
||||
const route = vi.fn<Route>(async () => answer(DETOUR))
|
||||
|
||||
const proof = await pinAlternative({ offer: offer({ direct: true }), current, route, signal: signal() })
|
||||
|
||||
expect(route).toHaveBeenCalledTimes(1)
|
||||
expect(route.mock.calls[0][0]).toEqual([])
|
||||
expect(proof).toMatchObject({ held: true, pins: [], fellBack: false })
|
||||
})
|
||||
|
||||
it('FE-ALTPIN-002: any other offer starts from the point where it leaves the driven road furthest', async () => {
|
||||
const route = vi.fn<Route>(async () => answer(DETOUR))
|
||||
|
||||
const proof = await pinAlternative({ offer: offer(), current, route, signal: signal() })
|
||||
|
||||
expect(route).toHaveBeenCalledTimes(1)
|
||||
expect(proof).toMatchObject({ held: true, pins: [{ lat: 53.3, lng: 12.8 }] })
|
||||
})
|
||||
|
||||
it('FE-ALTPIN-003: an answer that is not the offer adds the point it missed, in the order the offer drives', async () => {
|
||||
// The first answer passes the pin and still takes the old road for its first half.
|
||||
const route = vi.fn<Route>()
|
||||
.mockResolvedValueOnce(answer([[53.55, 9.99], [53.0, 11.5], [53.3, 12.8], [52.52, 13.4]]))
|
||||
.mockResolvedValueOnce(answer(DETOUR))
|
||||
|
||||
const proof = await pinAlternative({ offer: offer(), current, route, signal: signal() })
|
||||
|
||||
expect(route).toHaveBeenCalledTimes(2)
|
||||
// Driven through backwards the leg would double back between them.
|
||||
expect(route.mock.calls[1][0]).toEqual([{ lat: 53.4, lng: 11.5 }, { lat: 53.3, lng: 12.8 }])
|
||||
expect(proof.pins).toEqual([{ lat: 53.4, lng: 11.5 }, { lat: 53.3, lng: 12.8 }])
|
||||
expect(proof.held).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-ALTPIN-004: no more than three pins are tried before the offer counts as not held', async () => {
|
||||
// A long detour the router leaves somewhere new on every answer.
|
||||
const long: [number, number][] = [[50, 0], [50.5, 1], [51, 2], [51.5, 3], [52, 4], [52.5, 5], [52, 6]]
|
||||
const answers: [number, number][][] = [
|
||||
[[50, 0], [50, 1], [51, 2], [51.5, 3], [52, 4], [52.5, 5], [52, 6]],
|
||||
[[50, 0], [50.5, 1], [51, 2], [51, 3], [52, 4], [52.5, 5], [52, 6]],
|
||||
[[50, 0], [50.5, 1], [51, 2], [51.5, 3], [52, 4], [52, 5], [52, 6]],
|
||||
]
|
||||
const route = vi.fn<Route>()
|
||||
for (const line of answers) route.mockResolvedValueOnce(answer(line))
|
||||
|
||||
const proof = await pinAlternative({ offer: offer({ coordinates: long }), current: { ...current, coordinates: [[50, 0], [52, 6]] }, route, signal: signal() })
|
||||
|
||||
expect(MAX_PINS).toBe(3)
|
||||
expect(route).toHaveBeenCalledTimes(3)
|
||||
expect(route.mock.calls[2][0]).toHaveLength(3)
|
||||
expect(proof).toMatchObject({ held: false, pins: [], fellBack: false })
|
||||
expect(proof.last.coordinates).toEqual(answers[2])
|
||||
})
|
||||
|
||||
it('FE-ALTPIN-005: an answer that misses the same point again ends the check rather than repeating it', async () => {
|
||||
// A point OSRM pulls off a ferry to the pier: pinned again, the same road comes back.
|
||||
const route = vi.fn<Route>(async () => answer(RAIL))
|
||||
|
||||
const proof = await pinAlternative({ offer: offer(), current, route, signal: signal() })
|
||||
|
||||
expect(route).toHaveBeenCalledTimes(1)
|
||||
expect(proof).toMatchObject({ held: false, fellBack: false })
|
||||
expect(proof.last.coordinates).toEqual(RAIL)
|
||||
})
|
||||
|
||||
it('FE-ALTPIN-006: an answer from the stand-in engine gives no verdict on the road', async () => {
|
||||
// It would prove the offer to an engine that will not be the one drawing the leg.
|
||||
const route = vi.fn<Route>(async () => answer(DETOUR, { fellBack: true }))
|
||||
|
||||
const proof = await pinAlternative({ offer: offer(), current, route, signal: signal() })
|
||||
|
||||
expect(proof).toMatchObject({ held: false, fellBack: true, pins: [] })
|
||||
})
|
||||
|
||||
it('FE-ALTPIN-007: with no road to measure against, the check starts unpinned', async () => {
|
||||
const route = vi.fn<Route>(async () => answer(DETOUR))
|
||||
|
||||
await pinAlternative({ offer: offer(), current: undefined, route, signal: signal() })
|
||||
|
||||
expect(route.mock.calls[0][0]).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-ALTPIN-008: an abandoned check asks the router nothing more', async () => {
|
||||
const controller = new AbortController()
|
||||
const route = vi.fn<Route>(async () => {
|
||||
controller.abort()
|
||||
return answer([[53.55, 9.99], [53.0, 11.5], [53.3, 12.8], [52.52, 13.4]])
|
||||
})
|
||||
|
||||
await expect(pinAlternative({ offer: offer(), current, route, signal: controller.signal }))
|
||||
.rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(route).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('railLegAt', () => {
|
||||
const stop = (assignmentId: number, ownerDayId: number, ownerIndex: number, over: Partial<RoadtripStop> = {}) =>
|
||||
({ assignmentId, ownerDayId, ownerIndex, lat: 50, lng: 10, ...over }) as RoadtripStop
|
||||
const day = (dayId: number, stops: RoadtripStop[]) => ({ dayId, stops }) as RoadtripDay
|
||||
|
||||
it('FE-ALTPIN-009: finds the leg leaving the stop filed at the anchor, inside a card or across to the next', () => {
|
||||
const days = [
|
||||
day(1, [stop(10, 1, 0), stop(11, 1, 1)]),
|
||||
// A card that opens on a stop stored the day before, and a night marker standing on
|
||||
// the same position as a real stop.
|
||||
day(2, [stop(12, 1, 2), stop(12, 1, 2, { automaticNight: { phase: 'start' } as RoadtripStop['automaticNight'] }), stop(20, 2, 0)]),
|
||||
]
|
||||
|
||||
expect(railLegAt(days, { dayId: 1, afterIndex: 0 })).toMatchObject({ from: { assignmentId: 10 }, to: { assignmentId: 11 } })
|
||||
expect(railLegAt(days, { dayId: 1, afterIndex: 1 })).toMatchObject({ from: { assignmentId: 11 }, to: { assignmentId: 12 } })
|
||||
expect(railLegAt(days, { dayId: 1, afterIndex: 2 })).toMatchObject({ from: { assignmentId: 12 }, to: { assignmentId: 20 } })
|
||||
})
|
||||
|
||||
it('FE-ALTPIN-010: a terminal borrowing the index is no answer, and an anchor with nothing after it is none', () => {
|
||||
const terminal = stop(-99, 1, 0, { carrier: { role: 'departure' } as RoadtripStop['carrier'] })
|
||||
const days = [day(1, [terminal, stop(10, 1, 0), stop(11, 1, 1)])]
|
||||
|
||||
expect(railLegAt(days, { dayId: 1, afterIndex: 0 })).toMatchObject({ from: { assignmentId: 10 }, to: { assignmentId: 11 } })
|
||||
expect(railLegAt(days, { dayId: 1, afterIndex: 1 })).toBeNull()
|
||||
expect(railLegAt(days, { dayId: 3, afterIndex: 0 })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { furthestFrom, sameRoad, type RoadLine } from '../Map/RouteCalculator'
|
||||
import type { RailLegRoute, RailLegRouter, RoadtripDay, RoadtripStop } from './useRoadtripRoutes'
|
||||
import type { OfferedRoute, ViaAnchor } from './useRouteAlternatives'
|
||||
|
||||
/**
|
||||
* The most points one choice may pin to the road.
|
||||
*
|
||||
* Every pin is one more request to a host that allows one a second, and one more handle on
|
||||
* the map somebody has to understand later. A way that three points cannot hold is not
|
||||
* held by the router at all: a ferry OSRM will not board, or a class the day is not
|
||||
* weighed against.
|
||||
*/
|
||||
export const MAX_PINS = 3
|
||||
|
||||
/** A point a choice is held by, in the order the chosen road drives through them. */
|
||||
export interface Pin {
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
|
||||
/** Whether the rail's router drives an offer, and the pins that make it do so. */
|
||||
export interface PinProof {
|
||||
/** True when the router drove the offer with `pins` in place. */
|
||||
held: boolean
|
||||
/** The pins that held it, in the order the offer drives through them. Empty otherwise. */
|
||||
pins: Pin[]
|
||||
/** True when the last answer came from a stand-in engine, which proves nothing either way. */
|
||||
fellBack: boolean
|
||||
/** The router's answer to the last attempt. */
|
||||
last: RailLegRoute
|
||||
}
|
||||
|
||||
/**
|
||||
* The fewest points that make the rail's own router drive `offer`, found by asking it.
|
||||
*
|
||||
* A choice used to be stored as one via at the point where the offer strayed furthest,
|
||||
* and nobody asked whether the router then drove the offer. Often it did not. A point on
|
||||
* a ferry is pulled to the nearest pier by OSRM, which never boards one, so Amsterdam to
|
||||
* Newcastle "without the motorway" came back as a thousand kilometres through Calais. An
|
||||
* offer weighed away from motorways by the second engine is held by one point for a few
|
||||
* kilometres, and the motorway takes the rest. Either way the choice looked saved and the
|
||||
* drive did something else.
|
||||
*
|
||||
* So the check is the router itself. The router's own preference starts with no pin; any
|
||||
* other offer with the point furthest from the road being driven. After each answer that
|
||||
* is not the offer, the point of the offer furthest from that answer joins in, placed in
|
||||
* the order the offer drives through them, until the answer is the offer or `maxPins` is
|
||||
* reached. The spacing between the requests is the router's own (`RailLegRouter.route`).
|
||||
*
|
||||
* An answer from a stand-in engine proves nothing about the one that will draw the leg,
|
||||
* so it ends the check without a verdict on the road.
|
||||
*/
|
||||
export async function pinAlternative({
|
||||
offer,
|
||||
current,
|
||||
route,
|
||||
signal,
|
||||
maxPins = MAX_PINS,
|
||||
}: {
|
||||
offer: OfferedRoute
|
||||
/** The road the rail drives now; the first pin is where the offer leaves it furthest. */
|
||||
current: RoadLine | undefined
|
||||
route: RailLegRouter['route']
|
||||
signal: AbortSignal
|
||||
maxPins?: number
|
||||
}): Promise<PinProof> {
|
||||
const placed: { index: number; lat: number; lng: number }[] = []
|
||||
if (!offer.direct) {
|
||||
const far = furthestFrom(offer.coordinates, current?.coordinates ?? [])
|
||||
if (far) placed.push(far)
|
||||
}
|
||||
for (;;) {
|
||||
if (signal.aborted) throw new DOMException('The check was abandoned', 'AbortError')
|
||||
const last = await route(placed.map(({ lat, lng }) => ({ lat, lng })), signal)
|
||||
if (last.fellBack) return { held: false, pins: [], fellBack: true, last }
|
||||
if (sameRoad(last, offer)) return { held: true, pins: placed.map(({ lat, lng }) => ({ lat, lng })), fellBack: false, last }
|
||||
if (placed.length >= maxPins) return { held: false, pins: [], fellBack: false, last }
|
||||
const far = furthestFrom(offer.coordinates, last.coordinates)
|
||||
// The same point again would ask the same question and earn the same answer.
|
||||
if (!far || placed.some(p => p.index === far.index)) return { held: false, pins: [], fellBack: false, last }
|
||||
placed.push(far)
|
||||
placed.sort((a, b) => a.index - b.index)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function railLegAt(days: readonly RoadtripDay[], 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
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { renderHook, waitFor, act } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import type { Assignment, AssignmentsMap, Day, Settings } from '../../types'
|
||||
import type { RoadtripStop } from './useRoadtripRoutes'
|
||||
|
||||
// Hoisted together with the mock: the module factory runs before the file body, so a
|
||||
// class declared down there would not exist yet when the hook does its `instanceof`.
|
||||
@@ -14,7 +15,13 @@ const { calculateRouteWithLegs, RoutingRefusedError } = vi.hoisted(() => {
|
||||
}
|
||||
return { calculateRouteWithLegs: vi.fn(), RoutingRefusedError }
|
||||
})
|
||||
vi.mock('../Map/RouteCalculator', () => ({ calculateRouteWithLegs, RoutingRefusedError }))
|
||||
// The rest of the module stays real: which engine a leg belongs to is decided there, and
|
||||
// the leg router has to agree with it rather than with a copy.
|
||||
vi.mock('../Map/RouteCalculator', async importOriginal => ({
|
||||
...(await importOriginal<typeof import('../Map/RouteCalculator')>()),
|
||||
calculateRouteWithLegs,
|
||||
RoutingRefusedError,
|
||||
}))
|
||||
|
||||
import { useRoadtripRoutes } from './useRoadtripRoutes'
|
||||
import { DEFAULT_SETTINGS, useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -1011,3 +1018,70 @@ describe('a booking the traveller rides (#2428)', () => {
|
||||
expect(result.current.lines).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* One leg asked for again, the way the rail asks for it.
|
||||
*
|
||||
* The picker of other ways and the check behind a choice both route a single leg on
|
||||
* demand. Asked by anything but the rail's own rules, the offers described a drive the rail
|
||||
* was not on: OSRM with nothing avoided beside a day Valhalla had weighed, under the trip's
|
||||
* profile rather than the leg's own mode.
|
||||
*/
|
||||
describe('a leg asked for again on demand', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS } })
|
||||
})
|
||||
afterEach(() => act(() => useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS } })))
|
||||
|
||||
const stopOn = (ownerDayId: number, ownerIndex: number, at: [number, number], over: Partial<RoadtripStop> = {}) =>
|
||||
({ assignmentId: ownerDayId * 10 + ownerIndex, ownerDayId, ownerIndex, lat: at[0], lng: at[1], legMode: null, incomingLegMode: null, ...over }) as RoadtripStop
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-046: a leg inside one day is asked with that day\'s mode, the trip\'s classes and its own day', async () => {
|
||||
act(() => { useSettingsStore.setState(s => ({ settings: { ...s.settings, roadtrip_avoid: 'toll' } })) })
|
||||
const days = [day(1, 1, { default_transport_mode: 'driving' })]
|
||||
// The trip-wide profile says walking; the day's own default is what the run used.
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]), 'walking'))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(calculateRouteWithLegs.mock.calls[0][1]).toMatchObject({ profile: 'driving', avoid: ['toll'], dayId: 1 })
|
||||
|
||||
const [from, to] = result.current.days[0].stops
|
||||
const router = result.current.legRouter!(from, to, 1)
|
||||
expect(router).toMatchObject({ mode: 'driving', avoid: ['toll'], engine: 'valhalla' })
|
||||
|
||||
calculateRouteWithLegs.mockClear()
|
||||
calculateRouteWithLegs.mockResolvedValue({ ...routed(2), hasFerry: true })
|
||||
const answer = await router.route([{ lat: 53, lng: 11 }], new AbortController().signal)
|
||||
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledWith(
|
||||
[{ lat: HAMBURG[0], lng: HAMBURG[1] }, { lat: 53, lng: 11 }, { lat: BERLIN[0], lng: BERLIN[1] }],
|
||||
expect.objectContaining({ profile: 'driving', tripId: 7, dayId: 1, avoid: ['toll'] }),
|
||||
)
|
||||
expect(answer).toMatchObject({ coordinates: [HAMBURG, BERLIN], distance: 100000, duration: 3600, hasFerry: true, fellBack: false })
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-047: a seam is asked under the card it arrives on, and a leg nothing weighs is OSRM\'s', () => {
|
||||
act(() => { useSettingsStore.setState(s => ({ settings: { ...s.settings, roadtrip_avoid: 'motorway' } })) })
|
||||
const days = [day(1, 1, { default_transport_mode: 'cycling' }), day(2, 2, { default_transport_mode: 'driving' })]
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, days, {} as AssignmentsMap))
|
||||
|
||||
const lastOfDay1 = stopOn(1, 3, LUENEBURG)
|
||||
const firstOfDay2 = stopOn(2, 0, BERLIN)
|
||||
// Across the seam: day 2's default, so the car and the classes it avoids.
|
||||
expect(result.current.legRouter!(lastOfDay1, firstOfDay2, 2)).toMatchObject({ mode: 'driving', avoid: ['motorway'], engine: 'valhalla' })
|
||||
// Inside day 1: day 1's bicycle, which nothing is weighed against.
|
||||
expect(result.current.legRouter!(stopOn(1, 2, HAMBURG), lastOfDay1, 2)).toMatchObject({ mode: 'cycling', avoid: [], engine: 'osrm' })
|
||||
// A stop's own mode wins over any default, and a plugin prices its own leg.
|
||||
expect(result.current.legRouter!(stopOn(2, 0, BERLIN, { legMode: 'plugin:ev/fast' }), stopOn(2, 1, HAMBURG), 2))
|
||||
.toMatchObject({ mode: 'plugin:ev/fast', avoid: [], engine: 'plugin' })
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-048: a day the stand-in engine drove while avoiding says the avoidance did not happen', async () => {
|
||||
act(() => { useSettingsStore.setState(s => ({ settings: { ...s.settings, roadtrip_avoid: 'toll,ferry' } })) })
|
||||
calculateRouteWithLegs.mockResolvedValue({ ...routed(1), avoidance: { asked: ['ferry', 'toll'], achieved: [], fellBack: true } })
|
||||
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, [day(1, 1)], map(1, [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }])))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
expect(result.current.days[0].avoidMissed).toEqual(['ferry', 'toll'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
|
||||
import { assembleRoadtrip, carrierLegsFor, carrierSeam, foldRouteRun, isCarrierMode, seatCarrierStops, standsAsDay, terminalAssignmentId, viasLeaving, type CarrierSeam, type RoadtripStop, type RoadtripRoutes, type PlanDay, type QuietDay, type RoutedLeg } from '@trek/shared/roadtrip'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalculator'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { avoidedClasses, calculateRouteWithLegs, routeEngineFor, RoutingRefusedError, type RouteEngine } from '../Map/RouteCalculator'
|
||||
import { resolveLegMode } from '../Planner/legMode'
|
||||
import { splitIntoRuns, parseAvoid, type DriveLimits } from './roadtripModel'
|
||||
import { spillChains } from './nightSpill'
|
||||
@@ -70,6 +70,53 @@ const legKey = (from: RoadtripStop, to: RoadtripStop): string => `${stopKey(from
|
||||
const EMPTY_ACCOMMODATIONS: Accommodation[] = []
|
||||
const EMPTY_RESERVATIONS: Reservation[] = []
|
||||
|
||||
/** One leg as the rail's own router answered it, for a surface checking a choice against it. */
|
||||
export interface RailLegRoute {
|
||||
coordinates: [number, number][]
|
||||
distance: number
|
||||
duration: number
|
||||
/** Whether the road crosses by ferry, where the engine says so. */
|
||||
hasFerry?: boolean
|
||||
/** True when the leg's own engine could not answer and OSRM drove it unweighted. */
|
||||
fellBack?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* How the rail drives one of its legs, and the same question put again with points pinned
|
||||
* in between.
|
||||
*
|
||||
* The one place that answers "route this leg the way the rail does". The picker of other
|
||||
* ways asked OSRM with nothing avoided while the rail drove the leg through Valhalla with
|
||||
* the trip's avoidances, so the list offered roads the rail was not on and a choice could
|
||||
* not be told apart from a click that did nothing. Whatever offers a leg another way, or
|
||||
* checks that a choice holds, asks here.
|
||||
*/
|
||||
export interface RailLegRouter {
|
||||
/** The leg's mode, resolved the way the routing round resolved it. */
|
||||
mode: string
|
||||
/** The classes the rail weighs away on this leg: none unless the leg is driven by Valhalla. */
|
||||
avoid: RouteAvoidClass[]
|
||||
/** Which engine prices this leg on the rail. */
|
||||
engine: RouteEngine
|
||||
/** The leg from its first stop through `pins` to its last, asked exactly as the rail asks it. */
|
||||
route: (pins: { lat: number; lng: number }[], signal: AbortSignal) => Promise<RailLegRoute>
|
||||
}
|
||||
|
||||
/**
|
||||
* What the rail reads, plus what only a browser can do with it: route a leg again.
|
||||
*
|
||||
* Optional like `validateBoundaries`, because the same shape is assembled on the server,
|
||||
* where no router of the browser's exists; this hook always fills it in.
|
||||
*/
|
||||
export type RoadtripRoutesView = RoadtripRoutes & {
|
||||
/**
|
||||
* The router for the leg between two stops, `cardDayId` being the card they are drawn
|
||||
* on. Two stops stored on one day were asked for in that day's run; two stored on
|
||||
* different days are a seam, asked for under the card it arrives on.
|
||||
*/
|
||||
legRouter?: (from: RoadtripStop, to: RoadtripStop, cardDayId: number) => RailLegRouter
|
||||
}
|
||||
|
||||
const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number, accommodations: Accommodation[]): RoadtripStop | null => {
|
||||
const p = a.place
|
||||
if (!p || typeof p.lat !== 'number' || typeof p.lng !== 'number') return null
|
||||
@@ -133,7 +180,7 @@ export function useRoadtripRoutes(
|
||||
* between the two is the ride, never a road.
|
||||
*/
|
||||
reservations: Reservation[] = EMPTY_RESERVATIONS,
|
||||
): RoadtripRoutes {
|
||||
): RoadtripRoutesView {
|
||||
const { t } = useTranslation()
|
||||
const routeProfile = fallbackProfile || 'driving'
|
||||
// Leg text is pre-formatted in the chosen unit, so a km↔mi switch has to re-fetch.
|
||||
@@ -273,6 +320,17 @@ export function useRoadtripRoutes(
|
||||
return out
|
||||
}, [storedDays, rideSeams])
|
||||
|
||||
/**
|
||||
* A leg's mode, resolved the one way every request here resolves it: the stop's own,
|
||||
* else the default of `dayId`, else the trip's. The day runs pass their own day, a seam
|
||||
* the card it arrives on, and a leg asked for on demand whichever of the two it is.
|
||||
*/
|
||||
const legModeOf = useCallback((from: RoadtripStop, to: RoadtripStop, dayId: number): string => resolveLegMode(
|
||||
{ isPlace: true, leg_transport_mode: from.legMode },
|
||||
{ isPlace: true, incoming_leg_transport_mode: to.incomingLegMode },
|
||||
days.find(d => d.id === dayId)?.default_transport_mode || routeProfile,
|
||||
), [days, routeProfile])
|
||||
|
||||
// Only the geometry decides whether legs have to be re-fetched: renaming a place or
|
||||
// editing its notes must not fire a routing round.
|
||||
const viaKey = useMemo(
|
||||
@@ -303,9 +361,6 @@ export function useRoadtripRoutes(
|
||||
abortRef.current = controller
|
||||
setLoading(true)
|
||||
|
||||
const dayDefault = (dayId: number): string =>
|
||||
days.find(d => d.id === dayId)?.default_transport_mode || routeProfile
|
||||
|
||||
const collected: Record<number, Record<string, RoutedLeg>> = {}
|
||||
const collectedSnaps: Record<number, Record<string, SnappedWaypoint>> = {}
|
||||
const collectedMisses: Record<number, RouteAvoidClass[]> = {}
|
||||
@@ -384,14 +439,14 @@ export function useRoadtripRoutes(
|
||||
if (s) daySnaps[stopKey(stop)] = s
|
||||
})
|
||||
}
|
||||
// What was asked for against what the road turned out to be. Only
|
||||
// the second engine reports it, and only when it answered: an OSRM
|
||||
// fallback leaves `avoidance` absent, which is the honest reading
|
||||
// of "the weighting never happened". Collected here because the
|
||||
// rail plans in the browser and never sees the server's own copy
|
||||
// of this field — without it the "not honoured" badge could not
|
||||
// appear at all, and a motorway-free drive that is not one read as
|
||||
// if the setting had held.
|
||||
// What was asked for against what the road turned out to be. Only a
|
||||
// request that was asked to avoid something reports it: the second
|
||||
// engine says what it managed, and an OSRM fallback after it failed
|
||||
// says it managed nothing, which is the honest reading of "the
|
||||
// weighting never happened". Collected here because the rail plans
|
||||
// in the browser and never sees the server's own copy of this field.
|
||||
// Without it the "not honoured" badge could not appear at all, and a
|
||||
// motorway-free drive that is not one read as if the setting had held.
|
||||
if (r.avoidance) {
|
||||
const missed = r.avoidance.asked.filter(cls => !r.avoidance!.achieved.includes(cls))
|
||||
if (missed.length) {
|
||||
@@ -427,14 +482,8 @@ export function useRoadtripRoutes(
|
||||
for (const day of plan) {
|
||||
const dayLegs: Record<string, RoutedLeg> = {}
|
||||
collected[day.dayId] = dayLegs
|
||||
const dfMode = dayDefault(day.dayId)
|
||||
|
||||
const runs = splitIntoRuns(day.stops, (from, to) =>
|
||||
resolveLegMode(
|
||||
{ isPlace: true, leg_transport_mode: from.legMode },
|
||||
{ isPlace: true, incoming_leg_transport_mode: to.incomingLegMode },
|
||||
dfMode,
|
||||
))
|
||||
const runs = splitIntoRuns(day.stops, (from, to) => legModeOf(from, to, day.dayId))
|
||||
|
||||
for (const { stops: run, mode } of runs) enqueueRun(day, dayLegs, run, mode)
|
||||
}
|
||||
@@ -548,8 +597,18 @@ 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(';')
|
||||
/** When the last seam request went out, across every run of the effect below. */
|
||||
const lastSeamRequestAt = useRef(0)
|
||||
/**
|
||||
* 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
|
||||
* for both, so a choice being checked cannot fire into the gap a seam is keeping.
|
||||
*/
|
||||
const lastSpacedRequestAt = useRef(0)
|
||||
/** Waits until the gap since the last such request is kept, then claims the next slot. */
|
||||
const spacedRequest = useCallback(async (signal: AbortSignal): Promise<void> => {
|
||||
const since = performance.now() - lastSpacedRequestAt.current
|
||||
if (since < REQUEST_SPACING_MS) await sleep(REQUEST_SPACING_MS - since, signal)
|
||||
if (!signal.aborted) lastSpacedRequestAt.current = performance.now()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!seams.length) return
|
||||
@@ -557,11 +616,7 @@ export function useRoadtripRoutes(
|
||||
void (async () => {
|
||||
for (const seam of seams) {
|
||||
if (controller.signal.aborted) return
|
||||
const mode = resolveLegMode(
|
||||
{ isPlace: true, leg_transport_mode: seam.from.legMode },
|
||||
{ isPlace: true, incoming_leg_transport_mode: seam.to.incomingLegMode },
|
||||
days.find(d => d.id === seam.dayId)?.default_transport_mode || routeProfile,
|
||||
)
|
||||
const mode = legModeOf(seam.from, seam.to, seam.dayId)
|
||||
// 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,
|
||||
@@ -583,10 +638,8 @@ export function useRoadtripRoutes(
|
||||
// shared routing hosts answered the tail with 429, and a refused seam
|
||||
// writes no state, so nothing changed to make the effect try again: the
|
||||
// map drew the trip in pieces and the totals came back short.
|
||||
const since = performance.now() - lastSeamRequestAt.current
|
||||
if (since < REQUEST_SPACING_MS) await sleep(REQUEST_SPACING_MS - since, controller.signal)
|
||||
await spacedRequest(controller.signal)
|
||||
if (controller.signal.aborted) return
|
||||
lastSeamRequestAt.current = performance.now()
|
||||
try {
|
||||
// The via points on this seam, threaded in the same way the day runs thread
|
||||
// theirs. Without them a seam is the one stretch of the trip a via cannot
|
||||
@@ -634,7 +687,35 @@ export function useRoadtripRoutes(
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [seamKey, routeProfile, tripId, avoidKey])
|
||||
|
||||
return useMemo(() => assembleRoadtrip({ plan, quietDays, window, distanceUnit, allLegs, snapByDay, missedByDay, loading, limits, vehicleKind, connectDays, boundaries,
|
||||
const legRouter = useCallback((from: RoadtripStop, to: RoadtripStop, cardDayId: number): RailLegRouter => {
|
||||
// The day the routing round asked this pair under: its own stored day for a pair
|
||||
// inside one, the card it is drawn on for a seam. It decides the default mode and
|
||||
// is what a route provider plugin is told.
|
||||
const dayId = from.ownerDayId === to.ownerDayId ? from.ownerDayId : cardDayId
|
||||
const mode = legModeOf(from, to, dayId)
|
||||
return {
|
||||
mode,
|
||||
avoid: avoidedClasses(mode, avoid),
|
||||
engine: routeEngineFor(mode, avoid),
|
||||
route: async (pins, signal) => {
|
||||
await spacedRequest(signal)
|
||||
const r = await calculateRouteWithLegs(
|
||||
[{ lat: from.lat, lng: from.lng }, ...pins.map(p => ({ lat: p.lat, lng: p.lng })), { lat: to.lat, lng: to.lng }],
|
||||
{ signal, profile: mode, tripId: tripId ?? null, dayId, avoid },
|
||||
)
|
||||
return {
|
||||
coordinates: r.coordinates,
|
||||
distance: r.distance,
|
||||
duration: r.duration,
|
||||
hasFerry: r.hasFerry,
|
||||
fellBack: r.avoidance?.fellBack === true,
|
||||
}
|
||||
},
|
||||
}
|
||||
}, [legModeOf, avoid, spacedRequest, tripId])
|
||||
|
||||
const assembled = useMemo(() => assembleRoadtrip({ plan, quietDays, window, distanceUnit, allLegs, snapByDay, missedByDay, loading, limits, vehicleKind, connectDays, boundaries,
|
||||
labels: { start: t('roadtrip.window.resume'), end: t('roadtrip.window.stop') },
|
||||
}), [plan, quietDays, window, distanceUnit, t, allLegs, snapByDay, missedByDay, loading, limits, vehicleKind, connectDays, boundaries])
|
||||
return useMemo(() => ({ ...assembled, legRouter }), [assembled, legRouter])
|
||||
}
|
||||
|
||||
@@ -1,42 +1,58 @@
|
||||
import { renderHook, waitFor, act } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const { calculateAlternatives, calculateRoute } = vi.hoisted(() => ({
|
||||
calculateAlternatives: vi.fn(),
|
||||
calculateRoute: vi.fn(),
|
||||
const { calculateAlternatives } = vi.hoisted(() => ({ calculateAlternatives: vi.fn() }))
|
||||
// Only the router call is replaced. `sameRoad` stays the real one, because whether an offer
|
||||
// is the road already driven is exactly what these cases are about.
|
||||
vi.mock('../Map/RouteCalculator', async importOriginal => ({
|
||||
...(await importOriginal<typeof import('../Map/RouteCalculator')>()),
|
||||
calculateAlternatives,
|
||||
}))
|
||||
vi.mock('../Map/RouteCalculator', () => ({ calculateAlternatives, calculateRoute }))
|
||||
|
||||
import { useRouteAlternatives } from './useRouteAlternatives'
|
||||
import type { RoadtripStop } from './useRoadtripRoutes'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
import { useRouteAlternatives, type AlternativesRequest } from './useRouteAlternatives'
|
||||
import type { RailLegRouter } from './useRoadtripRoutes'
|
||||
|
||||
/**
|
||||
* FE-ALTHOOK-001..010: asking for other ways of driving one leg.
|
||||
* FE-ALTHOOK-001..012: asking for other ways of driving one leg.
|
||||
*
|
||||
* The case that carries the feature: when the leg already carries vias, the road
|
||||
* actually being driven is NOT among what the router offers for the two bare
|
||||
* endpoints, because it was asked a different question. Without fetching it
|
||||
* separately, opening the picker on a leg you have already reshaped shows three
|
||||
* roads, none of which is the one you are on, and no way back to it.
|
||||
* 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.
|
||||
* Asked for separately from OSRM with nothing avoided, the list had no current road at all
|
||||
* on a trip that avoids something, called a road the rail was not on the fastest, and
|
||||
* choosing that one did nothing.
|
||||
*/
|
||||
|
||||
const from = { lat: 53.55, lng: 9.99 } as RoadtripStop
|
||||
const to = { lat: 52.52, lng: 13.4 } as RoadtripStop
|
||||
const via = (lat: number, lng: number): RoadtripVia =>
|
||||
({ id: 1, day_id: 4, after_order_index: 0, sequence: 0, lat, lng })
|
||||
/** The road the rail drives from Hamburg to Berlin, and two others. */
|
||||
const RAIL: [number, number][] = [[53.55, 9.99], [53.0, 11.5], [52.52, 13.4]]
|
||||
const NORTH: [number, number][] = [[53.55, 9.99], [53.6, 11.5], [52.52, 13.4]]
|
||||
const SOUTH: [number, number][] = [[53.55, 9.99], [52.4, 11.5], [52.52, 13.4]]
|
||||
|
||||
const route = (over: Record<string, unknown> = {}) => ({
|
||||
coordinates: [[53.55, 9.99], [52.52, 13.4]],
|
||||
distance: 290_000,
|
||||
duration: 10_800,
|
||||
divergence: { lat: 53, lng: 11 },
|
||||
const offer = (coordinates: [number, number][], over: Record<string, unknown> = {}) => ({
|
||||
coordinates,
|
||||
distance: 300_000,
|
||||
duration: 11_000,
|
||||
divergence: null,
|
||||
...over,
|
||||
})
|
||||
|
||||
const route = vi.fn()
|
||||
|
||||
function request(router: Partial<RailLegRouter> = {}, over: Partial<AlternativesRequest> = {}): AlternativesRequest {
|
||||
return {
|
||||
dayId: 4,
|
||||
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 },
|
||||
anchor: { dayId: 3, afterIndex: 2 },
|
||||
ends: { from: 11, to: 12 },
|
||||
router: { mode: 'driving', avoid: [], engine: 'osrm', route, ...router },
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
calculateAlternatives.mockReset().mockResolvedValue([route(), route({ distance: 310_000, duration: 11_400 })])
|
||||
calculateRoute.mockReset().mockResolvedValue(route({ distance: 340_000, duration: 12_600 }))
|
||||
calculateAlternatives.mockReset().mockResolvedValue([offer(NORTH), offer(SOUTH, { distance: 310_000, duration: 11_400 })])
|
||||
})
|
||||
|
||||
describe('useRouteAlternatives', () => {
|
||||
@@ -46,71 +62,91 @@ describe('useRouteAlternatives', () => {
|
||||
expect(calculateAlternatives).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ALTHOOK-002: asking opens on a loading state, then fills it', async () => {
|
||||
it('FE-ALTHOOK-002: asking opens on a loading state that already knows where a choice goes, then fills it', async () => {
|
||||
const { result } = renderHook(() => useRouteAlternatives())
|
||||
|
||||
act(() => { result.current.ask(4, 1, from, to, 'driving', []) })
|
||||
expect(result.current.open).toMatchObject({ dayId: 4, index: 1, loading: true, routes: [] })
|
||||
act(() => { result.current.ask(request()) })
|
||||
expect(result.current.open).toMatchObject({
|
||||
dayId: 4, index: 1, loading: true, routes: [], proving: null, engine: 'osrm',
|
||||
anchor: { dayId: 3, afterIndex: 2 },
|
||||
ends: { from: 11, to: 12 },
|
||||
})
|
||||
expect(result.current.open?.route).toBe(route)
|
||||
|
||||
await waitFor(() => expect(result.current.open?.loading).toBe(false))
|
||||
expect(result.current.open?.routes).toHaveLength(2)
|
||||
// The router's own first answer is the direct one, which is what "no detour
|
||||
// at all" means when it is chosen.
|
||||
expect(result.current.open?.routes[0].direct).toBe(true)
|
||||
expect(result.current.open?.routes[1].direct).toBe(false)
|
||||
const routes = result.current.open!.routes
|
||||
expect(routes).toHaveLength(3)
|
||||
// The rail's road heads the list; the router's own first answer is the direct one.
|
||||
expect(routes[0]).toMatchObject({ current: true, coordinates: RAIL, distance: 290_000 })
|
||||
expect(routes[1]).toMatchObject({ direct: true, coordinates: NORTH })
|
||||
expect(routes[2]).toMatchObject({ direct: false, coordinates: SOUTH })
|
||||
})
|
||||
|
||||
it('FE-ALTHOOK-003: a bare leg is not asked for the road it is already driving', () => {
|
||||
it('FE-ALTHOOK-003: the offers are asked with the mode and the classes the rail drives the leg with', () => {
|
||||
const { result } = renderHook(() => useRouteAlternatives())
|
||||
act(() => { result.current.ask(4, 1, from, to, 'driving', []) })
|
||||
expect(calculateRoute).not.toHaveBeenCalled()
|
||||
|
||||
act(() => { result.current.ask(request({ avoid: ['toll'], engine: 'valhalla' })) })
|
||||
|
||||
expect(calculateAlternatives).toHaveBeenCalledTimes(1)
|
||||
const [from, to, profile, options] = calculateAlternatives.mock.calls[0] as [unknown, unknown, string, { avoid: string[]; signal: AbortSignal }]
|
||||
expect([from, to, profile]).toEqual([{ lat: 53.55, lng: 9.99 }, { lat: 52.52, lng: 13.4 }, 'driving'])
|
||||
expect(options.avoid).toEqual(['toll'])
|
||||
expect(options.signal).toBeInstanceOf(AbortSignal)
|
||||
})
|
||||
|
||||
it('FE-ALTHOOK-004: a bent leg puts the road being driven at the top', async () => {
|
||||
it('FE-ALTHOOK-004: Current is the rail\'s own leg, priced by the rail\'s engine, and never asked for again', async () => {
|
||||
// A trip that avoids a class is driven by the second engine; its current road carries
|
||||
// that engine, so the overlays read every offer against it and not against OSRM.
|
||||
const { result } = renderHook(() => useRouteAlternatives())
|
||||
|
||||
act(() => { result.current.ask(4, 1, from, to, 'driving', [via(53, 11.5)]) })
|
||||
act(() => { result.current.ask(request({ avoid: ['ferry'], engine: 'valhalla' })) })
|
||||
await waitFor(() => expect(result.current.open?.loading).toBe(false))
|
||||
|
||||
expect(result.current.open?.routes[0]).toMatchObject({ current: true, distance: 340_000 })
|
||||
expect(result.current.open?.routes).toHaveLength(3)
|
||||
// Routed through the via, not between the bare ends.
|
||||
expect(calculateRoute).toHaveBeenCalledWith(
|
||||
[{ lat: 53.55, lng: 9.99 }, { lat: 53, lng: 11.5 }, { lat: 52.52, lng: 13.4 }],
|
||||
'driving',
|
||||
expect.anything(),
|
||||
)
|
||||
expect(result.current.open?.engine).toBe('valhalla')
|
||||
expect(result.current.open?.routes[0]).toMatchObject({ current: true, engine: 'valhalla', duration: 10_800 })
|
||||
// One question for the offers, none for the road already on the rail.
|
||||
expect(calculateAlternatives).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-ALTHOOK-005: a via that barely moves the route is one road, not two', async () => {
|
||||
// Two lines are the same road if their ends and their length agree closely
|
||||
// enough. Listing it twice offers a choice that is not one.
|
||||
calculateRoute.mockResolvedValue(route({ distance: 290_020, duration: 10_810 }))
|
||||
it('FE-ALTHOOK-005: an offer that is the road already driven is listed once, as Current, and says it is the router\'s own', async () => {
|
||||
// The same road from the router, a few metres off the rail's line and priced a little
|
||||
// differently. Two entries for it offered a choice that was not one; left unmarked,
|
||||
// the road the rail is on was drawn pale and another took the blue.
|
||||
calculateAlternatives.mockResolvedValue([
|
||||
offer([[53.5501, 9.99], [53.0001, 11.5], [52.5201, 13.4]], { distance: 291_000, duration: 10_500 }),
|
||||
offer(NORTH),
|
||||
])
|
||||
const { result } = renderHook(() => useRouteAlternatives())
|
||||
|
||||
act(() => { result.current.ask(4, 1, from, to, 'driving', [via(53, 11)]) })
|
||||
act(() => { result.current.ask(request()) })
|
||||
await waitFor(() => expect(result.current.open?.loading).toBe(false))
|
||||
|
||||
expect(result.current.open?.routes).toHaveLength(2)
|
||||
expect(result.current.open?.routes.some(r => r.current)).toBe(false)
|
||||
const routes = result.current.open!.routes
|
||||
expect(routes).toHaveLength(2)
|
||||
expect(routes[0]).toMatchObject({ current: true, direct: true, distance: 290_000 })
|
||||
expect(routes.filter(r => r.current)).toHaveLength(1)
|
||||
expect(routes[1]).toMatchObject({ direct: false, coordinates: NORTH })
|
||||
})
|
||||
|
||||
it('FE-ALTHOOK-006: the driven road failing costs that entry, not the dialog', async () => {
|
||||
calculateRoute.mockRejectedValue(new Error('osrm down'))
|
||||
it('FE-ALTHOOK-006: the driven road among the other offers is still one entry, without the direct mark', async () => {
|
||||
calculateAlternatives.mockResolvedValue([offer(NORTH), offer(RAIL)])
|
||||
const { result } = renderHook(() => useRouteAlternatives())
|
||||
|
||||
act(() => { result.current.ask(4, 1, from, to, 'driving', [via(53, 11.5)]) })
|
||||
act(() => { result.current.ask(request()) })
|
||||
await waitFor(() => expect(result.current.open?.loading).toBe(false))
|
||||
|
||||
expect(result.current.open?.error).toBe(false)
|
||||
expect(result.current.open?.routes).toHaveLength(2)
|
||||
const routes = result.current.open!.routes
|
||||
expect(routes).toHaveLength(2)
|
||||
expect(routes[0].current).toBe(true)
|
||||
expect(routes[0].direct).toBeUndefined()
|
||||
expect(routes[1]).toMatchObject({ direct: true, coordinates: NORTH })
|
||||
})
|
||||
|
||||
it('FE-ALTHOOK-007: a router that will not answer says so instead of showing nothing', async () => {
|
||||
calculateAlternatives.mockRejectedValue(new Error('osrm down'))
|
||||
calculateAlternatives.mockRejectedValue(new Error('valhalla down'))
|
||||
const { result } = renderHook(() => useRouteAlternatives())
|
||||
|
||||
act(() => { result.current.ask(4, 1, from, to, 'driving', []) })
|
||||
act(() => { result.current.ask(request()) })
|
||||
await waitFor(() => expect(result.current.open?.loading).toBe(false))
|
||||
|
||||
expect(result.current.open).toMatchObject({ error: true, routes: [] })
|
||||
@@ -119,9 +155,9 @@ describe('useRouteAlternatives', () => {
|
||||
it('FE-ALTHOOK-008: only driving, walking and cycling reach the router', () => {
|
||||
const { result } = renderHook(() => useRouteAlternatives())
|
||||
|
||||
for (const [asked, sent] of [['walking', 'walking'], ['cycling', 'cycling'], ['transit', 'driving'], ['plugin:ferry', 'driving']]) {
|
||||
for (const [asked, sent] of [['walking', 'walking'], ['cycling', 'cycling'], ['transit', 'driving'], ['plugin:ev/fast', 'driving']]) {
|
||||
calculateAlternatives.mockClear()
|
||||
act(() => { result.current.ask(4, 1, from, to, asked, []) })
|
||||
act(() => { result.current.ask(request({ mode: asked })) })
|
||||
expect(calculateAlternatives.mock.calls[0][2], asked).toBe(sent)
|
||||
}
|
||||
})
|
||||
@@ -136,8 +172,8 @@ describe('useRouteAlternatives', () => {
|
||||
})
|
||||
const { result } = renderHook(() => useRouteAlternatives())
|
||||
|
||||
act(() => { result.current.ask(4, 1, from, to, 'driving', []) })
|
||||
act(() => { result.current.ask(4, 2, from, to, 'driving', []) })
|
||||
act(() => { result.current.ask(request()) })
|
||||
act(() => { result.current.ask(request({}, { index: 2 })) })
|
||||
|
||||
expect(signals[0].aborted).toBe(true)
|
||||
expect(signals[1].aborted).toBe(false)
|
||||
@@ -156,16 +192,64 @@ describe('useRouteAlternatives', () => {
|
||||
rerender()
|
||||
expect(result.current).toBe(idle)
|
||||
|
||||
act(() => { result.current.ask(4, 1, from, to, 'driving', []) })
|
||||
act(() => { result.current.ask(request()) })
|
||||
const asking = result.current
|
||||
expect(asking).not.toBe(idle)
|
||||
// The functions themselves never change, only the object carrying the new `open`.
|
||||
expect(asking.ask).toBe(idle.ask)
|
||||
expect(asking.close).toBe(idle.close)
|
||||
expect(asking.prove).toBe(idle.prove)
|
||||
expect(asking.settle).toBe(idle.settle)
|
||||
|
||||
await waitFor(() => expect(result.current.open?.loading).toBe(false))
|
||||
const answered = result.current
|
||||
rerender()
|
||||
expect(result.current).toBe(answered)
|
||||
})
|
||||
|
||||
it('FE-ALTHOOK-011: checking a choice marks it on the picker, and closing abandons the check', async () => {
|
||||
const { result } = renderHook(() => useRouteAlternatives())
|
||||
act(() => { result.current.ask(request()) })
|
||||
await waitFor(() => expect(result.current.open?.loading).toBe(false))
|
||||
|
||||
let signal: AbortSignal | undefined
|
||||
act(() => { signal = result.current.prove(2) })
|
||||
expect(result.current.open?.proving).toBe(2)
|
||||
expect(signal?.aborted).toBe(false)
|
||||
|
||||
// A check that led to no save leaves the picker open for another choice.
|
||||
act(() => { result.current.settle() })
|
||||
expect(result.current.open?.proving).toBeNull()
|
||||
expect(result.current.open?.routes).toHaveLength(3)
|
||||
|
||||
act(() => { signal = result.current.prove(1) })
|
||||
act(() => { result.current.close() })
|
||||
expect(signal?.aborted).toBe(true)
|
||||
expect(result.current.open).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ALTHOOK-012: another leg, or another check, abandons the one in hand', async () => {
|
||||
// The router's answer to an abandoned check must not be written onto the leg opened
|
||||
// after it, nor race the check that replaced it.
|
||||
const { result } = renderHook(() => useRouteAlternatives())
|
||||
act(() => { result.current.ask(request()) })
|
||||
await waitFor(() => expect(result.current.open?.loading).toBe(false))
|
||||
|
||||
let first: AbortSignal | undefined
|
||||
let second: AbortSignal | undefined
|
||||
act(() => { first = result.current.prove(1) })
|
||||
act(() => { second = result.current.prove(2) })
|
||||
expect(first?.aborted).toBe(true)
|
||||
expect(second?.aborted).toBe(false)
|
||||
|
||||
act(() => { result.current.ask(request({}, { index: 0 })) })
|
||||
expect(second?.aborted).toBe(true)
|
||||
expect(result.current.open).toMatchObject({ index: 0, proving: null })
|
||||
|
||||
// Nothing open, nothing to mark.
|
||||
act(() => { result.current.close() })
|
||||
act(() => { result.current.prove(1) })
|
||||
act(() => { result.current.settle() })
|
||||
expect(result.current.open).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,111 +1,170 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { calculateAlternatives, calculateRoute, type RouteAlternative } from '../Map/RouteCalculator'
|
||||
import type { RoadtripStop } from './useRoadtripRoutes'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
import { calculateAlternatives, sameRoad, type RouteAlternative, type RouteEngine } from '../Map/RouteCalculator'
|
||||
import type { RailLegRoute, RailLegRouter } from './useRoadtripRoutes'
|
||||
|
||||
/** One offered way of driving a leg, with what it means for this trip. */
|
||||
export interface OfferedRoute extends RouteAlternative {
|
||||
/** True for the road currently driven — the one the vias on this leg produce. */
|
||||
/** True for the road the rail drives on this leg now, whatever shaped it. */
|
||||
current?: boolean
|
||||
/** True for the router's own preference with no vias at all. */
|
||||
direct?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Which drive on a card a picker is asked about: the leg from stop `index` to the next one.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export type RailDrive = { kind: 'leg'; index: number }
|
||||
|
||||
/** Where the vias that shape a leg are stored: after stop `afterIndex` of day `dayId`. */
|
||||
export interface ViaAnchor {
|
||||
dayId: number
|
||||
afterIndex: number
|
||||
}
|
||||
|
||||
/** Which leg is being reconsidered, and what was offered for it. */
|
||||
export interface LegAlternatives {
|
||||
dayId: number
|
||||
/** Index of the leg within the day: the drive from stop `index` to `index + 1`. */
|
||||
index: number
|
||||
/** Where a choice is written. Read off the stop the leg leaves, when it was asked about. */
|
||||
anchor: ViaAnchor
|
||||
/**
|
||||
* The two stops the leg ran between when it was asked about, by assignment. A choice is
|
||||
* only written while the rail still has this leg at `anchor`, so a chain that re-flowed
|
||||
* in between cannot receive a road that was worked out for another one.
|
||||
*/
|
||||
ends: { from: number; to: number }
|
||||
/** The engine the rail drives this leg with. Offers from another cannot be read against it. */
|
||||
engine: RouteEngine
|
||||
/** Routes the leg through pins exactly as the rail does, to check a choice before saving it. */
|
||||
route: RailLegRouter['route']
|
||||
routes: OfferedRoute[]
|
||||
loading: boolean
|
||||
error: boolean
|
||||
/** The offer being checked against the rail's router and saved, or null while none is. */
|
||||
proving: number | null
|
||||
}
|
||||
|
||||
/** Everything a picker needs to know about the leg it is opened on. */
|
||||
export interface AlternativesRequest {
|
||||
dayId: number
|
||||
index: number
|
||||
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. */
|
||||
driven: Pick<RailLegRoute, 'coordinates' | 'distance' | 'duration'>
|
||||
anchor: ViaAnchor
|
||||
ends: { from: number; to: number }
|
||||
/** How the rail routes this leg: mode, avoided classes, engine, and the call itself. */
|
||||
router: RailLegRouter
|
||||
}
|
||||
|
||||
export interface RouteAlternativesState {
|
||||
open: LegAlternatives | null
|
||||
/** Asks the router for the ways of driving this one leg. */
|
||||
ask: (dayId: number, index: number, from: RoadtripStop, to: RoadtripStop, profile: string, vias: RoadtripVia[]) => void
|
||||
/** Asks the rail's own router for the ways of driving this one leg. */
|
||||
ask: (request: AlternativesRequest) => void
|
||||
close: () => void
|
||||
}
|
||||
|
||||
/** Two lines are the same road if their ends and their length agree closely enough. */
|
||||
function sameRoute(a: RouteAlternative, b: RouteAlternative): boolean {
|
||||
return Math.abs(a.distance - b.distance) < 50 && Math.abs(a.duration - b.duration) < 30
|
||||
/**
|
||||
* Marks offer `index` as being checked and saved. The signal aborts when the picker
|
||||
* closes, moves to another leg, or another check starts.
|
||||
*/
|
||||
prove: (index: number) => AbortSignal
|
||||
/** Ends a check that led to no save, leaving the picker open for another choice. */
|
||||
settle: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Other ways of driving one leg.
|
||||
*
|
||||
* One leg at a time, on demand, and never for the whole trip: OSRM answers with
|
||||
* One leg at a time, on demand, and never for the whole trip: the routers answer with
|
||||
* alternatives only between exactly two coordinates, so asking for a day would mean
|
||||
* breaking up the single request `splitIntoRuns` bundles it into — the optimisation that
|
||||
* breaking up the single request `splitIntoRuns` bundles it into, the optimisation that
|
||||
* makes a road trip appear at once rather than trickle in.
|
||||
*
|
||||
* When the leg already carries vias, the road actually being driven is NOT among what the
|
||||
* router offers for the two bare endpoints — it was asked a different question. So it is
|
||||
* fetched separately and put at the top, marked as the current one. Without that, opening
|
||||
* the picker on a leg you have already reshaped shows you three roads, none of which is
|
||||
* the one you are on, and no way back to it.
|
||||
* The offers come from the engine that drives the leg on the rail, with the same mode and
|
||||
* the same avoided classes, and the road the rail is on heads the list as `current`,
|
||||
* always, taken from the rail itself rather than asked for again. Asked for again it came
|
||||
* from OSRM with nothing avoided while the rail drove Valhalla, so the list had no current
|
||||
* road at all, called a road the rail was not on the fastest, and choosing it did nothing.
|
||||
* An offer that is the current road is not listed a second time; if it was the router's
|
||||
* own preference, the current road says so.
|
||||
*/
|
||||
export function useRouteAlternatives(): RouteAlternativesState {
|
||||
const [open, setOpen] = useState<LegAlternatives | null>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const provingRef = useRef<AbortController | null>(null)
|
||||
|
||||
const stopProving = useCallback(() => {
|
||||
provingRef.current?.abort()
|
||||
provingRef.current = null
|
||||
}, [])
|
||||
|
||||
const close = useCallback(() => {
|
||||
abortRef.current?.abort()
|
||||
abortRef.current = null
|
||||
stopProving()
|
||||
setOpen(null)
|
||||
}, [])
|
||||
}, [stopProving])
|
||||
|
||||
const ask = useCallback((
|
||||
dayId: number,
|
||||
index: number,
|
||||
from: RoadtripStop,
|
||||
to: RoadtripStop,
|
||||
profile: string,
|
||||
vias: RoadtripVia[],
|
||||
) => {
|
||||
const ask = useCallback((request: AlternativesRequest) => {
|
||||
abortRef.current?.abort()
|
||||
stopProving()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
setOpen({ dayId, index, routes: [], loading: true, error: false })
|
||||
const { dayId, index, from, to, driven, anchor, ends, router } = request
|
||||
const leg = { dayId, index, anchor, ends, engine: router.engine, route: router.route, proving: null }
|
||||
setOpen({ ...leg, routes: [], loading: true, error: false })
|
||||
|
||||
const osrmProfile: 'driving' | 'walking' | 'cycling' =
|
||||
profile === 'walking' ? 'walking' : profile === 'cycling' ? 'cycling' : 'driving'
|
||||
const ends = [{ lat: from.lat, lng: from.lng }, { lat: to.lat, lng: to.lng }]
|
||||
const current: OfferedRoute = {
|
||||
coordinates: driven.coordinates,
|
||||
distance: driven.distance,
|
||||
duration: driven.duration,
|
||||
divergence: null,
|
||||
engine: router.engine,
|
||||
current: true,
|
||||
}
|
||||
// A plugin's mode means nothing to the routers asked here, so its leg is offered the
|
||||
// ways a car would take; the offers then carry OSRM's times, and the list says so.
|
||||
const profile: 'driving' | 'walking' | 'cycling' =
|
||||
router.mode === 'walking' ? 'walking' : router.mode === 'cycling' ? 'cycling' : 'driving'
|
||||
|
||||
// The router's offers for the bare leg, plus — only when the leg is already bent —
|
||||
// the road that bend produces, so both are on the table together.
|
||||
const offers = calculateAlternatives(ends[0], ends[1], osrmProfile, { signal: controller.signal })
|
||||
const current = vias.length
|
||||
? calculateRoute(
|
||||
[ends[0], ...vias.map(v => ({ lat: v.lat, lng: v.lng })), ends[1]],
|
||||
osrmProfile,
|
||||
{ signal: controller.signal },
|
||||
).then(r => ({
|
||||
coordinates: r.coordinates,
|
||||
distance: r.distance,
|
||||
duration: r.duration,
|
||||
divergence: null,
|
||||
current: true,
|
||||
} as OfferedRoute)).catch(() => null)
|
||||
: Promise.resolve(null)
|
||||
|
||||
void Promise.all([offers, current])
|
||||
.then(([alternatives, driven]) => {
|
||||
void calculateAlternatives(from, to, profile, { signal: controller.signal, avoid: router.avoid })
|
||||
.then(alternatives => {
|
||||
if (controller.signal.aborted) return
|
||||
const routes: OfferedRoute[] = alternatives.map((r, i) => ({ ...r, direct: i === 0 }))
|
||||
// Only prepend the driven road when it really is a different one; with a via that
|
||||
// barely moves the route, the two are the same road and one entry is honest.
|
||||
if (driven && !routes.some(r => sameRoute(r, driven))) routes.unshift(driven)
|
||||
setOpen({ dayId, index, routes, loading: false, error: false })
|
||||
const routes: OfferedRoute[] = [current]
|
||||
alternatives.forEach((offer, i) => {
|
||||
if (sameRoad(offer, current)) {
|
||||
// The rail is already on the router's own preference: one entry, and it says so.
|
||||
if (i === 0) current.direct = true
|
||||
return
|
||||
}
|
||||
routes.push({ ...offer, direct: i === 0 })
|
||||
})
|
||||
setOpen({ ...leg, routes, loading: false, error: false })
|
||||
})
|
||||
.catch(() => {
|
||||
if (controller.signal.aborted) return
|
||||
// A router that will not answer is not worth a dialog; the leg keeps the route it
|
||||
// already has and the panel says so.
|
||||
setOpen({ dayId, index, routes: [], loading: false, error: true })
|
||||
setOpen({ ...leg, routes: [], loading: false, error: true })
|
||||
})
|
||||
}, [stopProving])
|
||||
|
||||
const prove = useCallback((index: number) => {
|
||||
stopProving()
|
||||
const controller = new AbortController()
|
||||
provingRef.current = controller
|
||||
setOpen(o => (o ? { ...o, proving: index } : o))
|
||||
return controller.signal
|
||||
}, [stopProving])
|
||||
|
||||
const settle = useCallback(() => {
|
||||
provingRef.current = null
|
||||
setOpen(o => (o ? { ...o, proving: null } : o))
|
||||
}, [])
|
||||
|
||||
// One object for as long as nothing in it changes. The planner keys its close gate and
|
||||
@@ -114,5 +173,5 @@ export function useRouteAlternatives(): RouteAlternativesState {
|
||||
// planner, whatever had caused it. Whether the picker stays open is still the gate's own
|
||||
// condition to decide (a new `open` runs it either way); the memo only stops that work
|
||||
// from repeating on renders that changed nothing about the picker.
|
||||
return useMemo(() => ({ open, ask, close }), [open, ask, close])
|
||||
return useMemo(() => ({ open, ask, close, prove, settle }), [open, ask, close, prove, settle])
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ function AlternativesBody({ planner, alts }: MRtAlternativesBarProps) {
|
||||
// Scrolls sideways rather than wrapping, because the row has a fixed height. The negative
|
||||
// margin lets a chip slide under the bar's own edge instead of stopping short of it.
|
||||
return (
|
||||
<div className="-mx-[14px] flex h-full snap-x gap-2 overflow-x-auto px-[14px]">
|
||||
<div className="-mx-[14px] flex h-full snap-x gap-2 overflow-x-auto px-[14px]" aria-busy={alts.saving}>
|
||||
{alts.overlays.map(alt => (
|
||||
<AlternativeChip
|
||||
key={alt.index}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useTransition } from 'react'
|
||||
import { alternativesPhase, type AlternativeOverlay, type AlternativesPhase } from '../../../../components/Roadtrip/alternativeOverlays'
|
||||
import { alternativesBusy, alternativesPhase, type AlternativeOverlay, type AlternativesPhase } from '../../../../components/Roadtrip/alternativeOverlays'
|
||||
import type { LegAlternatives } from '../../../../components/Roadtrip/useRouteAlternatives'
|
||||
import type { MTripShellApi, TripPlanner } from '../MTripShell'
|
||||
|
||||
@@ -37,7 +37,7 @@ export interface MRtAlternativesController {
|
||||
/** False offline: a choice is written as a via, and vias are online only. */
|
||||
editable: boolean
|
||||
canConfirm: boolean
|
||||
/** True while the chosen road is being written. */
|
||||
/** 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
|
||||
@@ -68,7 +68,10 @@ export function useMRtAlternatives(planner: TripPlanner, shell: MTripShellApi):
|
||||
const { open, close } = planner.routeAlternatives
|
||||
const overlays = planner.alternativeOverlays
|
||||
const editable = planner.roadtripVias.editable
|
||||
const [saving, startSaving] = useTransition()
|
||||
const [confirming, startSaving] = useTransition()
|
||||
// The planner's own mark as well as this transition, so the bar stands still for as long
|
||||
// as the choice is being checked, whichever surface started it.
|
||||
const saving = confirming || alternativesBusy(open)
|
||||
|
||||
const phase = open ? alternativesPhase(open, overlays) : null
|
||||
const highlighted = planner.highlightedAlternative
|
||||
@@ -100,7 +103,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, legIndex)
|
||||
planner.askRouteAlternatives(dayId, { kind: 'leg', index: legIndex })
|
||||
}
|
||||
if (shell.rtView === 'list') shell.toggleRtView()
|
||||
}
|
||||
@@ -111,8 +114,9 @@ export function useMRtAlternatives(planner: TripPlanner, shell: MTripShellApi):
|
||||
if (!canConfirm || !picked) return
|
||||
const index = picked.index
|
||||
// A transition rather than a flag of our own, so the pending state ends when the
|
||||
// write does. The planner reports a failure itself and leaves the picker open for
|
||||
// another go, and closes it once the via is saved.
|
||||
// write does. The planner checks the road with the router first, reports a refusal
|
||||
// or a failure itself and leaves the picker open for another go, and closes it once
|
||||
// the road is saved.
|
||||
startSaving(async () => {
|
||||
await planner.chooseRouteAlternative(index)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { roadtripPreferencesRepo } from '../../repo/roadtripPreferencesRepo'
|
||||
// FE-TP-ROAD-001 to FE-TP-ROAD-115
|
||||
// FE-TP-ROAD-001 to FE-TP-ROAD-127
|
||||
import React from 'react'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { TranslationProvider } from '../../i18n/TranslationContext'
|
||||
@@ -91,7 +91,11 @@ const rt = vi.hoisted(() => {
|
||||
// the arguments are the only place the planner's own decisions are visible: which
|
||||
// days it is willing to route, and which assignment list it builds them from.
|
||||
const routesArgs = { current: [] as unknown[] }
|
||||
// The leg router the rail hands out, and the call it routes a leg with. The router is
|
||||
// what the planner asks with; the call is what a choice is proven against.
|
||||
const legRoute = vi.fn()
|
||||
const routes = {
|
||||
legRouter: vi.fn(),
|
||||
days: [] as Array<Record<string, unknown>>,
|
||||
quietDays: [] as unknown[],
|
||||
lines: [] as unknown[],
|
||||
@@ -144,12 +148,14 @@ const rt = vi.hoisted(() => {
|
||||
open: null as null | Record<string, unknown>,
|
||||
ask: vi.fn(),
|
||||
close: vi.fn(),
|
||||
prove: vi.fn(),
|
||||
settle: vi.fn(),
|
||||
}
|
||||
// Hands out a fresh copy of `alt` on every render when set, the identity the real hook
|
||||
// had before it was memoised. A stable fixture runs an effect keyed on it once and never
|
||||
// again, which is exactly how a close gate that fired on every render went unseen.
|
||||
const altFresh = { current: false }
|
||||
return { vias, routes, corridor, alt, altFresh, routesArgs }
|
||||
return { vias, routes, corridor, alt, altFresh, routesArgs, legRoute }
|
||||
})
|
||||
|
||||
vi.mock('../../components/Roadtrip/useRoadtripVias', () => ({ useRoadtripVias: () => rt.vias }))
|
||||
@@ -306,6 +312,12 @@ beforeEach(() => {
|
||||
rt.vias.byDay = {}
|
||||
rt.vias.stale = false
|
||||
rt.vias.editable = true
|
||||
// Reset rather than cleared: an implementation set by one case (a write that fails) would
|
||||
// otherwise carry into the next and answer for it.
|
||||
rt.vias.addMany.mockReset().mockResolvedValue(undefined)
|
||||
rt.legRoute.mockReset()
|
||||
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.routesArgs.current = []
|
||||
rt.routes.lines = []
|
||||
@@ -889,6 +901,10 @@ describe('useTripPlanner road trip: one stop at a time', () => {
|
||||
})
|
||||
|
||||
describe('useTripPlanner road trip: other ways of driving a leg', () => {
|
||||
/** The road the rail drives from Hamburg to Berlin, and a detour north of it. */
|
||||
const RAIL_LINE: [number, number][] = [[53.55, 9.99], [53.0, 11.5], [52.52, 13.4]]
|
||||
const DETOUR: [number, number][] = [[53.55, 9.99], [53.4, 11.5], [53.3, 12.8], [52.52, 13.4]]
|
||||
|
||||
const routedDay = () => {
|
||||
seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] })
|
||||
rt.corridor.day = { dayId: 5, dayNumber: 1 }
|
||||
@@ -896,24 +912,61 @@ describe('useTripPlanner road trip: other ways of driving a leg', () => {
|
||||
dayId: 5,
|
||||
dayNumber: 1,
|
||||
stops: [
|
||||
{ lat: 53.55, lng: 9.99, name: 'Hamburg' },
|
||||
{ lat: 52.52, lng: 13.4, name: 'Berlin' },
|
||||
{ assignmentId: 11, lat: 53.55, lng: 9.99, name: 'Hamburg' },
|
||||
{ assignmentId: 12, lat: 52.52, lng: 13.4, name: 'Berlin' },
|
||||
],
|
||||
geometry: [[53.55, 9.99], [53.0, 11.5], [52.52, 13.4]],
|
||||
legs: [{ distance: 290_000, duration: 10_800 }],
|
||||
legLines: [RAIL_LINE],
|
||||
geometry: RAIL_LINE,
|
||||
}]
|
||||
}
|
||||
|
||||
it('FE-TP-ROAD-034: asking passes the vias of THAT leg, so the road being driven is offered too', async () => {
|
||||
/** 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,
|
||||
anchor: { dayId: 5, afterIndex: 0 },
|
||||
ends: { from: 11, to: 12 },
|
||||
engine: 'osrm',
|
||||
route: rt.legRoute,
|
||||
routes: [
|
||||
{ coordinates: RAIL_LINE, distance: 290_000, duration: 10_800, divergence: null, current: true },
|
||||
...offers,
|
||||
],
|
||||
...over,
|
||||
}
|
||||
}
|
||||
const detour = (over: Record<string, unknown> = {}) =>
|
||||
({ coordinates: DETOUR, distance: 310_000, duration: 11_400, divergence: { lat: 53.3, lng: 12.8 }, ...over })
|
||||
const answer = (coordinates: [number, number][], over: Record<string, unknown> = {}) =>
|
||||
({ coordinates, distance: 300_000, duration: 11_000, fellBack: false, ...over })
|
||||
|
||||
it('FE-TP-ROAD-034: asking hands over the leg the rail drives, where a choice is filed, and the router for that leg', async () => {
|
||||
routedDay()
|
||||
rt.vias.byDay = { 5: [via(1, 5, 0, 1), via(2, 5, 0, 0), via(3, 5, 1)] }
|
||||
// A card that opens on a stop stored on the day before: its vias are filed there.
|
||||
rt.routes.days[0].stops = [
|
||||
{ assignmentId: 11, ownerDayId: 4, ownerIndex: 2, lat: 53.55, lng: 9.99, name: 'Hamburg' },
|
||||
{ assignmentId: 12, lat: 52.52, lng: 13.4, name: 'Berlin' },
|
||||
]
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
act(() => { result.current.askRouteAlternatives(5, 0) })
|
||||
act(() => { result.current.askRouteAlternatives(5, { kind: 'leg', index: 0 }) })
|
||||
|
||||
const [dayId, legIndex, , , profile, legVias] = rt.alt.ask.mock.calls[0] as [number, number, unknown, unknown, string, Array<{ id: number }>]
|
||||
expect([dayId, legIndex, profile]).toEqual([5, 0, 'driving'])
|
||||
// Only the vias on leg 0, and in the order the car passes them.
|
||||
expect(legVias.map(v => v.id)).toEqual([2, 1])
|
||||
const [request] = rt.alt.ask.mock.calls[0] as [Record<string, unknown>]
|
||||
expect(request).toMatchObject({
|
||||
dayId: 5,
|
||||
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.
|
||||
driven: { coordinates: RAIL_LINE, distance: 290_000, duration: 10_800 },
|
||||
anchor: { dayId: 4, afterIndex: 2 },
|
||||
ends: { from: 11, to: 12 },
|
||||
})
|
||||
// The router of THAT leg, built for the card it is drawn on, not the trip-wide profile.
|
||||
const [from, to, cardDayId] = rt.routes.legRouter.mock.calls[0] as [{ assignmentId: number }, { assignmentId: number }, number]
|
||||
expect([from.assignmentId, to.assignmentId, cardDayId]).toEqual([11, 12, 5])
|
||||
expect((request.router as { route: unknown }).route).toBe(rt.legRoute)
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-035: asking again for the leg already open closes it instead', async () => {
|
||||
@@ -921,42 +974,48 @@ describe('useTripPlanner road trip: other ways of driving a leg', () => {
|
||||
rt.alt.open = { dayId: 5, index: 0, routes: [], loading: false, error: false }
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
act(() => { result.current.askRouteAlternatives(5, 0) })
|
||||
act(() => { result.current.askRouteAlternatives(5, { kind: 'leg', index: 0 }) })
|
||||
|
||||
expect(rt.alt.close).toHaveBeenCalled()
|
||||
expect(rt.alt.ask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-036: a leg with nothing at its far end is not a leg', async () => {
|
||||
it('FE-TP-ROAD-036: a leg with nothing at its far end, or no road yet, is not a leg', async () => {
|
||||
routedDay()
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
act(() => { result.current.askRouteAlternatives(5, 1) })
|
||||
act(() => { result.current.askRouteAlternatives(5, { kind: 'leg', index: 1 }) })
|
||||
rt.routes.days[0].legs = []
|
||||
act(() => { result.current.askRouteAlternatives(5, { kind: 'leg', index: 0 }) })
|
||||
|
||||
expect(rt.alt.ask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-037: choosing the road already driven changes nothing but the picker', async () => {
|
||||
routedDay()
|
||||
rt.alt.open = { dayId: 5, index: 0, routes: [{ current: true }], loading: false, error: false }
|
||||
openWith([detour()])
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
await act(async () => { await result.current.chooseRouteAlternative(0) })
|
||||
|
||||
expect(rt.vias.add).not.toHaveBeenCalled()
|
||||
expect(rt.legRoute).not.toHaveBeenCalled()
|
||||
expect(rt.vias.addMany).not.toHaveBeenCalled()
|
||||
expect(rt.alt.close).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-038: the router own preference clears the leg in ONE write', async () => {
|
||||
it('FE-TP-ROAD-038: the router own preference on a bent leg is proven without pins and clears the leg in ONE write', async () => {
|
||||
// One delete per via meant a full trip re-route between each of them, so undoing a
|
||||
// detour with three vias drew three routes.
|
||||
routedDay()
|
||||
rt.alt.open = { dayId: 5, index: 0, routes: [{ direct: true }], loading: false, error: false }
|
||||
rt.vias.byDay = { 5: [via(1, 5, 0), via(2, 5, 0, 1)] }
|
||||
openWith([detour({ direct: true })])
|
||||
rt.legRoute.mockResolvedValue(answer(DETOUR))
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
await act(async () => { await result.current.chooseRouteAlternative(0) })
|
||||
await act(async () => { await result.current.chooseRouteAlternative(1) })
|
||||
|
||||
expect(rt.legRoute).toHaveBeenCalledTimes(1)
|
||||
expect(rt.legRoute.mock.calls[0][0]).toEqual([])
|
||||
expect(rt.vias.addMany).toHaveBeenCalledWith(5, [], [0])
|
||||
expect(rt.alt.close).toHaveBeenCalled()
|
||||
})
|
||||
@@ -965,48 +1024,50 @@ describe('useTripPlanner road trip: other ways of driving a leg', () => {
|
||||
// Swallowing it closed the picker on a leg that still carries its via and still
|
||||
// routes the old way, so the traveller believed they had undone the detour.
|
||||
routedDay()
|
||||
rt.alt.open = { dayId: 5, index: 0, routes: [{ direct: true }], loading: false, error: false }
|
||||
rt.vias.byDay = { 5: [via(1, 5, 0)] }
|
||||
openWith([detour({ direct: true })])
|
||||
rt.legRoute.mockResolvedValue(answer(DETOUR))
|
||||
rt.vias.addMany.mockRejectedValue(new Error('offline'))
|
||||
const { result } = await renderRoadtrip()
|
||||
// The mode starts off until the addon feed answers, and that effect closes the
|
||||
// picker once on the way in. Measure from there.
|
||||
rt.alt.close.mockClear()
|
||||
|
||||
await act(async () => { await result.current.chooseRouteAlternative(0) })
|
||||
await act(async () => { await result.current.chooseRouteAlternative(1) })
|
||||
|
||||
expect(toasts.some(t => t.type === 'error' && t.message === 'offline')).toBe(true)
|
||||
expect(rt.alt.close).not.toHaveBeenCalled()
|
||||
expect(rt.alt.settle).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-040: another road replaces the leg it reshapes, as a via rather than a polyline', async () => {
|
||||
it('FE-TP-ROAD-040: another road the router drives through one pin replaces the leg with that pin', async () => {
|
||||
routedDay()
|
||||
rt.alt.open = {
|
||||
dayId: 5, index: 0, loading: false, error: false,
|
||||
routes: [{ divergence: { lat: 53.1, lng: 11.9 } }],
|
||||
}
|
||||
openWith([detour()])
|
||||
rt.legRoute.mockResolvedValue(answer(DETOUR))
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
await act(async () => { await result.current.chooseRouteAlternative(0) })
|
||||
await act(async () => { await result.current.chooseRouteAlternative(1) })
|
||||
|
||||
// Replacing, not appending: the alternatives were computed for the two bare
|
||||
// endpoints, so a leg that still carries its old via routes somewhere the
|
||||
// Marked as being checked while the router is asked.
|
||||
expect(rt.alt.prove).toHaveBeenCalledWith(1)
|
||||
// The first pin is where the offer strays furthest from the road being driven.
|
||||
expect(rt.legRoute.mock.calls[0][0]).toEqual([{ lat: 53.3, lng: 12.8 }])
|
||||
// Replacing, not appending: a leg that still carries its old via routes somewhere the
|
||||
// preview never drew.
|
||||
expect(rt.vias.addMany).toHaveBeenCalledWith(5, [{ after_order_index: 0, lat: 53.1, lng: 11.9 }], [0])
|
||||
expect(rt.vias.addMany).toHaveBeenCalledWith(5, [{ after_order_index: 0, lat: 53.3, lng: 12.8 }], [0])
|
||||
expect(rt.vias.add).not.toHaveBeenCalled()
|
||||
expect(rt.alt.close).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-041: a via that will not save leaves the picker open', async () => {
|
||||
it('FE-TP-ROAD-041: pins that will not save leave the picker open', async () => {
|
||||
routedDay()
|
||||
rt.alt.open = {
|
||||
dayId: 5, index: 0, loading: false, error: false,
|
||||
routes: [{ divergence: { lat: 53.1, lng: 11.9 } }],
|
||||
}
|
||||
openWith([detour()])
|
||||
rt.legRoute.mockResolvedValue(answer(DETOUR))
|
||||
rt.vias.addMany.mockRejectedValue(new Error('rejected'))
|
||||
const { result } = await renderRoadtrip()
|
||||
rt.alt.close.mockClear()
|
||||
|
||||
await act(async () => { await result.current.chooseRouteAlternative(0) })
|
||||
await act(async () => { await result.current.chooseRouteAlternative(1) })
|
||||
|
||||
expect(toasts.some(t => t.type === 'error' && t.message === 'rejected')).toBe(true)
|
||||
expect(rt.alt.close).not.toHaveBeenCalled()
|
||||
@@ -1019,10 +1080,211 @@ describe('useTripPlanner road trip: other ways of driving a leg', () => {
|
||||
|
||||
await act(async () => { await result.current.chooseRouteAlternative(3) })
|
||||
|
||||
expect(rt.legRoute).not.toHaveBeenCalled()
|
||||
expect(rt.vias.add).not.toHaveBeenCalled()
|
||||
expect(rt.vias.addMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-116: a road one pin does not hold gets a second, in the order the road drives them', async () => {
|
||||
// The first answer passes the pin and still takes the old road for the first half.
|
||||
routedDay()
|
||||
openWith([detour()])
|
||||
rt.legRoute
|
||||
.mockResolvedValueOnce(answer([[53.55, 9.99], [53.0, 11.5], [53.3, 12.8], [52.52, 13.4]]))
|
||||
.mockResolvedValueOnce(answer(DETOUR))
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
await act(async () => { await result.current.chooseRouteAlternative(1) })
|
||||
|
||||
expect(rt.legRoute).toHaveBeenCalledTimes(2)
|
||||
// The new pin goes in front of the first, because the detour reaches it first.
|
||||
const pins = [{ lat: 53.4, lng: 11.5 }, { lat: 53.3, lng: 12.8 }]
|
||||
expect(rt.legRoute.mock.calls[1][0]).toEqual(pins)
|
||||
expect(rt.vias.addMany).toHaveBeenCalledWith(5, pins.map(p => ({ after_order_index: 0, ...p })), [0])
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-117: a road the router will not drive is not saved, and the picker stays open to say so', async () => {
|
||||
routedDay()
|
||||
openWith([detour()])
|
||||
// Whatever is pinned, the router comes back on the road it was already on.
|
||||
rt.legRoute.mockResolvedValue(answer(RAIL_LINE))
|
||||
const { result } = await renderRoadtrip()
|
||||
rt.alt.close.mockClear()
|
||||
|
||||
await act(async () => { await result.current.chooseRouteAlternative(1) })
|
||||
|
||||
expect(rt.vias.addMany).not.toHaveBeenCalled()
|
||||
expect(rt.alt.close).not.toHaveBeenCalled()
|
||||
expect(rt.alt.settle).toHaveBeenCalled()
|
||||
expect(toasts).toContainEqual({ message: 'The road trip’s router won’t follow this way, so it was not saved.', type: 'error' })
|
||||
// Nothing about a ferry the offer never took.
|
||||
expect(toasts).not.toContainEqual(expect.objectContaining({ message: expect.stringContaining('ferry') }))
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-118: a ferry the router would not board says how to get it driven', async () => {
|
||||
// #2461: the way across the North Sea, pinned on the ferry, came back through Calais.
|
||||
routedDay()
|
||||
openWith([detour({ hasFerry: true, avoids: 'motorway', engine: 'valhalla' })])
|
||||
rt.legRoute.mockResolvedValue(answer(RAIL_LINE, { hasFerry: false }))
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
await act(async () => { await result.current.chooseRouteAlternative(1) })
|
||||
|
||||
expect(rt.vias.addMany).not.toHaveBeenCalled()
|
||||
expect(toasts).toContainEqual({
|
||||
message: 'This way crosses by ferry. Add the ferry as a transport booking and the drive follows it.',
|
||||
type: 'info',
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-119: the router own road on a leg nothing bends writes nothing and closes', async () => {
|
||||
routedDay()
|
||||
openWith([detour({ direct: true })])
|
||||
rt.legRoute.mockResolvedValue(answer(DETOUR))
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
await act(async () => { await result.current.chooseRouteAlternative(1) })
|
||||
|
||||
expect(rt.legRoute).toHaveBeenCalledTimes(1)
|
||||
expect(rt.vias.addMany).not.toHaveBeenCalled()
|
||||
expect(rt.alt.close).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-120: a leg that changed while the router was asked is not written to', async () => {
|
||||
routedDay()
|
||||
openWith([detour()])
|
||||
let answerNow: (value: unknown) => void = () => {}
|
||||
rt.legRoute.mockImplementation(() => new Promise(resolve => { answerNow = resolve }))
|
||||
const { result, rerender } = await renderRoadtrip()
|
||||
|
||||
let choosing: Promise<void> = Promise.resolve()
|
||||
act(() => { choosing = result.current.chooseRouteAlternative(1) })
|
||||
// A stop dropped in between: the leg from Hamburg now ends somewhere else.
|
||||
rt.routes.days = [{
|
||||
...rt.routes.days[0],
|
||||
stops: [
|
||||
{ assignmentId: 11, ownerDayId: 5, ownerIndex: 0, lat: 53.55, lng: 9.99 },
|
||||
{ assignmentId: 13, ownerDayId: 5, ownerIndex: 1, lat: 53.1, lng: 11.9 },
|
||||
{ assignmentId: 12, ownerDayId: 5, ownerIndex: 2, lat: 52.52, lng: 13.4 },
|
||||
],
|
||||
}]
|
||||
rerender()
|
||||
await act(async () => { answerNow(answer(DETOUR)); await choosing })
|
||||
|
||||
expect(rt.vias.addMany).not.toHaveBeenCalled()
|
||||
expect(toasts).toContainEqual({ message: 'This leg changed while the way was being checked, so nothing was saved.', type: 'error' })
|
||||
expect(rt.alt.close).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-121: offline nothing is checked and nothing is written, and it says why', async () => {
|
||||
routedDay()
|
||||
openWith([detour()])
|
||||
rt.vias.editable = false
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
await act(async () => { await result.current.chooseRouteAlternative(1) })
|
||||
|
||||
expect(rt.alt.prove).not.toHaveBeenCalled()
|
||||
expect(rt.legRoute).not.toHaveBeenCalled()
|
||||
expect(rt.vias.addMany).not.toHaveBeenCalled()
|
||||
expect(toasts).toContainEqual({ message: 'Other ways are saved online only. Reconnect to take this one.', type: 'error' })
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-122: a second choice while the first is being checked is not taken', async () => {
|
||||
// The map line stays clickable while a chip's choice is checked.
|
||||
routedDay()
|
||||
openWith([detour(), detour({ coordinates: [[53.55, 9.99], [52.0, 11.5], [52.52, 13.4]] })], { proving: 1 })
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
await act(async () => { await result.current.chooseRouteAlternative(2) })
|
||||
|
||||
expect(rt.alt.prove).not.toHaveBeenCalled()
|
||||
expect(rt.legRoute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-123: an answer from the stand-in engine proves nothing, so nothing is written', async () => {
|
||||
routedDay()
|
||||
openWith([detour()], { engine: 'valhalla' })
|
||||
rt.legRoute.mockResolvedValue(answer(DETOUR, { fellBack: true }))
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
await act(async () => { await result.current.chooseRouteAlternative(1) })
|
||||
|
||||
expect(rt.vias.addMany).not.toHaveBeenCalled()
|
||||
expect(rt.alt.settle).toHaveBeenCalled()
|
||||
expect(toasts).toContainEqual({ message: 'The router is not answering right now.', type: 'error' })
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-124: a router that fails while checking says so and writes nothing', async () => {
|
||||
routedDay()
|
||||
openWith([detour()])
|
||||
rt.legRoute.mockRejectedValue(new Error('429'))
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
await act(async () => { await result.current.chooseRouteAlternative(1) })
|
||||
|
||||
expect(rt.vias.addMany).not.toHaveBeenCalled()
|
||||
expect(rt.alt.settle).toHaveBeenCalled()
|
||||
expect(toasts).toContainEqual({ message: 'The router is not answering right now.', type: 'error' })
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-125: a check abandoned by closing the picker ends in silence', async () => {
|
||||
routedDay()
|
||||
openWith([detour()])
|
||||
const controller = new AbortController()
|
||||
rt.alt.prove.mockReturnValue(controller.signal)
|
||||
rt.legRoute.mockImplementation(async () => {
|
||||
controller.abort()
|
||||
throw new DOMException('aborted', 'AbortError')
|
||||
})
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
await act(async () => { await result.current.chooseRouteAlternative(1) })
|
||||
|
||||
expect(rt.vias.addMany).not.toHaveBeenCalled()
|
||||
expect(rt.alt.settle).not.toHaveBeenCalled()
|
||||
expect(toasts).not.toContainEqual(expect.objectContaining({ message: 'The router is not answering right now.' }))
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-127: a picker opened on another leg while the pins were written is left open', async () => {
|
||||
// The write lands, but the traveller has moved on to the next leg in the meantime:
|
||||
// closing now would close THAT picker. A write that fails is still said.
|
||||
routedDay()
|
||||
openWith([detour()])
|
||||
const controller = new AbortController()
|
||||
rt.alt.prove.mockReturnValue(controller.signal)
|
||||
rt.legRoute.mockResolvedValue(answer(DETOUR))
|
||||
rt.vias.addMany.mockImplementation(async () => { controller.abort() })
|
||||
const { result } = await renderRoadtrip()
|
||||
rt.alt.close.mockClear()
|
||||
|
||||
await act(async () => { await result.current.chooseRouteAlternative(1) })
|
||||
|
||||
expect(rt.vias.addMany).toHaveBeenCalledTimes(1)
|
||||
expect(rt.alt.close).not.toHaveBeenCalled()
|
||||
|
||||
const again = new AbortController()
|
||||
rt.alt.prove.mockReturnValue(again.signal)
|
||||
rt.vias.addMany.mockImplementation(async () => { again.abort(); throw new Error('rejected') })
|
||||
rt.alt.settle.mockClear()
|
||||
await act(async () => { await result.current.chooseRouteAlternative(1) })
|
||||
|
||||
expect(toasts).toContainEqual({ message: 'rejected', type: 'error' })
|
||||
expect(rt.alt.settle).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-126: offers are read against the engine the rail drives the leg with', async () => {
|
||||
// A trip that avoids something is driven by the second engine, and so are its offers:
|
||||
// none of them is another engine's, and the difference is printed.
|
||||
routedDay()
|
||||
openWith([detour({ engine: 'valhalla' })], { engine: 'valhalla' })
|
||||
rt.alt.open!.routes = (rt.alt.open!.routes as Array<Record<string, unknown>>).map((r, i) => (i === 0 ? { ...r, engine: 'valhalla' } : r))
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
expect(result.current.alternativeOverlays.map(o => o.otherEngine)).toEqual([false, false])
|
||||
expect(result.current.alternativeOverlays[1].slowerThanQuickest).toBe(600)
|
||||
})
|
||||
|
||||
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 = {
|
||||
|
||||
@@ -31,7 +31,7 @@ import { useAutomaticDayPoints } from '../../components/Roadtrip/useAutomaticDay
|
||||
import { useDayBoundaries } from '../../components/Roadtrip/useDayBoundaries'
|
||||
import type { DayBoundaryControls } from '../../components/Map/dayBoundaryDrag'
|
||||
import { dayWindow, roadtripInsertion } from '../../components/Roadtrip/dayWindow'
|
||||
import { carrierReservationIds, type CarrierTerminal } from '@trek/shared/roadtrip'
|
||||
import { carrierReservationIds, viasLeaving, type CarrierTerminal } from '@trek/shared/roadtrip'
|
||||
import { useTripRouteOverview } from '../../components/Map/useTripRouteOverview'
|
||||
import { useDawarichTrail } from '../../components/Map/useDawarichTrail'
|
||||
import { collapsedDayDates } from '../../components/Map/dawarichTrail'
|
||||
@@ -41,8 +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 } from '../../components/Roadtrip/useRouteAlternatives'
|
||||
import { buildAlternativeOverlays } from '../../components/Roadtrip/alternativeOverlays'
|
||||
import { useRouteAlternatives, type RailDrive } from '../../components/Roadtrip/useRouteAlternatives'
|
||||
import { alternativesBusy, buildAlternativeOverlays } from '../../components/Roadtrip/alternativeOverlays'
|
||||
import { pinAlternative, 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'
|
||||
@@ -1524,7 +1525,7 @@ export function useTripPlanner() {
|
||||
noMotorway: t('roadtrip.alt.noMotorway'),
|
||||
noToll: t('roadtrip.alt.noToll'),
|
||||
noFerry: t('roadtrip.alt.noFerry'),
|
||||
}),
|
||||
}, routeAlternatives.open?.engine),
|
||||
[routeAlternatives.open, t],
|
||||
)
|
||||
|
||||
@@ -1579,85 +1580,122 @@ export function useTripPlanner() {
|
||||
[refuel.offered, alternativeFocusPoints, automaticPoints.focusPoints],
|
||||
)
|
||||
|
||||
/** Asks the router for other ways of driving one leg of one day. */
|
||||
const askRouteAlternatives = useCallback((dayId: number, legIndex: number) => {
|
||||
/**
|
||||
* 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])
|
||||
|
||||
/**
|
||||
* Asks the rail's own router for other ways of one drive on a card.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
const askRouteAlternatives = useCallback((dayId: number, drive: RailDrive) => {
|
||||
const day = roadtripRoutes.days.find(d => d.dayId === dayId)
|
||||
const from = day?.stops[legIndex]
|
||||
const to = day?.stops[legIndex + 1]
|
||||
if (!from || !to || !day) return
|
||||
if (routeAlternatives.open?.dayId === dayId && routeAlternatives.open.index === legIndex) {
|
||||
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) {
|
||||
routeAlternatives.close()
|
||||
return
|
||||
}
|
||||
// The vias on THIS leg, so the road currently driven is offered alongside the
|
||||
// router's own suggestions rather than being missing from its own picker.
|
||||
const dayIndex = from.ownerIndex
|
||||
const legVias = (roadtripVias.byDay[from.ownerDayId] ?? [])
|
||||
.filter(v => v.after_order_index === dayIndex)
|
||||
.sort((a, b) => a.sequence - b.sequence)
|
||||
routeAlternatives.ask(dayId, legIndex, from, to, routeProfile, legVias)
|
||||
}, [roadtripRoutes.days, routeAlternatives, routeProfile, roadtripVias.byDay])
|
||||
routeAlternatives.ask({
|
||||
dayId,
|
||||
index: drive.index,
|
||||
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 },
|
||||
// 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.
|
||||
anchor: { dayId: from.ownerDayId, afterIndex: from.ownerIndex },
|
||||
ends: { from: from.assignmentId, to: to.assignmentId },
|
||||
router,
|
||||
})
|
||||
}, [roadtripRoutes, routeAlternatives])
|
||||
|
||||
/**
|
||||
* Taking one of the offered routes.
|
||||
* Taking one of the offered routes: pinned, proven, and only then written.
|
||||
*
|
||||
* Saved as a via at the point where that route differs most from the default, not as a
|
||||
* stored polyline: a polyline goes stale with the next OSM update and with every stop
|
||||
* that moves, while a via keeps forcing the router back onto this road for as long as
|
||||
* the road exists.
|
||||
* Saved as vias, not as a stored polyline: a polyline goes stale with the next OSM update
|
||||
* and with every stop that moves, while a via keeps forcing the router back onto this
|
||||
* road for as long as the road exists. But a via only holds a road the router is willing
|
||||
* to drive through it, and nothing used to check that: a point on a ferry was pulled to
|
||||
* the pier and the day went the long way round, and a way weighed away from motorways
|
||||
* kept the motorway after its one pinned point. So the rail's own router is asked first
|
||||
* (`pinAlternative`), and a way it will not follow is not saved and says why.
|
||||
*
|
||||
* The pins replace the leg's vias in one write rather than joining them. Appending put a
|
||||
* new point behind the old one and routed out to each in turn, a zigzag matching neither
|
||||
* the preview nor the distance printed on it; and one delete per via meant a full re-route
|
||||
* between each of them. A write that fails is reported and leaves the picker open:
|
||||
* swallowed, it closed on a leg that still carried its via, and not even the reload ran
|
||||
* to contradict the traveller.
|
||||
*/
|
||||
const chooseRouteAlternative = useCallback(async (index: number) => {
|
||||
const open = routeAlternatives.open
|
||||
const alt = open?.routes[index]
|
||||
if (!open || !alt) return
|
||||
const offer = open?.routes[index]
|
||||
if (!open || !offer) return
|
||||
// Choosing the road already being driven changes nothing.
|
||||
if (alt.current) { routeAlternatives.close(); return }
|
||||
// The router's own preference means no detour at all, so the vias on this leg go.
|
||||
if (alt.direct || !alt.divergence) {
|
||||
const day = roadtripRoutes.days.find(d => d.dayId === open.dayId)
|
||||
const stop = day?.stops[open.index]
|
||||
const dayIndex = stop?.ownerIndex ?? -1
|
||||
// Clearing the leg in one write. One delete per via meant a full trip re-route
|
||||
// between each of them, so undoing a detour with three vias drew three routes.
|
||||
//
|
||||
// Reported like every other write in this hook. Swallowing it closed the
|
||||
// picker on a leg that still carries its via and still routes the old way,
|
||||
// so the traveller believed they had undone the detour — and because the
|
||||
// request failed, not even the reload ran to contradict them.
|
||||
if (dayIndex >= 0) {
|
||||
try {
|
||||
await roadtripVias.addMany(stop!.ownerDayId, [], [dayIndex])
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
return
|
||||
}
|
||||
}
|
||||
if (offer.current) { routeAlternatives.close(); return }
|
||||
// One choice at a time: the map line can be clicked while a chip's choice is checked.
|
||||
if (alternativesBusy(open)) return
|
||||
// Vias are written online only, so a choice that could not be kept is not checked.
|
||||
if (!roadtripVias.editable) { toast.error(t('roadtrip.alt.offline')); return }
|
||||
|
||||
const signal = routeAlternatives.prove(index)
|
||||
let proof: PinProof
|
||||
try {
|
||||
proof = await pinAlternative({ offer, current: open.routes.find(r => r.current), route: open.route, signal })
|
||||
} catch {
|
||||
if (signal.aborted) return
|
||||
routeAlternatives.settle()
|
||||
toast.error(t('roadtrip.alt.failed'))
|
||||
return
|
||||
}
|
||||
if (signal.aborted) return
|
||||
if (!proof.held) {
|
||||
routeAlternatives.settle()
|
||||
toast.error(t(proof.fellBack ? 'roadtrip.alt.failed' : 'roadtrip.alt.notHeld'), 6000)
|
||||
// The one refusal with a way out: a crossing by ferry is a booking, and the drive
|
||||
// follows a booked ferry from terminal to terminal.
|
||||
if (!proof.fellBack && offer.hasFerry && !proof.last.hasFerry) toast.info(t('roadtrip.alt.ferryNotHeld'), 8000)
|
||||
return
|
||||
}
|
||||
|
||||
// The chain may have moved while the router was asked: a stop dragged, a collaborator's
|
||||
// edit arriving. Pins worked out for this leg are only written where it still runs.
|
||||
const { anchor, ends } = open
|
||||
const leg = railLegAt(railDaysRef.current, anchor)
|
||||
if (!leg || leg.from.assignmentId !== ends.from || leg.to.assignmentId !== ends.to) {
|
||||
toast.error(t('roadtrip.alt.legChanged'), 6000)
|
||||
routeAlternatives.close()
|
||||
return
|
||||
}
|
||||
const day = roadtripRoutes.days.find(d => d.dayId === open.dayId)
|
||||
const stop = day?.stops[open.index]
|
||||
if (!day || !stop) return
|
||||
const ownerDayId = stop.ownerDayId ?? day.dayId
|
||||
const legIndex = stop.ownerIndex ?? open.index
|
||||
try {
|
||||
// Replaces the leg rather than appending to it. The alternatives under the
|
||||
// button were computed for the two bare endpoints — the road already being
|
||||
// driven is offered separately as `current` — so a leg that already carries
|
||||
// a via cannot produce the line the preview drew. Appending put the new
|
||||
// point behind the old one and routed A, south to the old via, north to the
|
||||
// new one, then B: a zigzag matching neither the preview nor the distance
|
||||
// printed on it. Same write the direct branch above already uses.
|
||||
await roadtripVias.addMany(
|
||||
ownerDayId,
|
||||
[{ after_order_index: legIndex, lat: alt.divergence.lat, lng: alt.divergence.lng }],
|
||||
[legIndex],
|
||||
)
|
||||
routeAlternatives.close()
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
// The router's own road on a leg nothing bends is already what is driven.
|
||||
const bent = viasLeaving(leg.from, roadtripVias.byDay[anchor.dayId] ?? []).length > 0
|
||||
if (proof.pins.length || bent) {
|
||||
try {
|
||||
await roadtripVias.addMany(
|
||||
anchor.dayId,
|
||||
proof.pins.map(pin => ({ after_order_index: anchor.afterIndex, lat: pin.lat, lng: pin.lng })),
|
||||
[anchor.afterIndex],
|
||||
)
|
||||
} catch (err: unknown) {
|
||||
// Said even when the picker has moved on in the meantime: the write was asked for.
|
||||
if (!signal.aborted) routeAlternatives.settle()
|
||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
return
|
||||
}
|
||||
}
|
||||
}, [routeAlternatives, roadtripRoutes.days, roadtripVias, toast, t])
|
||||
// A picker opened on another leg while this was written belongs to that leg now.
|
||||
if (!signal.aborted) routeAlternatives.close()
|
||||
}, [routeAlternatives, roadtripVias, toast, t])
|
||||
|
||||
/**
|
||||
* A click on the drawn route puts a via there, and the drive is redrawn through it.
|
||||
|
||||
+7
-1
@@ -301,8 +301,14 @@ export interface RouteWithLegs {
|
||||
* banning it, so a drive with no untolled connection comes back on a toll road and
|
||||
* says so. Present only on a route that was asked to avoid something, so `undefined`
|
||||
* means the question was never put rather than "avoided nothing".
|
||||
*
|
||||
* `fellBack` is set when the engine that weighs classes away could not answer and OSRM
|
||||
* drove the request unweighted instead. Nothing was avoided then, so `achieved` is
|
||||
* empty, the same reading the server's planner gives such a leg.
|
||||
*/
|
||||
avoidance?: { asked: RouteAvoidClass[]; achieved: RouteAvoidClass[] }
|
||||
avoidance?: { asked: RouteAvoidClass[]; achieved: RouteAvoidClass[]; fellBack?: boolean }
|
||||
/** Whether the road crosses by ferry, where the engine that drew it says so. */
|
||||
hasFerry?: boolean
|
||||
}
|
||||
|
||||
/** A road class a route can be asked to leave out. */
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { vi } from 'vitest'
|
||||
import type { LegAlternatives } from '../../src/components/Roadtrip/useRouteAlternatives'
|
||||
|
||||
/**
|
||||
* A picker open on one leg, answered, idle and priced by OSRM unless `over` says otherwise.
|
||||
*
|
||||
* The anchor follows the leg it is given, the way a card whose stops are stored on its own
|
||||
* day files them. A case about a leg stored elsewhere says so itself.
|
||||
*/
|
||||
export function openLeg(over: Partial<LegAlternatives> = {}): LegAlternatives {
|
||||
return {
|
||||
dayId: 2,
|
||||
index: 0,
|
||||
routes: [],
|
||||
loading: false,
|
||||
error: false,
|
||||
proving: null,
|
||||
anchor: { dayId: over.dayId ?? 2, afterIndex: over.index ?? 0 },
|
||||
ends: { from: 1, to: 2 },
|
||||
engine: 'osrm',
|
||||
route: vi.fn(),
|
||||
...over,
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import type { Day, Place, Reservation } from '../../../../src/types'
|
||||
import { visibleRouteReservations, type RouteVisibilityOptions } from '../../../../src/utils/reservationRoutes'
|
||||
import type { AlternativeOverlay } from '../../../../src/components/Roadtrip/alternativeOverlays'
|
||||
import type { LegAlternatives } from '../../../../src/components/Roadtrip/useRouteAlternatives'
|
||||
import { openLeg } from '../../../helpers/legAlternatives'
|
||||
import { RT_ALT_BAR_LIFT } from '../../../../src/mobile/screens/trip/roadtrip/useMRtAlternatives'
|
||||
|
||||
// FE-MOB-MAPAREA-001 to FE-MOB-MAPAREA-042
|
||||
@@ -144,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 = { dayId: 3, index: 0, routes: [], loading: overlays.length === 0, error: false }
|
||||
const open: LegAlternatives = openLeg({ dayId: 3, index: 0, loading: overlays.length === 0 })
|
||||
return {
|
||||
routeAlternatives: { ...base.routeAlternatives, open },
|
||||
alternativeOverlays: overlays,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/
|
||||
import type { Day, Place, Reservation } from '../../../../src/types'
|
||||
import type { CarrierTerminal, RoadtripDay, RoadtripRoutes, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
|
||||
import type { LegAlternatives } from '../../../../src/components/Roadtrip/useRouteAlternatives'
|
||||
import { openLeg } from '../../../helpers/legAlternatives'
|
||||
|
||||
// FE-MOB-RTTAB-001 to FE-MOB-RTTAB-057
|
||||
//
|
||||
@@ -624,7 +625,7 @@ describe('MRoadtripTab', () => {
|
||||
function withPicker(open: Partial<LegAlternatives>, over: Partial<TripPlanner> = {}): TripPlanner {
|
||||
const base = buildPlanner()
|
||||
return planner({
|
||||
routeAlternatives: { ...base.routeAlternatives, open: { dayId: 2, index: 0, routes: [], loading: true, error: false, ...open } },
|
||||
routeAlternatives: { ...base.routeAlternatives, open: openLeg({ dayId: 2, index: 0, loading: true, ...open }) },
|
||||
...over,
|
||||
})
|
||||
}
|
||||
@@ -669,7 +670,7 @@ describe('MRoadtripTab', () => {
|
||||
expect(p.refuel.close).toHaveBeenCalledTimes(1)
|
||||
// Day 2, the card: the leg's first stop is stored on day 1, and the planner works
|
||||
// that out from the card itself, the way the desk rail asks.
|
||||
expect(p.askRouteAlternatives).toHaveBeenCalledWith(2, 0)
|
||||
expect(p.askRouteAlternatives).toHaveBeenCalledWith(2, { kind: 'leg', index: 0 })
|
||||
expect(p.askRouteAlternatives).not.toHaveBeenCalledWith(1, expect.anything())
|
||||
expect(shell.toggleRtView).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
@@ -6,8 +6,9 @@ import { RT_ALT_BAR_HEIGHT, useMRtAlternatives } from '../../../../src/mobile/sc
|
||||
import { buildAlternativeOverlays } from '../../../../src/components/Roadtrip/alternativeOverlays'
|
||||
import type { LegAlternatives, OfferedRoute } from '../../../../src/components/Roadtrip/useRouteAlternatives'
|
||||
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import { openLeg } from '../../../helpers/legAlternatives'
|
||||
|
||||
// FE-MOB-RTALT-001 to FE-MOB-RTALT-011
|
||||
// FE-MOB-RTALT-001 to FE-MOB-RTALT-012
|
||||
//
|
||||
// Rendered through the real controller hook, so a tap is checked all the way to what the
|
||||
// planner is asked to do, and the pending state is the transition's own.
|
||||
@@ -30,7 +31,7 @@ function overlays(routes: OfferedRoute[] = ROUTES) {
|
||||
|
||||
function planner(open: Partial<LegAlternatives> | null, over: Partial<TripPlanner> = {}): TripPlanner {
|
||||
const base = buildPlanner()
|
||||
const leg = open ? { dayId: 2, index: 0, routes: ROUTES, loading: false, error: false, ...open } : null
|
||||
const leg = open ? openLeg({ dayId: 2, index: 0, routes: ROUTES, ...open }) : null
|
||||
return buildPlanner({
|
||||
selectedDayId: 2,
|
||||
routeAlternatives: { ...base.routeAlternatives, open: leg },
|
||||
@@ -201,4 +202,16 @@ describe('MRtAlternativesBar', () => {
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
expect(chips()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-MOB-RTALT-012: while the planner checks a road with the router, the bar is busy and takes nothing', () => {
|
||||
// The check asks the router up to three times before anything is written, so the bar
|
||||
// has to stand still for all of it, not only for the write at the end.
|
||||
renderBar(planner({ proving: 2 }, { highlightedAlternative: 2 }))
|
||||
|
||||
for (const chip of chips()) expect(chip).toBeDisabled()
|
||||
expect(chips()[0].parentElement).toHaveAttribute('aria-busy', 'true')
|
||||
expect(confirmButton()).toBeDisabled()
|
||||
expect(confirmButton()).toHaveAttribute('aria-busy', 'true')
|
||||
expect(confirmButton().querySelector('.animate-spin')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,8 +7,9 @@ import {
|
||||
import { buildAlternativeOverlays } from '../../../../src/components/Roadtrip/alternativeOverlays'
|
||||
import type { LegAlternatives, OfferedRoute } from '../../../../src/components/Roadtrip/useRouteAlternatives'
|
||||
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import { openLeg } from '../../../helpers/legAlternatives'
|
||||
|
||||
// FE-MOB-RTALTH-001 to FE-MOB-RTALTH-009
|
||||
// FE-MOB-RTALTH-001 to FE-MOB-RTALTH-010
|
||||
//
|
||||
// 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.
|
||||
@@ -25,7 +26,7 @@ const OVERLAYS = buildAlternativeOverlays(ROUTES, {
|
||||
})
|
||||
|
||||
function leg(over: Partial<LegAlternatives> = {}): LegAlternatives {
|
||||
return { dayId: 2, index: 1, routes: ROUTES, loading: false, error: false, ...over }
|
||||
return openLeg({ dayId: 2, index: 1, routes: ROUTES, ...over })
|
||||
}
|
||||
|
||||
/** A planner with the picker open on leg 1 of day 2, answered with three roads. */
|
||||
@@ -101,7 +102,7 @@ describe('useMRtAlternatives', () => {
|
||||
|
||||
expect(p.setHighlightedAlternative).toHaveBeenCalledWith(null)
|
||||
expect(p.refuel.close).toHaveBeenCalledTimes(1)
|
||||
expect(p.askRouteAlternatives).toHaveBeenCalledWith(2, 0)
|
||||
expect(p.askRouteAlternatives).toHaveBeenCalledWith(2, { kind: 'leg', index: 0 })
|
||||
// The pick is cleared before the new question goes out.
|
||||
const cleared = vi.mocked(p.setHighlightedAlternative).mock.invocationCallOrder[0]
|
||||
expect(cleared).toBeLessThan(vi.mocked(p.askRouteAlternatives).mock.invocationCallOrder[0])
|
||||
@@ -111,7 +112,7 @@ describe('useMRtAlternatives', () => {
|
||||
const onMap = buildShell({ rtView: 'map' })
|
||||
const second = setup(planner({}, null), onMap)
|
||||
act(() => { second.result.current.ask(2, 0) })
|
||||
expect(second.planner.askRouteAlternatives).toHaveBeenCalledWith(2, 0)
|
||||
expect(second.planner.askRouteAlternatives).toHaveBeenCalledWith(2, { kind: 'leg', index: 0 })
|
||||
expect(onMap.toggleRtView).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -195,6 +196,18 @@ describe('useMRtAlternatives', () => {
|
||||
expect(viewer.result.current.canAsk).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-MOB-RTALTH-010: a check the planner is running keeps the phone busy, whoever started it', () => {
|
||||
// The desk's map line and the phone's confirm lead to the same check. The planner marks
|
||||
// it on the picker, and the phone reads that mark rather than only its own transition.
|
||||
const p = planner({ highlightedAlternative: 1 }, leg({ proving: 1 }))
|
||||
const { result } = setup(p)
|
||||
|
||||
expect(result.current.saving).toBe(true)
|
||||
expect(result.current.canConfirm).toBe(false)
|
||||
act(() => { result.current.confirm() })
|
||||
expect(p.chooseRouteAlternative).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user