From 7b023c6b54a4e6d93b8441ecb39abf71b87a7592 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 12 Oct 2026 21:14:05 +0000 Subject: [PATCH] refactor(map): the road trip via helpers move to utils, so the route hook imports no component --- client/src/components/Map/dayRouteLeg.test.ts | 99 +++++++++++++++++++ client/src/components/Map/dayRouteLeg.ts | 29 ++++++ .../src/components/Map/tripRouteGeometry.ts | 2 +- .../src/components/Planner/DayPlanSidebar.tsx | 3 +- .../components/Roadtrip/useRoadtripRoutes.ts | 2 +- client/src/hooks/useRouteCalculation.ts | 2 +- .../src/pages/tripPlanner/useRoadtripFeed.ts | 2 +- .../Map => utils}/dayRouteVias.test.ts | 65 +----------- .../{components/Map => utils}/dayRouteVias.ts | 29 +----- 9 files changed, 139 insertions(+), 94 deletions(-) create mode 100644 client/src/components/Map/dayRouteLeg.test.ts create mode 100644 client/src/components/Map/dayRouteLeg.ts rename client/src/{components/Map => utils}/dayRouteVias.test.ts (69%) rename client/src/{components/Map => utils}/dayRouteVias.ts (82%) diff --git a/client/src/components/Map/dayRouteLeg.test.ts b/client/src/components/Map/dayRouteLeg.test.ts new file mode 100644 index 000000000..ffe7b7326 --- /dev/null +++ b/client/src/components/Map/dayRouteLeg.test.ts @@ -0,0 +1,99 @@ +import type { RoadtripVia } from '@trek/shared'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { buildAssignment, buildPlace } from '../../../tests/helpers/factories'; +import type { AssignmentsMap, RouteSegment } from '../../types'; +import { dayViaLookup } from '../../utils/dayRouteVias'; +import { routeLeg } from './dayRouteLeg'; + +vi.mock('./RouteCalculator', async (importActual) => { + const actual = await importActual(); + return { ...actual, calculateRouteWithLegs: vi.fn() }; +}); + +const { calculateRouteWithLegs } = await import('./RouteCalculator'); + +const leg = (distance: number): RouteSegment => ({ + mid: [0, 0], + from: [distance, 0], + to: [distance + 1, 0], + distance, + duration: distance / 10, + distanceText: '', + durationText: '', + walkingText: '', + drivingText: '', +}); + +const via = (dayId: number, afterOrderIndex: number, lat: number, sequence = 0): RoadtripVia => + ({ + id: lat, + day_id: dayId, + after_order_index: afterOrderIndex, + sequence, + lat, + lng: 0, + created_at: '', + }) as RoadtripVia; + +const stop = (id: number, order: number, extra: Record = {}) => + buildAssignment({ id, day_id: 1, order_index: order, place: buildPlace({ lat: 48 + order, lng: 2 }), ...extra }); + +// Stored order: hotel row, Paris, a place without coordinates, Lille, Brussels. +const STORED: AssignmentsMap = { + '1': [ + stop(14, 3), + stop(11, 1), + stop(10, 0, { accommodation_id: 5 }), + buildAssignment({ + id: 12, + day_id: 1, + order_index: 2, + place: buildPlace({ lat: null as unknown as number, lng: null as unknown as number }), + }), + stop(13, 2.5), + ], +}; + +beforeEach(() => { + vi.mocked(calculateRouteWithLegs).mockReset(); +}); + +describe('dayRouteLeg (#2580)', () => { + it('FE-MAP-DRV-009: one leg is routed through its vias and read back as one leg in its mode', async () => { + vi.mocked(calculateRouteWithLegs).mockResolvedValue({ + coordinates: [], + distance: 0, + duration: 0, + legs: [leg(1000), leg(2000)], + }); + const lookup = dayViaLookup(STORED, { 1: [via(1, 1, 49.3)] }); + const options = { profile: 'driving', tripId: 7, dayId: 1 }; + + const seg = await routeLeg( + { lat: 49, lng: 2, assignmentId: 11 }, + { lat: 50.5, lng: 2, assignmentId: 13 }, + lookup, + options, + 'metric' + ); + + expect(vi.mocked(calculateRouteWithLegs).mock.calls[0][0]).toEqual([ + { lat: 49, lng: 2 }, + { lat: 49.3, lng: 0 }, + { lat: 50.5, lng: 2 }, + ]); + expect(seg?.distance).toBe(3000); + expect(seg?.mode).toBe('driving'); + }); + + it('FE-MAP-DRV-010: a leg the router cannot answer is undefined', async () => { + vi.mocked(calculateRouteWithLegs).mockRejectedValueOnce(new Error('down')); + expect( + await routeLeg({ lat: 1, lng: 1 }, { lat: 2, lng: 2 }, null, { profile: 'driving', dayId: 1 }, 'metric') + ).toBeUndefined(); + vi.mocked(calculateRouteWithLegs).mockResolvedValueOnce({ coordinates: [], distance: 0, duration: 0, legs: [] }); + expect( + await routeLeg({ lat: 1, lng: 1 }, { lat: 2, lng: 2 }, null, { profile: 'driving', dayId: 1 }, 'metric') + ).toBeUndefined(); + }); +}); diff --git a/client/src/components/Map/dayRouteLeg.ts b/client/src/components/Map/dayRouteLeg.ts new file mode 100644 index 000000000..4f420c584 --- /dev/null +++ b/client/src/components/Map/dayRouteLeg.ts @@ -0,0 +1,29 @@ +import type { DistanceUnit, RouteSegment } from '../../types'; +import { foldViaLegs, viasOnDayLeg, type DayViaLookup } from '../../utils/dayRouteVias'; +import { calculateRouteWithLegs } from './RouteCalculator'; + +/** One end of a leg: a planned stop carries its assignment, which the vias are filed against. */ +export type LegEnd = { lat: number; lng: number; assignmentId?: number }; + +/** + * The drive between two points of a day as one leg, bent through the road trip's vias + * between them and tagged with the mode it was routed in. Undefined when the router has + * no road for it. + */ +export async function routeLeg( + from: LegEnd, + to: LegEnd, + lookup: DayViaLookup | null, + options: Parameters[1] & { profile: string; dayId: number }, + unit: DistanceUnit | undefined +): Promise { + const vias = viasOnDayLeg(lookup, options.dayId, options.profile, from, to); + try { + const waypoints = [from, ...vias, to].map((p) => ({ lat: p.lat, lng: p.lng })); + const r = await calculateRouteWithLegs(waypoints, options); + const leg = foldViaLegs(r.legs, [0, vias.length + 1], unit)[0]; + return leg ? { ...leg, mode: options.profile } : undefined; + } catch { + return undefined; + } +} diff --git a/client/src/components/Map/tripRouteGeometry.ts b/client/src/components/Map/tripRouteGeometry.ts index afa5dd228..36017c598 100644 --- a/client/src/components/Map/tripRouteGeometry.ts +++ b/client/src/components/Map/tripRouteGeometry.ts @@ -1,6 +1,6 @@ import { calculateRouteWithLegs, RoutingRefusedError, type RouteProfileKey } from './RouteCalculator' import { buildDayRouteRuns, type DayRouteInputs, type DayRoutePoint } from './dayRoutePlan' -import { dayViaLookup, foldViaLegs, threadVias, viasOnDayLeg, type DayViaLookup } from './dayRouteVias' +import { dayViaLookup, foldViaLegs, threadVias, viasOnDayLeg, type DayViaLookup } from '../../utils/dayRouteVias' import { projectDayItinerary } from './dayTourProjection' import { resolveLegMode } from '../Planner/legMode' import { dayColor } from '../Roadtrip/dayColors' diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 49a8f2629..8c0e4b75f 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -11,7 +11,8 @@ import { ChevronDown, ChevronRight, ChevronUp, Compass, RotateCcw, ExternalLink, import { type PickedPlace } from './TransitSearchPanel' import { buildTransitLeg, buildTransitNameIndex } from './transitLeg' import { optimizeRoute, generateGoogleMapsUrl, generateCoMapsUrl } from '../Map/RouteCalculator' -import { dayViaLookup, routeLeg, type LegEnd, type ViasByDay } from '../Map/dayRouteVias' +import { dayViaLookup, type ViasByDay } from '../../utils/dayRouteVias' +import { routeLeg, type LegEnd } from '../Map/dayRouteLeg' import GoogleMapsIcon from '../shared/GoogleMapsIcon' import PlaceAvatar from '../shared/PlaceAvatar' import ConfirmDialog from '../shared/ConfirmDialog' diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.ts b/client/src/components/Roadtrip/useRoadtripRoutes.ts index bcc43bce8..87011f441 100644 --- a/client/src/components/Roadtrip/useRoadtripRoutes.ts +++ b/client/src/components/Roadtrip/useRoadtripRoutes.ts @@ -14,7 +14,7 @@ import { dayWindow } from './dayWindow' import { useTranslation } from '../../i18n/TranslationContext' import { stayStartingOn } from '../../utils/dayMerge' import { staysAtTheirPlaces } from './nightBookend' -import { viaKeyOf } from '../Map/dayRouteVias' +import { viaKeyOf } from '../../utils/dayRouteVias' export type { RoadtripStop, RoadtripDay, RoadtripRoutes, QuietDay, AccessSpur, RoutedLeg, SnappedPoint } from '@trek/shared/roadtrip' diff --git a/client/src/hooks/useRouteCalculation.ts b/client/src/hooks/useRouteCalculation.ts index a044a8b1a..94d2979ad 100644 --- a/client/src/hooks/useRouteCalculation.ts +++ b/client/src/hooks/useRouteCalculation.ts @@ -6,7 +6,7 @@ import { useSettingsStore } from '../store/settingsStore' import { useAddonStore } from '../store/addonStore' import { calculateRouteWithLegs, type RouteProfileKey } from '../components/Map/RouteCalculator' import { buildDayRouteRuns, hotelBookendOf, TRANSPORT_TYPES } from '../components/Map/dayRoutePlan' -import { dayViaLookup, foldViaLegs, threadVias, viaKeyOf, viasOnDayLeg } from '../components/Map/dayRouteVias' +import { dayViaLookup, foldViaLegs, threadVias, viaKeyOf, viasOnDayLeg } from '../utils/dayRouteVias' import { resolveLegMode } from '../components/Planner/legMode' import type { RoadtripVia } from '@trek/shared' import type { TripStoreState } from '../store/tripStore' diff --git a/client/src/pages/tripPlanner/useRoadtripFeed.ts b/client/src/pages/tripPlanner/useRoadtripFeed.ts index b8fd91c70..ed9071e56 100644 --- a/client/src/pages/tripPlanner/useRoadtripFeed.ts +++ b/client/src/pages/tripPlanner/useRoadtripFeed.ts @@ -14,7 +14,7 @@ import { useRefuelSearch } from '../../components/Roadtrip/useRefuelSearch' import { useRoadtripCorridor } from '../../components/Roadtrip/useRoadtripCorridor' import { useRoadtripRoutes } from '../../components/Roadtrip/useRoadtripRoutes' import type { RoadtripVias } from '../../components/Roadtrip/useRoadtripVias' -import { roadtripStopList } from '../../components/Map/dayRouteVias' +import { roadtripStopList } from '../../utils/dayRouteVias' import { useLoadRoadtripSettings } from '../../hooks/useRoadtripSettings' import { roadtripPreferencesRepo } from '../../repo/roadtripPreferencesRepo' import { useAuthStore } from '../../store/authStore' diff --git a/client/src/components/Map/dayRouteVias.test.ts b/client/src/utils/dayRouteVias.test.ts similarity index 69% rename from client/src/components/Map/dayRouteVias.test.ts rename to client/src/utils/dayRouteVias.test.ts index 99548dbba..e8b00c59c 100644 --- a/client/src/components/Map/dayRouteVias.test.ts +++ b/client/src/utils/dayRouteVias.test.ts @@ -1,23 +1,8 @@ import type { RoadtripVia } from '@trek/shared'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { buildAssignment, buildPlace } from '../../../tests/helpers/factories'; -import type { AssignmentsMap, RouteSegment } from '../../types'; -import { - dayViaLookup, - foldViaLegs, - roadtripStopList, - routeLeg, - threadVias, - viaKeyOf, - viasOnDayLeg, -} from './dayRouteVias'; - -vi.mock('./RouteCalculator', async (importActual) => { - const actual = await importActual(); - return { ...actual, calculateRouteWithLegs: vi.fn() }; -}); - -const { calculateRouteWithLegs } = await import('./RouteCalculator'); +import { describe, expect, it } from 'vitest'; +import { buildAssignment, buildPlace } from '../../tests/helpers/factories'; +import type { AssignmentsMap, RouteSegment } from '../types'; +import { dayViaLookup, foldViaLegs, roadtripStopList, threadVias, viaKeyOf, viasOnDayLeg } from './dayRouteVias'; const leg = (distance: number): RouteSegment => ({ mid: [0, 0], @@ -68,10 +53,6 @@ function lookupFor(viasByDay: Record) { return lookup; } -beforeEach(() => { - vi.mocked(calculateRouteWithLegs).mockReset(); -}); - describe('dayRouteVias (#2580)', () => { it('FE-MAP-DRV-001: the stop list is the stored order with coordinates, hidden rows included', () => { expect(roadtripStopList(STORED['1']).map((a) => a.id)).toEqual([10, 11, 13, 14]); @@ -144,42 +125,4 @@ describe('dayRouteVias (#2580)', () => { expect(viaKeyOf({ ...a, 1: [via(1, 0, 49.4)] })).not.toBe(viaKeyOf(a)); expect(viaKeyOf(undefined)).toBe(''); }); - - it('FE-MAP-DRV-009: one leg is routed through its vias and read back as one leg in its mode', async () => { - vi.mocked(calculateRouteWithLegs).mockResolvedValue({ - coordinates: [], - distance: 0, - duration: 0, - legs: [leg(1000), leg(2000)], - }); - const lookup = dayViaLookup(STORED, { 1: [via(1, 1, 49.3)] }); - const options = { profile: 'driving', tripId: 7, dayId: 1 }; - - const seg = await routeLeg( - { lat: 49, lng: 2, assignmentId: 11 }, - { lat: 50.5, lng: 2, assignmentId: 13 }, - lookup, - options, - 'metric' - ); - - expect(vi.mocked(calculateRouteWithLegs).mock.calls[0][0]).toEqual([ - { lat: 49, lng: 2 }, - { lat: 49.3, lng: 0 }, - { lat: 50.5, lng: 2 }, - ]); - expect(seg?.distance).toBe(3000); - expect(seg?.mode).toBe('driving'); - }); - - it('FE-MAP-DRV-010: a leg the router cannot answer is undefined', async () => { - vi.mocked(calculateRouteWithLegs).mockRejectedValueOnce(new Error('down')); - expect( - await routeLeg({ lat: 1, lng: 1 }, { lat: 2, lng: 2 }, null, { profile: 'driving', dayId: 1 }, 'metric') - ).toBeUndefined(); - vi.mocked(calculateRouteWithLegs).mockResolvedValueOnce({ coordinates: [], distance: 0, duration: 0, legs: [] }); - expect( - await routeLeg({ lat: 1, lng: 1 }, { lat: 2, lng: 2 }, null, { profile: 'driving', dayId: 1 }, 'metric') - ).toBeUndefined(); - }); }); diff --git a/client/src/components/Map/dayRouteVias.ts b/client/src/utils/dayRouteVias.ts similarity index 82% rename from client/src/components/Map/dayRouteVias.ts rename to client/src/utils/dayRouteVias.ts index 5f8e931e5..2dbc0b7cf 100644 --- a/client/src/components/Map/dayRouteVias.ts +++ b/client/src/utils/dayRouteVias.ts @@ -1,7 +1,6 @@ import type { RoadtripVia } from '@trek/shared'; import { isCarrierMode, mergeRouteSegments, viasOnLeg } from '@trek/shared/roadtrip'; -import type { Assignment, AssignmentsMap, DistanceUnit, RouteSegment } from '../../types'; -import { calculateRouteWithLegs } from './RouteCalculator'; +import type { Assignment, AssignmentsMap, DistanceUnit, RouteSegment } from '../types'; /** * The stops of a day as the road trip counts them, in the order it drives them. @@ -116,29 +115,3 @@ export function foldViaLegs(legs: RouteSegment[], stopAt: number[], unit: Distan } return folded; } - -/** One end of a leg: a planned stop carries its assignment, which the vias are filed against. */ -export type LegEnd = { lat: number; lng: number; assignmentId?: number }; - -/** - * The drive between two points of a day as one leg, bent through the road trip's vias - * between them and tagged with the mode it was routed in. Undefined when the router has - * no road for it. - */ -export async function routeLeg( - from: LegEnd, - to: LegEnd, - lookup: DayViaLookup | null, - options: Parameters[1] & { profile: string; dayId: number }, - unit: DistanceUnit | undefined -): Promise { - const vias = viasOnDayLeg(lookup, options.dayId, options.profile, from, to); - try { - const waypoints = [from, ...vias, to].map((p) => ({ lat: p.lat, lng: p.lng })); - const r = await calculateRouteWithLegs(waypoints, options); - const leg = foldViaLegs(r.legs, [0, vias.length + 1], unit)[0]; - return leg ? { ...leg, mode: options.profile } : undefined; - } catch { - return undefined; - } -}