mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
64f370fd0e
A PDF of a long trip routes for minutes, and only the skip button could stop it. Closing the export dialog, hiding the phone sheet or leaving the trip left the legs going out and opened the document later on whatever page the user was on. Closing now stops the routing and the document never opens; skipping still prints without the remaining legs. The plan filter moves from TripPDF to pdfScope beside the other plan predicate, which keeps TripPDF under its size entry. The routing test name gets its quotes back, and the export dialog and sheet tests cover the leg count, the skip button and closing while routing.
288 lines
14 KiB
TypeScript
288 lines
14 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { dayColor } from '../Roadtrip/dayColors'
|
|
import { dayRouteColor, planTripRoute, routeTrip, summariseTripRoute } from './tripRouteGeometry'
|
|
import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories'
|
|
import type { Accommodation, AssignmentsMap, Reservation, RouteSegment } from '../../types'
|
|
|
|
vi.mock('./RouteCalculator', async (importActual) => {
|
|
const actual = await importActual<typeof import('./RouteCalculator')>()
|
|
return { ...actual, calculateRouteWithLegs: vi.fn() }
|
|
})
|
|
|
|
const { calculateRouteWithLegs, RoutingRefusedError } = await import('./RouteCalculator')
|
|
|
|
const leg = (distance: number): RouteSegment => ({
|
|
mid: [0, 0], from: [0, 0], to: [0, 0], distance, duration: 600,
|
|
distanceText: '10 km', durationText: '10 min', walkingText: '2 h', drivingText: '10 min',
|
|
})
|
|
|
|
/** A router that never answers but honours its signal, exactly as a real fetch does —
|
|
* without that the abort has nothing to cut and the pool waits forever. */
|
|
const neverAnswers = () => vi.mocked(calculateRouteWithLegs).mockImplementation(
|
|
(_waypoints, opts) => new Promise((_resolve, reject) => {
|
|
opts?.signal?.addEventListener(
|
|
'abort',
|
|
() => reject(new DOMException('Aborted', 'AbortError')),
|
|
{ once: true },
|
|
)
|
|
}) as ReturnType<typeof calculateRouteWithLegs>,
|
|
)
|
|
|
|
const at = (lat: number, lng: number, order: number, dayId: number) =>
|
|
buildAssignment({ day_id: dayId, order_index: order, place: buildPlace({ lat, lng }) })
|
|
|
|
const DAYS = [buildDay({ id: 1, day_number: 1 }), buildDay({ id: 2, day_number: 2 })]
|
|
const ASSIGNMENTS: AssignmentsMap = {
|
|
'1': [at(48.86, 2.35, 0, 1), at(48.90, 2.42, 1, 1)],
|
|
'2': [at(45.76, 4.83, 0, 2), at(45.80, 4.90, 1, 2)],
|
|
}
|
|
const input = { days: DAYS, assignments: ASSIGNMENTS, reservations: [], accommodations: [], optimizeFromAccommodation: false }
|
|
|
|
beforeEach(() => {
|
|
vi.mocked(calculateRouteWithLegs).mockReset()
|
|
vi.mocked(calculateRouteWithLegs).mockResolvedValue({
|
|
coordinates: [[48.86, 2.35], [48.90, 2.42]],
|
|
distance: 10000, duration: 600,
|
|
legs: [leg(10000)],
|
|
})
|
|
})
|
|
|
|
describe('tripRouteGeometry', () => {
|
|
it('FE-MAP-TRG-001: plans one request per day and skips days with nothing to drive', () => {
|
|
const plan = planTripRoute({ ...input, assignments: { ...ASSIGNMENTS, '2': [at(45.76, 4.83, 0, 2)] } }, 'driving')
|
|
|
|
expect(plan).toHaveLength(1)
|
|
expect(plan[0].day.id).toBe(1)
|
|
expect(plan[0].runs[0]).toHaveLength(1)
|
|
})
|
|
|
|
it('FE-MAP-TRG-002: a day is drawn in the colour the road trip gives it', () => {
|
|
// One palette for both readings of the trip, so a day never changes colour.
|
|
expect(dayRouteColor(buildDay({ day_number: 1 }))).toEqual(dayColor(1))
|
|
expect(dayRouteColor(buildDay({ day_number: 2 }))).toEqual(dayColor(2))
|
|
})
|
|
|
|
it('FE-MAP-TRG-003: routes every day and sums the trip', async () => {
|
|
const summary = await routeTrip(input, { profile: 'driving', tripId: 7 })
|
|
|
|
expect(summary.days).toHaveLength(2)
|
|
expect(summary.totalDistance).toBe(20000)
|
|
expect(summary.lines).toHaveLength(2)
|
|
expect(summary.lineColors).toHaveLength(2)
|
|
})
|
|
|
|
it('FE-MAP-TRG-004: an empty trip summarises to nothing rather than throwing', async () => {
|
|
const summary = await routeTrip({ ...input, assignments: {} }, { profile: 'driving', tripId: 7 })
|
|
|
|
expect(summary).toEqual(summariseTripRoute([]))
|
|
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
|
|
})
|
|
|
|
// The PDF used to route under an 8 s deadline: with a second between two requests that
|
|
// is about seven legs, so a month-long trip printed most of its days as straight lines
|
|
// and a total that looked final, and every export routed a few more days (#2876).
|
|
it('FE-MAP-TRG-005: a long trip is routed to its last leg, and says how far it has got', async () => {
|
|
const days = Array.from({ length: 12 }, (_, i) => buildDay({ id: i + 1, day_number: i + 1 }))
|
|
const assignments: AssignmentsMap = Object.fromEntries(days.map(day => [
|
|
String(day.id),
|
|
[at(48 + day.id / 10, 2.35, 0, day.id), at(48 + day.id / 10, 2.45, 1, day.id)],
|
|
]))
|
|
vi.useFakeTimers()
|
|
try {
|
|
vi.mocked(calculateRouteWithLegs).mockImplementation(() => new Promise(resolve => {
|
|
setTimeout(() => resolve({ coordinates: [[48.86, 2.35], [48.90, 2.42]], distance: 10000, duration: 600, legs: [leg(10000)] }), 100)
|
|
}))
|
|
const onProgress = vi.fn<(done: number, total: number) => void>()
|
|
const pending = routeTrip({ ...input, days, assignments }, { profile: 'driving', tripId: 7, onProgress })
|
|
await vi.advanceTimersByTimeAsync(12 * 1200)
|
|
const summary = await pending
|
|
|
|
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(12)
|
|
expect(summary.unroutedLegs).toBe(0)
|
|
expect(summary.totalDistance).toBe(120000)
|
|
expect(onProgress.mock.calls[0]).toEqual([0, 12])
|
|
expect(onProgress.mock.calls.map(([done]) => done)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])
|
|
expect(onProgress.mock.calls.every(([, total]) => total === 12)).toBe(true)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it("FE-MAP-TRG-006: a caller's own abort cuts the routing short, and what never answered is counted", async () => {
|
|
neverAnswers()
|
|
const controller = new AbortController()
|
|
const pending = routeTrip(input, { profile: 'driving', tripId: 7, signal: controller.signal })
|
|
controller.abort()
|
|
|
|
await expect(pending).resolves.toMatchObject({ totalDistance: 0, unroutedLegs: 2 })
|
|
})
|
|
|
|
it('FE-MAP-TRG-014: a host that never answers costs one leg its road, not the whole round', async () => {
|
|
neverAnswers()
|
|
vi.useFakeTimers()
|
|
try {
|
|
const pending = routeTrip(input, { profile: 'driving', tripId: 7 })
|
|
await vi.advanceTimersByTimeAsync(30_000)
|
|
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
|
await vi.advanceTimersByTimeAsync(1100 + 30_000)
|
|
const summary = await pending
|
|
|
|
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
|
|
expect(summary.lines).toHaveLength(2)
|
|
expect(summary.unroutedLegs).toBe(2)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
// The public OSRM hosts allow about one request a second. Every leg of every day at
|
|
// once is exactly the burst they answer with 429 from the second second on, and each
|
|
// refused leg used to stay a straight line with no distance and no word about it.
|
|
it('FE-MAP-TRG-007: asks the router one leg at a time, a second apart', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
// A round trip takes time; only a network answer is paced, a cache hit is not.
|
|
vi.mocked(calculateRouteWithLegs).mockImplementation(() => new Promise(resolve => {
|
|
setTimeout(() => resolve({ coordinates: [[48.86, 2.35], [48.90, 2.42]], distance: 10000, duration: 600, legs: [leg(10000)] }), 100)
|
|
}))
|
|
const pending = routeTrip(input, { profile: 'driving', tripId: 7 })
|
|
|
|
// The second day waits for the first to answer...
|
|
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
|
await vi.advanceTimersByTimeAsync(100)
|
|
// ...and then for the gap the shared hosts need between two requests.
|
|
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
|
await vi.advanceTimersByTimeAsync(1100)
|
|
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
|
|
|
|
await vi.advanceTimersByTimeAsync(100)
|
|
const summary = await pending
|
|
expect(summary.totalDistance).toBe(20000)
|
|
expect(summary.unroutedLegs).toBe(0)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('FE-MAP-TRG-008: a rate limit is waited out and the leg asked for again', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
vi.mocked(calculateRouteWithLegs)
|
|
.mockRejectedValueOnce(new RoutingRefusedError(429, null))
|
|
.mockResolvedValue({ coordinates: [[48.86, 2.35], [48.90, 2.42]], distance: 10000, duration: 600, legs: [leg(10000)] })
|
|
const pending = routeTrip(input, { profile: 'driving', tripId: 7 })
|
|
|
|
await vi.advanceTimersByTimeAsync(1500)
|
|
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
|
|
await vi.advanceTimersByTimeAsync(1100)
|
|
const summary = await pending
|
|
|
|
// Both days routed: the refused leg answered on its second try.
|
|
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(3)
|
|
expect(summary.totalDistance).toBe(20000)
|
|
expect(summary.unroutedLegs).toBe(0)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('FE-MAP-TRG-009: a host that says how long to wait is given that long', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
vi.mocked(calculateRouteWithLegs)
|
|
.mockRejectedValueOnce(new RoutingRefusedError(429, 6000))
|
|
.mockResolvedValue({ coordinates: [[48.86, 2.35], [48.90, 2.42]], distance: 10000, duration: 600, legs: [leg(10000)] })
|
|
const pending = routeTrip(input, { profile: 'driving', tripId: 7 })
|
|
|
|
// The own first backoff would be 1500 ms; asking then is just a second refusal.
|
|
await vi.advanceTimersByTimeAsync(2000)
|
|
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
|
await vi.advanceTimersByTimeAsync(4000)
|
|
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
|
|
|
|
await vi.advanceTimersByTimeAsync(1100)
|
|
await expect(pending).resolves.toMatchObject({ totalDistance: 20000 })
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('FE-MAP-TRG-010: a refusal the host meant is not repeated, and the leg is counted as unrouted', async () => {
|
|
// A 400 objects to these coordinates; the same request earns the same answer.
|
|
vi.mocked(calculateRouteWithLegs)
|
|
.mockRejectedValueOnce(new RoutingRefusedError(400, null))
|
|
.mockResolvedValue({ coordinates: [[45.76, 4.83], [45.80, 4.90]], distance: 10000, duration: 600, legs: [leg(10000)] })
|
|
|
|
const summary = await routeTrip(input, { profile: 'driving', tripId: 7 })
|
|
|
|
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
|
|
// The first day keeps its straight line and adds nothing to the sum...
|
|
expect(summary.lines[0]).toEqual([[48.86, 2.35], [48.90, 2.42]])
|
|
expect(summary.days[0].distance).toBe(0)
|
|
expect(summary.totalDistance).toBe(10000)
|
|
// ...and says so, per day and for the trip, so the total is not mistaken for complete.
|
|
expect(summary.days.map(d => d.unroutedLegs)).toEqual([1, 0])
|
|
expect(summary.unroutedLegs).toBe(1)
|
|
})
|
|
|
|
it('FE-MAP-TRG-011: a rate limit that never lifts gives up after the retries and is counted', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
vi.mocked(calculateRouteWithLegs).mockRejectedValue(new RoutingRefusedError(429, null))
|
|
const pending = routeTrip(input, { profile: 'driving', tripId: 7 })
|
|
|
|
// Two backoffs per leg, a gap between the legs, then it stops asking.
|
|
await vi.advanceTimersByTimeAsync(20000)
|
|
const summary = await pending
|
|
|
|
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(6)
|
|
expect(summary.totalDistance).toBe(0)
|
|
expect(summary.unroutedLegs).toBe(2)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('FE-MAP-TRG-012: the overview draws no drive between two stays on the day a flight connects them (#2476)', () => {
|
|
// Day 2 moves from a Munich hotel to a Hamburg one. The overview reads the same
|
|
// day plan as the map, so the flight saved without airports keeps the road off it.
|
|
const days = [1, 2, 3].map(n => buildDay({ id: n, day_number: n }))
|
|
const accommodations = [
|
|
{ id: 1, trip_id: 1, place_lat: 48.137, place_lng: 11.575, start_day_id: 1, end_day_id: 2 },
|
|
{ id: 2, trip_id: 1, place_lat: 53.551, place_lng: 9.993, start_day_id: 2, end_day_id: 3 },
|
|
] as unknown as Accommodation[]
|
|
const flight = {
|
|
id: 80, trip_id: 1, type: 'flight', title: 'LH 2078', day_id: 2, end_day_id: 2,
|
|
reservation_time: '2026-11-04T15:15', reservation_end_time: '2026-11-04T17:20', endpoints: [],
|
|
} as unknown as Reservation
|
|
const moving = { days, assignments: {}, accommodations, optimizeFromAccommodation: true }
|
|
|
|
expect(planTripRoute({ ...moving, reservations: [flight] }, 'driving')).toEqual([])
|
|
// Without the booking the day is a drive from one hotel to the other, as before (#1297).
|
|
const byRoad = planTripRoute({ ...moving, reservations: [] }, 'driving')
|
|
expect(byRoad.map(d => d.day.id)).toEqual([2])
|
|
})
|
|
|
|
it('FE-MAP-TRG-013: a road picked in the road trip bends the overview and still counts as one leg (#2580)', async () => {
|
|
// A lodging row hides in the plan but counts in the stored list, so the via behind
|
|
// the first stop of day 1 is filed at 1.
|
|
const hotel = buildAssignment({ day_id: 1, order_index: -1, accommodation_id: 3, place: buildPlace({ lat: 48.8, lng: 2.3 }) })
|
|
const storedAssignments = { ...ASSIGNMENTS, '1': [hotel, ...ASSIGNMENTS['1']] }
|
|
const viasByDay = { 1: [{ id: 1, day_id: 1, after_order_index: 1, sequence: 0, lat: 48.95, lng: 2.6, created_at: '' }] }
|
|
vi.mocked(calculateRouteWithLegs).mockImplementation(async (points) => ({
|
|
coordinates: points.map(p => [p.lat, p.lng] as [number, number]),
|
|
distance: 0, duration: 0,
|
|
legs: points.slice(1).map(() => leg(10000)),
|
|
}))
|
|
|
|
const plan = planTripRoute({ ...input, storedAssignments, viasByDay }, 'driving')
|
|
expect(plan[0].runs[0][0].waypoints.map(p => p.lat)).toEqual([48.86, 48.95, 48.90])
|
|
expect(plan[1].runs[0][0].waypoints.map(p => p.lat)).toEqual([45.76, 45.80])
|
|
|
|
const summary = await routeTrip({ ...input, storedAssignments, viasByDay }, { profile: 'driving', tripId: 7, unit: 'metric' })
|
|
expect(summary.days[0].segments).toHaveLength(1)
|
|
expect(summary.days[0].distance).toBe(20000)
|
|
expect(summary.days[1].distance).toBe(10000)
|
|
})
|
|
})
|