Files
TREK/client/tests/integration/hooks/useRouteCalculation.test.ts
T
Maurice 62f8b0deee fix(mobile): pick the plan timeline's hotel legs by what the route drew (#2501)
On a day that opens with a flight, where the traveller also put the
hotel on the day as a stop of their own, the phone timeline started the
day with a hotel row and the drive from that stop to the next place,
above the flight, and then showed the same drive again in its own spot.

hotelLegsForDay guessed the bookends from coordinates: the first
segment leaving the hotel's spot was the morning leg. The route had
correctly drawn no morning leg on that day, but the stop on the
hotel's spot starts a leg there too, so that hop was taken for it.

The route calculation now tags the two legs it draws from and to the
accommodation, and the timeline reads that tag instead. The hotel end
of a tagged leg still has to be the day's own stay, because the
calculation keeps the previous day's legs until the new day has
routed. The stop to stop connectors leave tagged legs alone, so one
leg can no longer show up in both places.
2026-09-26 10:36:03 +02:00

1126 lines
53 KiB
TypeScript

import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useRouteCalculation } from '../../../src/hooks/useRouteCalculation';
import { useTripStore } from '../../../src/store/tripStore';
import { hotelLegsForDay } from '../../../src/mobile/screens/trip/plan/planTimelineModel';
import { buildAssignment, buildPlace } from '../../helpers/factories';
import type { TripStoreState } from '../../../src/store/tripStore';
import type { Day, RouteSegment } from '../../../src/types';
vi.mock('../../../src/components/Map/RouteCalculator', async (importActual) => {
const actual = await importActual<typeof import('../../../src/components/Map/RouteCalculator')>();
return {
...actual,
calculateRouteWithLegs: vi.fn(),
calculateRoute: vi.fn(),
optimizeRoute: vi.fn((waypoints: unknown[]) => waypoints),
generateGoogleMapsUrl: vi.fn(),
};
});
const { calculateRouteWithLegs } = await import('../../../src/components/Map/RouteCalculator');
function buildMockStore(assignments: Record<string, ReturnType<typeof buildAssignment>[]> = {}): Partial<TripStoreState> {
// Also populate the real Zustand store so updateRouteForDay (which reads from
// useTripStore.getState()) sees the same assignments as the hook's tripStore param.
// Reset reservations and days to empty so transport-split logic doesn't interfere.
useTripStore.setState({ assignments, reservations: [], days: [] } as any);
return { assignments } as Partial<TripStoreState>;
}
const MOCK_SEGMENTS: RouteSegment[] = [
{
mid: [48.5, 2.5],
from: [48.86, 2.35],
to: [48.21, 16.37],
distance: 343000,
duration: 12600,
distanceText: '343 km',
durationText: '3 h 30 min',
walkingText: '70 h',
drivingText: '3 h 30 min',
},
];
// Empty coordinates make the hook fall back to the straight-line geometry,
// so the `route` assertions keep checking the raw waypoints while the legs
// still flow through to `routeSegments`.
const MOCK_ROUTE_WITH_LEGS = {
coordinates: [] as [number, number][],
distance: 343000,
duration: 12600,
legs: MOCK_SEGMENTS,
};
describe('useRouteCalculation', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset trip store assignments so each test starts clean
useTripStore.setState({ assignments: {} } as any);
(calculateRouteWithLegs as ReturnType<typeof vi.fn>).mockResolvedValue(MOCK_ROUTE_WITH_LEGS);
});
it('FE-HOOK-ROUTE-001: with no selectedDayId, route is null', () => {
const store = buildMockStore({});
const { result } = renderHook(() =>
useRouteCalculation(store as TripStoreState, null)
);
expect(result.current.route).toBeNull();
});
it('FE-HOOK-ROUTE-002: with < 2 waypoints, route remains null', async () => {
const place = buildPlace({ lat: 48.8566, lng: 2.3522 });
const assignment = buildAssignment({ day_id: 5, order_index: 0, place });
const store = buildMockStore({ '5': [assignment] });
const { result } = renderHook(() =>
useRouteCalculation(store as TripStoreState, 5)
);
await act(async () => {});
expect(result.current.route).toBeNull();
});
it('FE-HOOK-ROUTE-003: with ≥ 2 geo-coded assignments, sets route coordinates', async () => {
const p1 = buildPlace({ lat: 48.8566, lng: 2.3522 });
const p2 = buildPlace({ lat: 51.5074, lng: -0.1278 });
const a1 = buildAssignment({ day_id: 5, order_index: 0, place: p1 });
const a2 = buildAssignment({ day_id: 5, order_index: 1, place: p2 });
const store = buildMockStore({ '5': [a1, a2] });
const { result } = renderHook(() =>
useRouteCalculation(store as TripStoreState, 5)
);
await act(async () => {});
// route is an array of segments; no transport → single segment with all places
expect(result.current.route).toEqual([
[[p1.lat, p1.lng], [p2.lat, p2.lng]],
]);
});
it('FE-HOOK-ROUTE-004: calls calculateRouteWithLegs and exposes the returned segments', async () => {
const p1 = buildPlace({ lat: 48.8566, lng: 2.3522 });
const p2 = buildPlace({ lat: 51.5074, lng: -0.1278 });
const a1 = buildAssignment({ day_id: 5, order_index: 0, place: p1 });
const a2 = buildAssignment({ day_id: 5, order_index: 1, place: p2 });
const store = buildMockStore({ '5': [a1, a2] });
const { result } = renderHook(() =>
useRouteCalculation(store as TripStoreState, 5)
);
await act(async () => {});
expect(calculateRouteWithLegs).toHaveBeenCalled();
// Each leg is now tagged with the mode it was routed in (#1281); with no
// per-segment override or day default, that resolves to the 'driving' default.
expect(result.current.routeSegments).toEqual(MOCK_SEGMENTS.map(s => ({ ...s, mode: 'driving' })));
});
it('FE-HOOK-ROUTE-023: consecutive legs of one mode go out as a single multi-waypoint request', async () => {
// Three places routed in the day default and a fourth reached on foot: two
// requests, not three, and the connectors stay one per pair in order.
(calculateRouteWithLegs as ReturnType<typeof vi.fn>).mockImplementation(
(waypoints: { lat: number; lng: number }[]) => Promise.resolve({
coordinates: [] as [number, number][],
distance: 0,
duration: 0,
legs: waypoints.slice(0, -1).map((w, i) => ({
...MOCK_SEGMENTS[0],
from: [w.lat, w.lng] as [number, number],
to: [waypoints[i + 1].lat, waypoints[i + 1].lng] as [number, number],
})),
}),
);
const pts = [
{ lat: 48.86, lng: 2.35 }, { lat: 48.87, lng: 2.36 },
{ lat: 48.88, lng: 2.37 }, { lat: 48.89, lng: 2.38 },
];
const assignments = pts.map((pt, i) => ({
...buildAssignment({ day_id: 5, order_index: i, place: buildPlace(pt) }),
// The third place walks to the fourth; the first two use the day default.
leg_transport_mode: i === 2 ? 'walking' : null,
}));
const store = buildMockStore({ '5': assignments as never });
const { result } = renderHook(() => useRouteCalculation(store as TripStoreState, 5));
await act(async () => {});
const calls = (calculateRouteWithLegs as ReturnType<typeof vi.fn>).mock.calls;
expect(calls).toHaveLength(2);
expect(calls[0][0]).toEqual(pts.slice(0, 3));
expect(calls[0][1].profile).toBe('driving');
expect(calls[1][0]).toEqual(pts.slice(2));
expect(calls[1][1].profile).toBe('walking');
// One segment per pair, in the order they are walked.
expect(result.current.routeSegments.map(s => s.mode)).toEqual(['driving', 'driving', 'walking']);
expect(result.current.routeSegments.map(s => s.from)).toEqual([
[pts[0].lat, pts[0].lng], [pts[1].lat, pts[1].lng], [pts[2].lat, pts[2].lng],
]);
});
it('FE-HOOK-ROUTE-006: assignments are sorted by order_index before extracting waypoints', async () => {
const p1 = buildPlace({ lat: 10, lng: 10 });
const p2 = buildPlace({ lat: 20, lng: 20 });
// order_index 1 comes before 0 in the array, but should be sorted
const a1 = buildAssignment({ day_id: 5, order_index: 1, place: p1 });
const a2 = buildAssignment({ day_id: 5, order_index: 0, place: p2 });
const store = buildMockStore({ '5': [a1, a2] });
const { result } = renderHook(() =>
useRouteCalculation(store as TripStoreState, 5)
);
await act(async () => {});
// After sort: a2 (order_index=0) first, then a1 (order_index=1)
expect(result.current.route).toEqual([
[[p2.lat, p2.lng], [p1.lat, p1.lng]],
]);
});
it('FE-HOOK-ROUTE-007: assignments with no lat/lng are filtered out', async () => {
const pValid = buildPlace({ lat: 48.8566, lng: 2.3522 });
const pNoGeo = buildPlace({ lat: null as any, lng: null as any });
const a1 = buildAssignment({ day_id: 5, order_index: 0, place: pNoGeo });
const a2 = buildAssignment({ day_id: 5, order_index: 1, place: pValid });
const store = buildMockStore({ '5': [a1, a2] });
const { result } = renderHook(() =>
useRouteCalculation(store as TripStoreState, 5)
);
await act(async () => {});
// Only 1 valid waypoint → route is null
expect(result.current.route).toBeNull();
});
it('FE-HOOK-ROUTE-008: AbortController.abort() is called when selectedDayId changes', async () => {
// Make calculateRouteWithLegs resolve slowly
let resolveSegments!: (val: typeof MOCK_ROUTE_WITH_LEGS) => void;
(calculateRouteWithLegs as ReturnType<typeof vi.fn>).mockImplementationOnce(
(_waypoints: unknown[], options: { signal?: AbortSignal }) => {
return new Promise<typeof MOCK_ROUTE_WITH_LEGS>((resolve) => {
resolveSegments = resolve;
options?.signal?.addEventListener('abort', () => resolve(MOCK_ROUTE_WITH_LEGS));
});
}
);
const p1 = buildPlace({ lat: 10, lng: 10 });
const p2 = buildPlace({ lat: 20, lng: 20 });
const a1 = buildAssignment({ day_id: 5, order_index: 0, place: p1 });
const a2 = buildAssignment({ day_id: 5, order_index: 1, place: p2 });
const store1 = buildMockStore({ '5': [a1, a2], '6': [a1, a2] });
const { rerender } = renderHook(
({ dayId }: { dayId: number }) => useRouteCalculation(store1 as TripStoreState, dayId),
{ initialProps: { dayId: 5 } }
);
// Change to day 6 — should abort in-flight request for day 5
await act(async () => {
rerender({ dayId: 6 });
});
// calculateRouteWithLegs should have been called at least once for day 5
// and once more for day 6
expect((calculateRouteWithLegs as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThanOrEqual(1);
// Cleanup
resolveSegments?.(MOCK_ROUTE_WITH_LEGS);
});
it('FE-HOOK-ROUTE-009: AbortError from calculateSegments does not set routeSegments to []', async () => {
const abortError = new Error('Aborted');
abortError.name = 'AbortError';
(calculateRouteWithLegs as ReturnType<typeof vi.fn>).mockRejectedValueOnce(abortError);
const p1 = buildPlace({ lat: 10, lng: 10 });
const p2 = buildPlace({ lat: 20, lng: 20 });
const a1 = buildAssignment({ day_id: 5, order_index: 0, place: p1 });
const a2 = buildAssignment({ day_id: 5, order_index: 1, place: p2 });
const store = buildMockStore({ '5': [a1, a2] });
const { result } = renderHook(() =>
useRouteCalculation(store as TripStoreState, 5)
);
await act(async () => {});
// AbortError should be swallowed silently — segments remain empty
expect(result.current.routeSegments).toEqual([]);
});
it('FE-HOOK-ROUTE-010: non-AbortError from calculateSegments sets routeSegments to []', async () => {
(calculateRouteWithLegs as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Network error'));
const p1 = buildPlace({ lat: 10, lng: 10 });
const p2 = buildPlace({ lat: 20, lng: 20 });
const a1 = buildAssignment({ day_id: 5, order_index: 0, place: p1 });
const a2 = buildAssignment({ day_id: 5, order_index: 1, place: p2 });
const store = buildMockStore({ '5': [a1, a2] });
const { result } = renderHook(() =>
useRouteCalculation(store as TripStoreState, 5)
);
await act(async () => {});
expect(result.current.routeSegments).toEqual([]);
});
it('FE-HOOK-ROUTE-011: when selectedDayId is null, route and segments are cleared', async () => {
const p1 = buildPlace({ lat: 10, lng: 10 });
const p2 = buildPlace({ lat: 20, lng: 20 });
const a1 = buildAssignment({ day_id: 5, order_index: 0, place: p1 });
const a2 = buildAssignment({ day_id: 5, order_index: 1, place: p2 });
const store = buildMockStore({ '5': [a1, a2] });
const { result, rerender } = renderHook(
({ dayId }: { dayId: number | null }) => useRouteCalculation(store as TripStoreState, dayId),
{ initialProps: { dayId: 5 as number | null } }
);
await act(async () => {});
// Some route may have been set for day 5
await act(async () => {
rerender({ dayId: null });
});
expect(result.current.route).toBeNull();
expect(result.current.routeSegments).toEqual([]);
});
it('FE-HOOK-ROUTE-014: #1321 day-1 arrival draws no check-in-hotel → departure leg', async () => {
// Day 1 = arrival from home: a flight (departure → arrival airport) then two activities,
// checking into a hotel tonight. The morning hotel is only a check-in fallback, so the
// hotel must NOT be bookended to the flight's departure point; the evening leg stays.
const dep = { lat: 50.03, lng: 8.57 }; // home/departure airport
const arr = { lat: 41.30, lng: 2.08 }; // destination airport
const actA = buildPlace({ lat: 41.38, lng: 2.17 });
const actB = buildPlace({ lat: 41.40, lng: 2.19 });
const hotel = { lat: 41.39, lng: 2.16 };
const flight = {
id: 100, type: 'flight', day_id: 1, end_day_id: 1, day_plan_position: 0,
endpoints: [
{ role: 'from', lat: dep.lat, lng: dep.lng },
{ role: 'to', lat: arr.lat, lng: arr.lng },
],
};
const a1 = buildAssignment({ day_id: 1, order_index: 1, place: actA });
const a2 = buildAssignment({ day_id: 1, order_index: 2, place: actB });
const accommodations = [{ id: 1, start_day_id: 1, end_day_id: 2, place_lat: hotel.lat, place_lng: hotel.lng }];
// A single stable store reference (like buildMockStore) so selectedDayAssignments
// keeps its identity across renders and the effect doesn't loop.
const store = { assignments: { '1': [a1, a2] } } as unknown as TripStoreState;
useTripStore.setState({
assignments: store.assignments,
reservations: [flight],
days: [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }],
} as any);
const { result } = renderHook(() =>
useRouteCalculation(store, 1, true, 'driving', accommodations as any)
);
await act(async () => {});
const legs = (result.current.route ?? []).map(run => run.map(p => `${p[0]},${p[1]}`));
// The spurious morning bookend [hotel → departure airport] must be gone.
expect(legs).not.toContainEqual([`${hotel.lat},${hotel.lng}`, `${dep.lat},${dep.lng}`]);
// The route starts the day's run at the arrival airport, not the hotel.
expect(result.current.route?.[0]?.[0]).toEqual([arr.lat, arr.lng]);
// The evening leg [last activity → hotel] is still drawn.
expect(legs).toContainEqual([`${actB.lat},${actB.lng}`, `${hotel.lat},${hotel.lng}`]);
});
// #2071 — Frankfurt → Vancouver via Reykjavik, an airport place either side. The
// booking's position lives per LEG in metadata.legs, which this hook never read, so
// the flight vanished from the waypoint list and the two airports were joined into
// one driving run across the Atlantic.
it('FE-HOOK-ROUTE-024: a layover flight breaks the run instead of joining the airports', async () => {
const fra = { lat: 50.0379, lng: 8.5622 };
const kef = { lat: 63.985, lng: -22.6056 };
const yvr = { lat: 49.1947, lng: -123.1792 };
const fraPlace = buildPlace({ lat: fra.lat, lng: fra.lng });
const yvrPlace = buildPlace({ lat: yvr.lat, lng: yvr.lng });
const flight = {
id: 300, type: 'flight', day_id: 1, end_day_id: 1,
endpoints: [
{ role: 'from', sequence: 0, lat: fra.lat, lng: fra.lng },
{ role: 'stop', sequence: 1, lat: kef.lat, lng: kef.lng },
{ role: 'to', sequence: 2, lat: yvr.lat, lng: yvr.lng },
],
metadata: JSON.stringify({
legs: [
{ from: 'FRA', to: 'KEF', dep_time: '10:00', arr_time: '12:00', day_positions: { 1: 0.5 } },
{ from: 'KEF', to: 'YVR', dep_time: '14:00', arr_time: '16:00', day_positions: { 1: 0.6 } },
],
}),
};
const before = buildAssignment({ day_id: 1, order_index: 0, place: fraPlace });
const after = buildAssignment({ day_id: 1, order_index: 1, place: yvrPlace });
const store = { assignments: { '1': [before, after] } } as unknown as TripStoreState;
useTripStore.setState({
assignments: store.assignments,
reservations: [flight],
days: [{ id: 1, day_number: 1 }],
} as any);
const { result } = renderHook(() => useRouteCalculation(store, 1, true, 'driving'));
await act(async () => {});
const legs = (result.current.route ?? []).map(run => run.map(p => `${p[0]},${p[1]}`));
// The transatlantic pair is the bug: it must not appear in any run.
for (const run of legs) {
const i = run.indexOf(`${fra.lat},${fra.lng}`);
if (i >= 0) expect(run[i + 1]).not.toBe(`${yvr.lat},${yvr.lng}`);
}
expect(legs).not.toContainEqual([`${fra.lat},${fra.lng}`, `${yvr.lat},${yvr.lng}`]);
});
// Each leg spans its OWN pair of airports. Reading the booking's outermost two for
// every leg put a Reykjavik stop on a Vancouver → Frankfurt drive.
it('FE-HOOK-ROUTE-025: a stop between two legs routes against that leg\'s airports', async () => {
const fra = { lat: 50.0379, lng: 8.5622 };
const kef = { lat: 63.985, lng: -22.6056 };
const yvr = { lat: 49.1947, lng: -123.1792 };
const cafe = buildPlace({ lat: 64.1466, lng: -21.9426 });
const flight = {
id: 301, type: 'flight', day_id: 1, end_day_id: 1,
endpoints: [
{ role: 'from', sequence: 0, lat: fra.lat, lng: fra.lng },
{ role: 'stop', sequence: 1, lat: kef.lat, lng: kef.lng },
{ role: 'to', sequence: 2, lat: yvr.lat, lng: yvr.lng },
],
metadata: JSON.stringify({
legs: [
{ from: 'FRA', to: 'KEF', dep_time: '08:00', arr_time: '10:00', day_positions: { 1: 0.5 } },
{ from: 'KEF', to: 'YVR', dep_time: '16:00', arr_time: '18:00', day_positions: { 1: 1.5 } },
],
}),
};
const stop = buildAssignment({ day_id: 1, order_index: 1, place: cafe });
const store = { assignments: { '1': [stop] } } as unknown as TripStoreState;
useTripStore.setState({
assignments: store.assignments,
reservations: [flight],
days: [{ id: 1, day_number: 1 }],
} as any);
const { result } = renderHook(() => useRouteCalculation(store, 1, true, 'driving'));
await act(async () => {});
const legs = (result.current.route ?? []).map(run => run.map(p => `${p[0]},${p[1]}`));
// The café is reached from Reykjavik and left towards Reykjavik.
expect(legs).toContainEqual([`${kef.lat},${kef.lng}`, `${cafe.lat},${cafe.lng}`, `${kef.lat},${kef.lng}`]);
// Neither of the far airports may appear next to it.
const flat = legs.flat();
expect(flat).not.toContain(`${fra.lat},${fra.lng}`);
expect(flat).not.toContain(`${yvr.lat},${yvr.lng}`);
});
it('FE-HOOK-ROUTE-015: day-1 with a first activity timed after check-in keeps the hotel → first-activity leg', async () => {
// The check-in day is still a home-base loop when the first activity provably happens
// at/after check-in (you dropped your bags first) — the hotel → first-stop leg remains.
// Since #1597 the loop needs that time proof; un-timed activities draw no morning leg.
const actA = buildPlace({ lat: 41.38, lng: 2.17, place_time: '15:00' });
const actB = buildPlace({ lat: 41.40, lng: 2.19 });
const hotel = { lat: 41.39, lng: 2.16 };
const a1 = buildAssignment({ day_id: 1, order_index: 0, place: actA });
const a2 = buildAssignment({ day_id: 1, order_index: 1, place: actB });
const accommodations = [{ id: 1, start_day_id: 1, end_day_id: 2, check_in: '14:00', place_lat: hotel.lat, place_lng: hotel.lng }];
const store = { assignments: { '1': [a1, a2] } } as unknown as TripStoreState;
useTripStore.setState({
assignments: store.assignments,
reservations: [],
days: [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }],
} as any);
const { result } = renderHook(() =>
useRouteCalculation(store, 1, true, 'driving', accommodations as any)
);
await act(async () => {});
const legs = (result.current.route ?? []).map(run => run.map(p => `${p[0]},${p[1]}`));
expect(legs).toContainEqual([`${hotel.lat},${hotel.lng}`, `${actA.lat},${actA.lng}`]);
expect(legs).toContainEqual([`${actB.lat},${actB.lng}`, `${hotel.lat},${hotel.lng}`]);
});
it('FE-HOOK-ROUTE-022: #1597 check-in day with an un-timed place and a transport starts at the place, not the hotel', async () => {
// Day 1 of a driving holiday: leave "Home" (no time set), cross by tunnel/ferry, and
// check into a hotel near the arrival port tonight. With a 19:00 check-in recorded,
// an un-timed Home cannot prove it was reached after check-in, so no hotel → Home
// leg — the route starts at Home and still ends at the hotel. (The same day with
// NO check-in time recorded is FE-HOOK-ROUTE-030.)
const home = buildPlace({ lat: 52.48, lng: -1.90 }); // un-timed "Home"
const dep = { lat: 51.09, lng: 1.12 }; // Folkestone terminal
const arr = { lat: 50.94, lng: 1.81 }; // Calais terminal
const hotel = { lat: 50.95, lng: 1.85 };
const tunnel = {
id: 200, type: 'train', day_id: 1, end_day_id: 1, day_plan_position: 1,
endpoints: [
{ role: 'from', lat: dep.lat, lng: dep.lng },
{ role: 'to', lat: arr.lat, lng: arr.lng },
],
};
const a1 = buildAssignment({ day_id: 1, order_index: 0, place: home });
const accommodations = [{ id: 1, start_day_id: 1, end_day_id: 2, check_in: '19:00', place_lat: hotel.lat, place_lng: hotel.lng }];
const store = { assignments: { '1': [a1] } } as unknown as TripStoreState;
useTripStore.setState({
assignments: store.assignments,
reservations: [tunnel],
days: [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }],
} as any);
const { result } = renderHook(() =>
useRouteCalculation(store, 1, true, 'driving', accommodations as any)
);
await act(async () => {});
const legs = (result.current.route ?? []).map(run => run.map(p => `${p[0]},${p[1]}`));
// No phantom morning bookend [hotel → Home].
expect(legs).not.toContainEqual([`${hotel.lat},${hotel.lng}`, `${home.lat},${home.lng}`]);
// The day starts at Home and drives to the departure terminal.
expect(result.current.route?.[0]?.[0]).toEqual([home.lat, home.lng]);
// The evening leg [arrival terminal → hotel] is still drawn.
expect(legs).toContainEqual([`${arr.lat},${arr.lng}`, `${hotel.lat},${hotel.lng}`]);
});
it('FE-HOOK-ROUTE-016: #1297 transfer day with no activities draws the hotel → hotel leg', async () => {
// Day 2 is a pure transfer: check out of hotel A (slept there last night) and into
// hotel B tonight, with no activities or transport. The map must still draw A → B.
const hotelA = { lat: 48.86, lng: 2.35 };
const hotelB = { lat: 45.76, lng: 4.84 };
const accommodations = [
{ id: 1, start_day_id: 1, end_day_id: 2, place_lat: hotelA.lat, place_lng: hotelA.lng },
{ id: 2, start_day_id: 2, end_day_id: 3, place_lat: hotelB.lat, place_lng: hotelB.lng },
];
const store = { assignments: {} } as unknown as TripStoreState;
useTripStore.setState({
assignments: {},
reservations: [],
days: [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }, { id: 3, day_number: 3 }],
} as any);
const { result } = renderHook(() =>
useRouteCalculation(store, 2, true, 'driving', accommodations as any)
);
await act(async () => {});
const legs = (result.current.route ?? []).map(run => run.map(p => `${p[0]},${p[1]}`));
expect(legs).toContainEqual([`${hotelA.lat},${hotelA.lng}`, `${hotelB.lat},${hotelB.lng}`]);
});
it('FE-HOOK-ROUTE-017: #1297 rest day in one hotel with no activities draws nothing', async () => {
// Guard against a zero-length loop: morning and evening hotel are the same, no
// activities — no transfer leg should be drawn.
const hotel = { lat: 48.86, lng: 2.35 };
const accommodations = [
{ id: 1, start_day_id: 1, end_day_id: 4, place_lat: hotel.lat, place_lng: hotel.lng },
];
const store = { assignments: {} } as unknown as TripStoreState;
useTripStore.setState({
assignments: {},
reservations: [],
days: [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }, { id: 3, day_number: 3 }],
} as any);
const { result } = renderHook(() =>
useRouteCalculation(store, 2, true, 'driving', accommodations as any)
);
await act(async () => {});
expect(result.current.route).toBeNull();
});
it('FE-HOOK-ROUTE-020: #1465 check-in day with a place before check-in draws no hotel → first-stop leg', async () => {
// Airport (10:00) and a museum (12:00) on the check-in day, both before the 15:00 check-in.
// You reach them before dropping your bags, so the day starts at the airport — no hotel →
// airport leg. The evening leg to the hotel (where you sleep tonight) still stands.
const airport = buildPlace({ lat: 41.30, lng: 2.08, place_time: '10:00' });
const museum = buildPlace({ lat: 41.38, lng: 2.17, place_time: '12:00' });
const hotel = { lat: 41.39, lng: 2.16 };
const a1 = buildAssignment({ day_id: 1, order_index: 0, place: airport });
const a2 = buildAssignment({ day_id: 1, order_index: 1, place: museum });
const accommodations = [{ id: 1, start_day_id: 1, end_day_id: 2, check_in: '15:00', place_lat: hotel.lat, place_lng: hotel.lng }];
const store = { assignments: { '1': [a1, a2] } } as unknown as TripStoreState;
useTripStore.setState({
assignments: store.assignments,
reservations: [],
days: [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }],
} as any);
const { result } = renderHook(() =>
useRouteCalculation(store, 1, true, 'driving', accommodations as any)
);
await act(async () => {});
const legs = (result.current.route ?? []).map(run => run.map(p => `${p[0]},${p[1]}`));
// No spurious morning bookend [hotel → airport].
expect(legs).not.toContainEqual([`${hotel.lat},${hotel.lng}`, `${airport.lat},${airport.lng}`]);
// The day starts at the airport, and still ends at the hotel for the night.
expect(result.current.route?.[0]?.[0]).toEqual([airport.lat, airport.lng]);
expect(legs).toContainEqual([`${museum.lat},${museum.lng}`, `${hotel.lat},${hotel.lng}`]);
});
it('FE-HOOK-ROUTE-021: #1465 check-out day with a place after check-out draws no last-stop → hotel leg', async () => {
// Day 2 is the hotel's check-out day (11:00). You wake there, visit a museum (09:00), then
// head "home" (18:00, after check-out). Having left the hotel, there is no return leg — but
// the morning leg [hotel → museum] (you slept there) stays.
const museum = buildPlace({ lat: 41.38, lng: 2.17, place_time: '09:00' });
const home = buildPlace({ lat: 41.10, lng: 1.80, place_time: '18:00' });
const hotel = { lat: 41.39, lng: 2.16 };
const a1 = buildAssignment({ day_id: 2, order_index: 0, place: museum });
const a2 = buildAssignment({ day_id: 2, order_index: 1, place: home });
const accommodations = [{ id: 1, start_day_id: 1, end_day_id: 2, check_out: '11:00', place_lat: hotel.lat, place_lng: hotel.lng }];
const store = { assignments: { '2': [a1, a2] } } as unknown as TripStoreState;
useTripStore.setState({
assignments: store.assignments,
reservations: [],
days: [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }],
} as any);
const { result } = renderHook(() =>
useRouteCalculation(store, 2, true, 'driving', accommodations as any)
);
await act(async () => {});
const legs = (result.current.route ?? []).map(run => run.map(p => `${p[0]},${p[1]}`));
// No spurious evening bookend [home → hotel].
expect(legs).not.toContainEqual([`${home.lat},${home.lng}`, `${hotel.lat},${hotel.lng}`]);
// The morning leg from the slept-in hotel is still drawn, and the day ends at home.
expect(legs).toContainEqual([`${hotel.lat},${hotel.lng}`, `${museum.lat},${museum.lng}`]);
const flat = result.current.route ?? [];
expect(flat[flat.length - 1]?.[flat[flat.length - 1].length - 1]).toEqual([home.lat, home.lng]);
});
it('FE-HOOK-ROUTE-012: setRoute and setRouteInfo are exposed', () => {
const store = buildMockStore({});
const { result } = renderHook(() =>
useRouteCalculation(store as TripStoreState, null)
);
expect(result.current.setRoute).toBeTypeOf('function');
expect(result.current.setRouteInfo).toBeTypeOf('function');
});
it('FE-HOOK-ROUTE-013: route recalculates when assignments change via store update', async () => {
const p1 = buildPlace({ lat: 10, lng: 10 });
const p2 = buildPlace({ lat: 20, lng: 20 });
const a1 = buildAssignment({ day_id: 5, order_index: 0, place: p1 });
const a2 = buildAssignment({ day_id: 5, order_index: 1, place: p2 });
let storeData = buildMockStore({ '5': [a1, a2] });
const { result, rerender } = renderHook(() =>
useRouteCalculation(storeData as TripStoreState, 5)
);
await act(async () => {});
expect(result.current.route).toEqual([
[[p1.lat, p1.lng], [p2.lat, p2.lng]],
]);
// Now add a third place — update both the local store object and the Zustand store
const p3 = buildPlace({ lat: 30, lng: 30 });
const a3 = buildAssignment({ day_id: 5, order_index: 2, place: p3 });
storeData = buildMockStore({ '5': [a1, a2, a3] }); // also calls useTripStore.setState
await act(async () => {
rerender();
});
await act(async () => {});
expect(result.current.route).toEqual([
[[p1.lat, p1.lng], [p2.lat, p2.lng], [p3.lat, p3.lng]],
]);
});
it('FE-HOOK-ROUTE-018: two flights on one day are not road-routed airport→airport (#1394)', async () => {
// Two single-day flights, no place between them. The arrival of the first and the
// departure of the second must NOT be joined into a phantom driving run — that leg
// is the flight itself, not a drive.
const store = buildMockStore({ '5': [] });
useTripStore.setState({
reservations: [
{ id: 1, type: 'flight', day_id: 5, end_day_id: 5, day_positions: { 5: 0 },
endpoints: [{ role: 'from', lat: 52.5, lng: 13.4 }, { role: 'to', lat: 42.4, lng: 18.7 }] },
{ id: 2, type: 'flight', day_id: 5, end_day_id: 5, day_positions: { 5: 1 },
endpoints: [{ role: 'from', lat: 50.1, lng: 14.3 }, { role: 'to', lat: 42.4, lng: 18.9 }] },
],
days: [{ id: 5, day_number: 1 }],
} as any);
const { result } = renderHook(() =>
useRouteCalculation(store as TripStoreState, 5)
);
await act(async () => {});
// No real place anywhere on the day → nothing is a drive → no route is drawn.
// Before the fix this produced a bogus [flight1.arrival → flight2.departure] leg.
expect(result.current.route).toBeNull();
expect(result.current.routeSegments).toEqual([]);
});
it('FE-HOOK-ROUTE-026: an overnight flight draws no departure-airport → hotel leg (#2133)', async () => {
// The reporter's literal repro: add a flight on a day, add an accommodation that
// checks in tonight. The flight leaves today and lands tomorrow, so the day
// contributes ONLY its departure airport — and that airport was being joined to
// the hotel by a straight line, as if you had driven back from it after taking off.
const icn = { lat: 37.46, lng: 126.44 }; // Seoul Incheon
const hotel = { lat: 21.28, lng: -157.83 }; // Waikiki
const flight = {
id: 300, type: 'flight', day_id: 1, end_day_id: 2, day_positions: { 1: 0 },
endpoints: [
{ role: 'from', lat: icn.lat, lng: icn.lng },
{ role: 'to', lat: 21.32, lng: -157.92 },
],
};
const accommodations = [{ id: 1, start_day_id: 1, end_day_id: 3, place_lat: hotel.lat, place_lng: hotel.lng }];
const store = { assignments: { '1': [] } } as unknown as TripStoreState;
useTripStore.setState({
assignments: store.assignments,
reservations: [flight],
days: [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }, { id: 3, day_number: 3 }],
} as any);
const { result } = renderHook(() =>
useRouteCalculation(store, 1, true, 'driving', accommodations as any)
);
await act(async () => {});
const legs = (result.current.route ?? []).map(run => run.map(p => `${p[0]},${p[1]}`));
expect(legs).not.toContainEqual([`${icn.lat},${icn.lng}`, `${hotel.lat},${hotel.lng}`]);
expect(legs).toEqual([]);
});
it('FE-HOOK-ROUTE-027: a far-away airport is never stapled to the local stops of a day (#2133)', async () => {
// The screenshot: a Honolulu day with local stops and an inbound long-haul whose
// DEPARTURE clock (18:30 in Seoul) sorts it after them. Its Seoul departure airport
// was being appended to the run of Waikiki stops, and the road router answers that
// pair with NoRoute, so the whole run fell back to one straight trans-Pacific line.
const waikiki = buildPlace({ lat: 21.28, lng: -157.83, place_time: '12:00' });
const diamond = buildPlace({ lat: 21.26, lng: -157.80, place_time: '15:00' });
const icn = { lat: 37.46, lng: 126.44 };
const hnl = { lat: 21.32, lng: -157.92 };
const a1 = buildAssignment({ day_id: 1, order_index: 0, place: waikiki });
const a2 = buildAssignment({ day_id: 1, order_index: 1, place: diamond });
const flight = {
id: 301, type: 'flight', day_id: 1, end_day_id: 1, reservation_time: '18:30',
endpoints: [
{ role: 'from', lat: icn.lat, lng: icn.lng },
{ role: 'to', lat: hnl.lat, lng: hnl.lng },
],
};
const store = { assignments: { '1': [a1, a2] } } as unknown as TripStoreState;
useTripStore.setState({
assignments: store.assignments,
reservations: [flight],
days: [{ id: 1, day_number: 1 }],
} as any);
const { result } = renderHook(() =>
useRouteCalculation(store, 1, true, 'driving')
);
await act(async () => {});
const flat = (result.current.route ?? []).flat().map(p => `${p[0]},${p[1]}`);
// Seoul never appears in a driving run drawn over Oahu.
expect(flat).not.toContain(`${icn.lat},${icn.lng}`);
// The genuine local drive between the two Waikiki stops survives.
const legs = (result.current.route ?? []).map(run => run.map(p => `${p[0]},${p[1]}`));
expect(legs).toContainEqual([`${waikiki.lat},${waikiki.lng}`, `${diamond.lat},${diamond.lng}`]);
});
it('FE-HOOK-ROUTE-028: a hire car pickup still draws its leg to the hotel tonight (#2133)', async () => {
// The counter-case the fix must not eat. A multi-day rental contributes only its
// PICKUP point on the collection day — the same "one endpoint" shape as the
// overnight flight above — but you keep driving the car, so the drive from the
// depot to tonight's hotel is real and must stay.
const depot = { lat: 48.35, lng: 11.78 }; // Munich airport depot
const hotel = { lat: 48.14, lng: 11.58 }; // Munich city hotel
const rental = {
id: 302, type: 'car', day_id: 1, end_day_id: 3, day_positions: { 1: 0 },
endpoints: [
{ role: 'from', lat: depot.lat, lng: depot.lng },
{ role: 'to', lat: 52.5, lng: 13.4 },
],
};
const accommodations = [{ id: 1, start_day_id: 1, end_day_id: 3, place_lat: hotel.lat, place_lng: hotel.lng }];
const store = { assignments: { '1': [] } } as unknown as TripStoreState;
useTripStore.setState({
assignments: store.assignments,
reservations: [rental],
days: [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }, { id: 3, day_number: 3 }],
} as any);
const { result } = renderHook(() =>
useRouteCalculation(store, 1, true, 'driving', accommodations as any)
);
await act(async () => {});
const legs = (result.current.route ?? []).map(run => run.map(p => `${p[0]},${p[1]}`));
expect(legs).toContainEqual([`${depot.lat},${depot.lng}`, `${hotel.lat},${hotel.lng}`]);
});
it('FE-HOOK-ROUTE-029: you still drive from the hotel to your outbound airport (#2133)', async () => {
// The legitimate morning mirror. A same-day flight contributes both endpoints, so
// the day opens on its DEPARTURE airport — a drive you really make.
const hotel = { lat: 48.14, lng: 11.58 };
const muc = { lat: 48.35, lng: 11.78 };
const flight = {
id: 303, type: 'flight', day_id: 3, end_day_id: 3, day_positions: { 3: 0 },
endpoints: [
{ role: 'from', lat: muc.lat, lng: muc.lng },
{ role: 'to', lat: 41.80, lng: 12.25 },
],
};
const accommodations = [{ id: 1, start_day_id: 1, end_day_id: 3, place_lat: hotel.lat, place_lng: hotel.lng }];
const store = { assignments: { '3': [] } } as unknown as TripStoreState;
useTripStore.setState({
assignments: store.assignments,
reservations: [flight],
days: [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }, { id: 3, day_number: 3 }],
} as any);
const { result } = renderHook(() =>
useRouteCalculation(store, 3, true, 'driving', accommodations as any)
);
await act(async () => {});
const legs = (result.current.route ?? []).map(run => run.map(p => `${p[0]},${p[1]}`));
expect(legs).toContainEqual([`${hotel.lat},${hotel.lng}`, `${muc.lat},${muc.lng}`]);
});
it('FE-HOOK-ROUTE-030: #2157 arrival day with no check-in time draws no hotel → Home leg when you fly in', async () => {
// The reported day 1: "Home" (untimed), the outbound flight, and a hotel that
// checks in tonight with neither check-in nor check-out recorded. The no-time
// default was closing the loop from a hotel 2400 km away — the 47-hour leg in
// the screenshot. Flying means the hotel is the day's destination, not its start.
const home = buildPlace({ lat: 50.11, lng: 8.68 }); // untimed "Home"
const dep = { lat: 50.03, lng: 8.57 }; // Frankfurt airport
const arr = { lat: 63.99, lng: -22.61 }; // Keflavík
const hotel = { lat: 64.14, lng: -21.94 }; // Reykjavík
const flight = {
id: 400, type: 'flight', day_id: 1, end_day_id: 1, day_plan_position: 1,
endpoints: [
{ role: 'from', lat: dep.lat, lng: dep.lng },
{ role: 'to', lat: arr.lat, lng: arr.lng },
],
};
const a1 = buildAssignment({ day_id: 1, order_index: 0, place: home });
const accommodations = [{ id: 1, start_day_id: 1, end_day_id: 2, place_lat: hotel.lat, place_lng: hotel.lng }];
const store = { assignments: { '1': [a1] } } as unknown as TripStoreState;
useTripStore.setState({
assignments: store.assignments,
reservations: [flight],
days: [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }],
} as any);
const { result } = renderHook(() =>
useRouteCalculation(store, 1, true, 'driving', accommodations as any)
);
await act(async () => {});
const legs = (result.current.route ?? []).map(run => run.map(p => `${p[0]},${p[1]}`));
// No phantom morning bookend [hotel → Home].
expect(legs).not.toContainEqual([`${hotel.lat},${hotel.lng}`, `${home.lat},${home.lng}`]);
// The day starts at Home and drives to the departure airport...
expect(result.current.route?.[0]?.[0]).toEqual([home.lat, home.lng]);
expect(legs).toContainEqual([`${home.lat},${home.lng}`, `${dep.lat},${dep.lng}`]);
// ...and the evening leg [arrival airport → hotel] is still drawn.
expect(legs).toContainEqual([`${arr.lat},${arr.lng}`, `${hotel.lat},${hotel.lng}`]);
});
it('FE-HOOK-ROUTE-031: #2157 departure day with no check-out time draws no Home → hotel leg after you fly out', async () => {
// The reported last day: fly home, then "Home" (untimed). The hotel you left that
// morning must not be routed back to from Home — but the morning drive
// [hotel → departure airport] is real and stays.
const hotel = { lat: 64.14, lng: -21.94 }; // Reykjavík
const dep = { lat: 63.99, lng: -22.61 }; // Keflavík
const arr = { lat: 50.03, lng: 8.57 }; // Frankfurt airport
const home = buildPlace({ lat: 50.11, lng: 8.68 }); // untimed "Home"
const flight = {
id: 401, type: 'flight', day_id: 2, end_day_id: 2, day_plan_position: 0,
endpoints: [
{ role: 'from', lat: dep.lat, lng: dep.lng },
{ role: 'to', lat: arr.lat, lng: arr.lng },
],
};
const a1 = buildAssignment({ day_id: 2, order_index: 1, place: home });
const accommodations = [{ id: 1, start_day_id: 1, end_day_id: 2, place_lat: hotel.lat, place_lng: hotel.lng }];
const store = { assignments: { '2': [a1] } } as unknown as TripStoreState;
useTripStore.setState({
assignments: store.assignments,
reservations: [flight],
days: [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }],
} as any);
const { result } = renderHook(() =>
useRouteCalculation(store, 2, true, 'driving', accommodations as any)
);
await act(async () => {});
const legs = (result.current.route ?? []).map(run => run.map(p => `${p[0]},${p[1]}`));
// No phantom evening bookend [Home → hotel].
expect(legs).not.toContainEqual([`${home.lat},${home.lng}`, `${hotel.lat},${hotel.lng}`]);
// The real morning drive [hotel → departure airport] stays...
expect(legs).toContainEqual([`${hotel.lat},${hotel.lng}`, `${dep.lat},${dep.lng}`]);
// ...and the day ends at Home.
const runs = result.current.route ?? [];
expect(runs[runs.length - 1]?.slice(-1)[0]).toEqual([home.lat, home.lng]);
});
it('FE-HOOK-ROUTE-032: #2157 no-time check-out day without any transport still draws no ocean leg back', async () => {
// The issue's literal steps mention no flight at all: just "Home" on the last
// day. With no carrier recorded, an edge place no drive of the hotel could
// reach must still not close the loop across the ocean.
const hotel = { lat: 64.14, lng: -21.94 }; // Reykjavík
const home = buildPlace({ lat: 50.11, lng: 8.68 }); // untimed "Home", ~2400 km away
const a1 = buildAssignment({ day_id: 2, order_index: 0, place: home });
const accommodations = [{ id: 1, start_day_id: 1, end_day_id: 2, place_lat: hotel.lat, place_lng: hotel.lng }];
const store = { assignments: { '2': [a1] } } as unknown as TripStoreState;
useTripStore.setState({
assignments: store.assignments,
reservations: [],
days: [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }],
} as any);
const { result } = renderHook(() =>
useRouteCalculation(store, 2, true, 'driving', accommodations as any)
);
await act(async () => {});
const legs = (result.current.route ?? []).map(run => run.map(p => `${p[0]},${p[1]}`));
// No return leg [Home → hotel] after checking out.
expect(legs).not.toContainEqual([`${home.lat},${home.lng}`, `${hotel.lat},${hotel.lng}`]);
// The one real journey — you woke at the hotel and travelled home — stays.
expect(legs).toContainEqual([`${hotel.lat},${hotel.lng}`, `${home.lat},${home.lng}`]);
});
it('FE-HOOK-ROUTE-033: #2157 the reporter drives home, and 508 km is still no way back to the hotel', async () => {
// FE-HOOK-ROUTE-032 with the trip the issue actually describes: a car holiday
// inside Germany, no booking of any kind, and a last day that ends at "Home" in
// Frankfurt, 508 km from the Warnemünde hotel. Every road router happily answers
// that pair, which is why a reachability limit left the reported leg on the map.
const hotel = { lat: 54.18, lng: 12.08 }; // Warnemünde
const home = buildPlace({ lat: 50.11, lng: 8.68 }); // untimed "Home"
const a1 = buildAssignment({ day_id: 2, order_index: 0, place: home });
const accommodations = [{ id: 1, start_day_id: 1, end_day_id: 2, place_lat: hotel.lat, place_lng: hotel.lng }];
const store = { assignments: { '2': [a1] } } as unknown as TripStoreState;
useTripStore.setState({
assignments: store.assignments,
reservations: [],
days: [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }],
} as any);
const { result } = renderHook(() =>
useRouteCalculation(store, 2, true, 'driving', accommodations as any)
);
await act(async () => {});
const legs = (result.current.route ?? []).map(run => run.map(p => `${p[0]},${p[1]}`));
expect(legs).not.toContainEqual([`${home.lat},${home.lng}`, `${hotel.lat},${hotel.lng}`]);
expect(legs).toContainEqual([`${hotel.lat},${hotel.lng}`, `${home.lat},${home.lng}`]);
});
it('FE-HOOK-ROUTE-034: #2157 the same drive on the arrival day starts at Home, not at the hotel', async () => {
// The reporter's second screenshot: day one of the car holiday, "Home" untimed, and
// a hotel checking in tonight with no check-in time. The day leaves Frankfurt for
// Warnemünde, so nothing drove out of that hotel this morning.
const hotel = { lat: 54.18, lng: 12.08 }; // Warnemünde
const home = buildPlace({ lat: 50.11, lng: 8.68 }); // untimed "Home"
const a1 = buildAssignment({ day_id: 1, order_index: 0, place: home });
const accommodations = [{ id: 1, start_day_id: 1, end_day_id: 2, place_lat: hotel.lat, place_lng: hotel.lng }];
const store = { assignments: { '1': [a1] } } as unknown as TripStoreState;
useTripStore.setState({
assignments: store.assignments,
reservations: [],
days: [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }],
} as any);
const { result } = renderHook(() =>
useRouteCalculation(store, 1, true, 'driving', accommodations as any)
);
await act(async () => {});
const legs = (result.current.route ?? []).map(run => run.map(p => `${p[0]},${p[1]}`));
expect(legs).not.toContainEqual([`${hotel.lat},${hotel.lng}`, `${home.lat},${home.lng}`]);
// Tonight's hotel is still where the day ends.
expect(legs).toContainEqual([`${home.lat},${home.lng}`, `${hotel.lat},${hotel.lng}`]);
});
it('FE-HOOK-ROUTE-035: #2476 a moving day with a flight saved without airports draws no hotel → hotel drive', async () => {
// FE-HOOK-ROUTE-016 with the flight the day was actually travelled on. It carries
// no coordinates, so nothing is left to draw, and the fallback drive from one
// hotel to the other is exactly the stretch that was flown.
const hotelA = { lat: 48.137, lng: 11.575 };
const hotelB = { lat: 53.551, lng: 9.993 };
const accommodations = [
{ id: 1, start_day_id: 1, end_day_id: 2, place_lat: hotelA.lat, place_lng: hotelA.lng },
{ id: 2, start_day_id: 2, end_day_id: 3, place_lat: hotelB.lat, place_lng: hotelB.lng },
];
const flight = {
id: 80, 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: [],
};
const store = { assignments: {} } as unknown as TripStoreState;
useTripStore.setState({
assignments: {},
reservations: [flight],
days: [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }, { id: 3, day_number: 3 }],
} as unknown as Partial<TripStoreState>);
const { result } = renderHook(() =>
useRouteCalculation(store, 2, true, 'driving', accommodations as unknown as Parameters<typeof useRouteCalculation>[4])
);
await act(async () => {});
expect(result.current.route).toBeNull();
expect(calculateRouteWithLegs).not.toHaveBeenCalled();
});
describe('hotel bookend legs are tagged on the segments (#2501)', () => {
// Every request answers one leg per neighbouring waypoint pair, echoing the
// waypoints the way OSRM's legs do.
const echoLegs = () => (calculateRouteWithLegs as ReturnType<typeof vi.fn>).mockImplementation(
(waypoints: { lat: number; lng: number }[]) => Promise.resolve({
coordinates: [] as [number, number][],
distance: 0,
duration: 0,
legs: waypoints.slice(0, -1).map((w, i) => ({
...MOCK_SEGMENTS[0],
from: [w.lat, w.lng] as [number, number],
to: [waypoints[i + 1].lat, waypoints[i + 1].lng] as [number, number],
})),
}),
);
const tagsOf = (segments: RouteSegment[]) =>
segments.map(s => [`${s.from[0]},${s.from[1]} > ${s.to[0]},${s.to[1]}`, s.hotelBookend ?? null]);
it('FE-HOOK-ROUTE-036: a day out of the hotel tags the drive out and the drive back, nothing else', async () => {
echoLegs();
const hotel = { lat: 41.39, lng: 2.16 };
const actA = buildPlace({ lat: 41.38, lng: 2.17 });
const actB = buildPlace({ lat: 41.40, lng: 2.19 });
const store = { assignments: { '2': [
buildAssignment({ day_id: 2, order_index: 0, place: actA }),
buildAssignment({ day_id: 2, order_index: 1, place: actB }),
] } } as unknown as TripStoreState;
useTripStore.setState({
assignments: store.assignments,
reservations: [],
days: [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }, { id: 3, day_number: 3 }],
} as unknown as Partial<TripStoreState>);
const accommodations = [{ id: 1, start_day_id: 1, end_day_id: 3, place_lat: hotel.lat, place_lng: hotel.lng }];
const { result } = renderHook(() =>
useRouteCalculation(store, 2, true, 'driving', accommodations as unknown as Parameters<typeof useRouteCalculation>[4])
);
await act(async () => {});
expect(tagsOf(result.current.routeSegments)).toEqual([
[`${hotel.lat},${hotel.lng} > ${actA.lat},${actA.lng}`, 'morning'],
[`${actA.lat},${actA.lng} > ${actB.lat},${actB.lng}`, null],
[`${actB.lat},${actB.lng} > ${hotel.lat},${hotel.lng}`, 'evening'],
]);
});
it('FE-HOOK-ROUTE-037: a hotel stop of your own after landing is no morning bookend', async () => {
// The reported day: fly in, drive to the hotel you put on the day yourself, then
// on to the town hall. Only the drive back to the hotel tonight is a bookend.
echoLegs();
const hotel = { lat: 53.5465, lng: 9.9727 };
const ams = { lat: 52.3105, lng: 4.7683 };
const ham = { lat: 53.6304, lng: 9.9882 };
const hotelStop = buildPlace({ lat: hotel.lat, lng: hotel.lng });
const townHall = buildPlace({ lat: 53.5503, lng: 9.9937 });
const flight = {
id: 90, type: 'flight', day_id: 1, end_day_id: 1, day_plan_position: -0.5,
reservation_time: '2026-10-19T10:00', reservation_end_time: '2026-10-19T13:00',
endpoints: [
{ role: 'from', sequence: 0, lat: ams.lat, lng: ams.lng },
{ role: 'to', sequence: 1, lat: ham.lat, lng: ham.lng },
],
};
const store = { assignments: { '1': [
buildAssignment({ day_id: 1, order_index: 0, place: hotelStop }),
buildAssignment({ day_id: 1, order_index: 1, place: townHall }),
] } } as unknown as TripStoreState;
useTripStore.setState({
assignments: store.assignments,
reservations: [flight],
days: [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }, { id: 3, day_number: 3 }],
} as unknown as Partial<TripStoreState>);
const accommodations = [{ id: 1, start_day_id: 1, end_day_id: 3, check_in: '15:00', check_out: '11:00', place_lat: hotel.lat, place_lng: hotel.lng }];
const { result } = renderHook(() =>
useRouteCalculation(store, 1, true, 'driving', accommodations as unknown as Parameters<typeof useRouteCalculation>[4])
);
await act(async () => {});
expect(tagsOf(result.current.routeSegments)).toEqual([
[`${ham.lat},${ham.lng} > ${hotel.lat},${hotel.lng}`, null],
[`${hotel.lat},${hotel.lng} > ${townHall.lat},${townHall.lng}`, null],
[`${townHall.lat},${townHall.lng} > ${hotel.lat},${hotel.lng}`, 'evening'],
]);
});
it('FE-HOOK-ROUTE-038: after a day switch the last day\'s bookends are no legs of the new day\'s hotel', async () => {
// Day 1 sleeps in Munich, day 3 in Hamburg. The segments of day 1 stay in place
// until day 3 has routed, and that request may take a while or never answer.
echoLegs();
const days = [1, 2, 3, 4].map(n => ({ id: n, trip_id: 1, day_number: n, date: `2026-05-0${n}`, title: null }));
const munich = { id: 1, trip_id: 1, place_name: 'Munich Inn', place_lat: 48.137, place_lng: 11.575, start_day_id: 1, end_day_id: 2 };
const hamburg = { id: 2, trip_id: 1, place_name: 'Hamburg Inn', place_lat: 53.551, place_lng: 9.993, start_day_id: 2, end_day_id: 4 };
const stays = [munich, hamburg] as unknown as Parameters<typeof useRouteCalculation>[4];
const store = { assignments: {
'1': [
buildAssignment({ day_id: 1, order_index: 0, place: buildPlace({ lat: 48.14, lng: 11.58 }) }),
buildAssignment({ day_id: 1, order_index: 1, place: buildPlace({ lat: 48.15, lng: 11.59 }) }),
],
'3': [
buildAssignment({ day_id: 3, order_index: 0, place: buildPlace({ lat: 53.56, lng: 9.99 }) }),
buildAssignment({ day_id: 3, order_index: 1, place: buildPlace({ lat: 53.57, lng: 10.0 }) }),
],
} } as unknown as TripStoreState;
useTripStore.setState({ assignments: store.assignments, reservations: [], days } as unknown as Partial<TripStoreState>);
const legsOf = (dayId: number, segments: RouteSegment[]) =>
hotelLegsForDay(days[dayId - 1] as unknown as Day, days as unknown as Day[], stays, segments);
const { result, rerender } = renderHook(
({ dayId }: { dayId: number }) => useRouteCalculation(store, dayId, true, 'driving', stays),
{ initialProps: { dayId: 1 } },
);
await act(async () => {});
expect(legsOf(1, result.current.routeSegments)).toMatchObject({ top: { name: 'Munich Inn' }, bottom: { name: 'Munich Inn' } });
(calculateRouteWithLegs as ReturnType<typeof vi.fn>).mockImplementation(() => new Promise(() => {}));
rerender({ dayId: 3 });
await act(async () => {});
// Day 1's legs are still there, and Hamburg shows none of them.
expect(result.current.routeSegments.some(s => s.hotelBookend)).toBe(true);
expect(legsOf(3, result.current.routeSegments)).toEqual({ top: null, bottom: null });
});
});
});