diff --git a/server/src/nest/roadtrip/roadtrip-plan.service.ts b/server/src/nest/roadtrip/roadtrip-plan.service.ts index 5b714d1f9..abc6f5dbb 100644 --- a/server/src/nest/roadtrip/roadtrip-plan.service.ts +++ b/server/src/nest/roadtrip/roadtrip-plan.service.ts @@ -15,6 +15,9 @@ import { parseClock, effectiveRangeKm, parseAvoid, + formatDistance, + formatDurationShort, + type DistanceUnit, type RoadtripStop, type PlanDay, type RoutedLeg, @@ -133,6 +136,8 @@ export class RoadtripPlanService { if (context.visits.length > 150) throw new HttpException({ error: 'This trip exceeds the 150-visit calculation limit.' }, 400); const asked = new Set(); + const distanceUnit: DistanceUnit = + this.settings.getUserSettings(userId).distance_unit === 'imperial' ? 'imperial' : 'metric'; const fetchRun = async (stops: RoadtripStop[], dayId: number, profile: string) => { const points: { lat: number; lng: number }[] = []; const stopAt: number[] = []; @@ -152,12 +157,28 @@ export class RoadtripPlanService { try { if (points.length > 100) throw new Error('Too many waypoints'); const routed = await this.router.route(userId, tripId, dayId, points, profile, avoid); - const legs = routed.parts.map((part, index) => ({ - ...routed.leg.seg, - ...part, - from: [points[index].lat, points[index].lng] as [number, number], - to: [points[index + 1].lat, points[index + 1].lng] as [number, number], - })); + // Spreading the run's own seg first carried its whole-route text and + // midpoint onto every leg of a multi-stop run: `part` only overrides the + // metres and the seconds, so leg one of a 480 km day reported 100 km with + // "480 km" printed beside it. The four texts and the midpoint are per leg + // now, the way the browser has always computed them — and in the reader's + // own unit, rather than the router's hard-coded kilometres. + const legs = routed.parts.map((part, index) => { + const from = [points[index].lat, points[index].lng] as [number, number]; + const to = [points[index + 1].lat, points[index + 1].lng] as [number, number]; + const durationText = formatDurationShort(part.duration); + return { + ...routed.leg.seg, + ...part, + from, + to, + mid: [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2] as [number, number], + distanceText: formatDistance(part.distance / 1000, distanceUnit), + durationText, + drivingText: durationText, + walkingText: durationText, + }; + }); Object.assign( allLegs, foldRouteRun(stops, stopAt, { coordinates: routed.leg.line, legs, vias: routed.leg.vias }, profile), @@ -236,7 +257,7 @@ export class RoadtripPlanService { vehicleKind, connectDays, boundaries: context.boundaries, - distanceUnit: this.settings.getUserSettings(userId).distance_unit === 'imperial' ? 'imperial' : 'metric', + distanceUnit, labels: { start: 'Continue journey', end: 'End of day' }, }); return { diff --git a/server/tests/unit/nest/roadtrip-planning.test.ts b/server/tests/unit/nest/roadtrip-planning.test.ts index 37fddbcf1..c52fba167 100644 --- a/server/tests/unit/nest/roadtrip-planning.test.ts +++ b/server/tests/unit/nest/roadtrip-planning.test.ts @@ -102,9 +102,11 @@ describe('roadtrip preferences', () => { expect(() => s.preferences.update(10, { roadtrip_day_end: '06:00', roadtrip_range_km: 200 })).toThrow(); expect(s.preferenceDb.run).not.toHaveBeenCalled(); s.preferences.update(10, { roadtrip_day_start: '06:00', roadtrip_day_end: '09:00' }); + // The fourth argument is the socket that saved, so the tab that made the + // change is left out of its own echo. Undefined here: no header was sent. expect(s.realtime.broadcast).toHaveBeenCalledWith('10', 'roadtripPreferences:changed', { preferences: s.preferences.read(10), - }); + }, undefined); expect(s.preferences.read(10).roadtrip_range_km).toBe(100); }); it.each([ diff --git a/shared/src/roadtrip/assemble.spec.ts b/shared/src/roadtrip/assemble.spec.ts new file mode 100644 index 000000000..80bb6aeff --- /dev/null +++ b/shared/src/roadtrip/assemble.spec.ts @@ -0,0 +1,104 @@ +/** + * ROADTRIP-ASSEMBLE-001..003 — what the assembler does with the model's warnings. + * + * The model itself is pinned by roadtripModel.spec.ts. This file pins the + * wrapper, which is where the arguments are chosen: `deriveDriveWarnings` takes + * a two-slot "does this stop refuel" pair, slot 0 for the stop the leg leaves + * and slot 1 for the stop it arrives at, and the assembler hard-coded slot 1 to + * false. The suppression the model was written for could therefore never fire + * through it, so a day that stops at a charger 700 km in reported running dry + * at the charger, and a run of warnings that should collapse to one did not. + */ +import { assembleRoadtrip } from './assemble'; +import type { PlanDay, RoadtripStop, RoutedLeg } from './planning-types'; + +import { describe, it, expect } from 'vitest'; + +const stop = (over: Partial & { ownerIndex: number }): RoadtripStop => ({ + assignmentId: 100 + over.ownerIndex, + ownerDayId: 1, + placeId: 200 + over.ownerIndex, + name: `Stop ${over.ownerIndex}`, + lat: 50 + over.ownerIndex, + lng: 10, + time: null, + dwellMinutes: null, + legMode: null, + incomingLegMode: null, + stopType: null, + ...over, +}); + +const leg = (km: number): RoutedLeg => ({ + seg: { + mid: [50, 10], + from: [50, 10], + to: [51, 10], + distance: km * 1000, + duration: km * 40, + walkingText: '', + drivingText: '', + distanceText: `${km} km`, + mode: 'driving', + }, + line: [ + [50, 10], + [51, 10], + ], + vias: [], +}); + +const stopKey = (s: RoadtripStop): string => + `${s.lat.toFixed(5)},${s.lng.toFixed(5)},${s.legMode ?? ''},${s.incomingLegMode ?? ''}`; +const legKey = (from: RoadtripStop, to: RoadtripStop): string => `${stopKey(from)}>${stopKey(to)}`; + +function assemble(stops: RoadtripStop[], legs: number[]) { + const day: PlanDay = { dayId: 1, dayNumber: 1, date: '2026-06-01', title: null, stops }; + const allLegs: Record = {}; + legs.forEach((km, i) => { + allLegs[legKey(stops[i], stops[i + 1])] = leg(km); + }); + return assembleRoadtrip({ + plan: [day], + quietDays: [], + window: null, + distanceUnit: 'km', + allLegs, + snapByDay: {}, + missedByDay: {}, + loading: false, + // 600 km on a full battery, and no daily driving ceiling to muddy the warnings. + limits: { rangeKm: 600 }, + vehicleKind: 'electric', + connectDays: false, + boundaries: [], + labels: { start: 'start', end: 'end' }, + }); +} + +const rangeWarnings = (routes: ReturnType) => + routes.days[0].driveWarnings.filter((w) => w.code === 'range'); + +describe('assembleRoadtrip drive warnings', () => { + it('ROADTRIP-ASSEMBLE-001: a charger at the end of an out-of-range leg answers for it', () => { + const stops = [stop({ ownerIndex: 0 }), stop({ ownerIndex: 1, stopType: 'charging' })]; + + expect(rangeWarnings(assemble(stops, [700]))).toEqual([]); + }); + + it('ROADTRIP-ASSEMBLE-002: an ordinary stop at the end of the same leg still warns', () => { + const stops = [stop({ ownerIndex: 0 }), stop({ ownerIndex: 1 })]; + + // The warning belongs to the stop the leg reaches, which is where the + // traveller would be standing when the battery ran out. + expect(rangeWarnings(assemble(stops, [700]))).toEqual([expect.objectContaining({ index: 1 })]); + }); + + it('ROADTRIP-ASSEMBLE-003: the budget resets at the charger, so the leg after it is measured fresh', () => { + const stops = [stop({ ownerIndex: 0 }), stop({ ownerIndex: 1, stopType: 'charging' }), stop({ ownerIndex: 2 })]; + + // 500 after a charge is inside a 600 km range, however far the day drove + // before it. + expect(rangeWarnings(assemble(stops, [700, 500]))).toEqual([]); + }); +}); diff --git a/shared/src/roadtrip/assemble.ts b/shared/src/roadtrip/assemble.ts index edafa5895..f4f252c82 100644 --- a/shared/src/roadtrip/assemble.ts +++ b/shared/src/roadtrip/assemble.ts @@ -176,7 +176,15 @@ export function assembleRoadtrip({ for (let i = 0; i < stops.length; i++) { const incoming = inboundAt.get(i); if (incoming?.seg) { - const incomingDrive = deriveDriveWarnings([incoming.seg], [false, false], limits, carryKm); + // Slot 1 is the stop the leg ARRIVES at, which for an inbound leg is this + // one. Hard-coded false, the range warning could never be suppressed by + // the very charger or petrol station that resolves it. + const incomingDrive = deriveDriveWarnings( + [incoming.seg], + [false, refuelsRange(stops[i]!.stopType, vehicleKind)], + limits, + carryKm, + ); carryKm = incomingDrive.carryKm; drive.warnings.push(...incomingDrive.warnings.map((w) => ({ ...w, index: i }))); for (const dry of incomingDrive.emptyAt) { @@ -191,7 +199,11 @@ export function assembleRoadtrip({ const leg = routed[i]; const outgoing = deriveDriveWarnings( i < stops.length - 1 ? [leg?.seg] : [], - [refuelsRange(stops[i]!.stopType, vehicleKind), false], + // Departure, then arrival: this leg leaves stop i and reaches stop i + 1, + // so a range warning belongs to the next stop and is the next stop's to + // answer for. With false here, a day that stops at a charger 700 km in + // still reported running dry at the charger. + [refuelsRange(stops[i]!.stopType, vehicleKind), refuelsRange(stops[i + 1]?.stopType, vehicleKind)], limits, carryKm, [stops[i]!.fillPercent], diff --git a/shared/src/roadtrip/planning.ts b/shared/src/roadtrip/planning.ts index 4d21da7f5..ce88a0a0f 100644 --- a/shared/src/roadtrip/planning.ts +++ b/shared/src/roadtrip/planning.ts @@ -3,6 +3,7 @@ export * from './roadtripModel'; export * from './corridor'; export * from './nightSpill'; export * from './dayWindow'; +export * from './units'; export * from './vehicleRange'; export * from './assemble'; export * from './routeRun';